Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ffcea0cb07 | ||
|
|
a3f8314c9a | ||
|
|
d5ccb1091a | ||
|
|
528774d640 | ||
|
|
3a9ea9e330 | ||
|
|
25f2d9d0e4 | ||
|
|
5ab6778b97 | ||
|
|
26697472cf | ||
|
|
2abd4ffcb6 | ||
|
|
a9ac303d3e | ||
|
|
740602b9bf | ||
|
|
e31a588f19 | ||
|
|
cda626be10 | ||
|
|
b7ce1d467c | ||
|
|
52fb999af6 | ||
|
|
e0453fa07d | ||
|
|
2525a12264 | ||
|
|
8e09dd85f8 | ||
|
|
addb3d1b32 | ||
|
|
86884e368e | ||
|
|
51f881681b | ||
|
|
27483ae1d6 | ||
|
|
595c29e17c | ||
|
|
acf93a1f36 | ||
|
|
84278293df | ||
|
|
dc24d80ed4 | ||
|
|
3810af66da | ||
|
|
eb339195c7 | ||
|
|
5d1065c1bc | ||
|
|
6ffb5bd8c1 | ||
|
|
dddf79ec2f | ||
|
|
5ebb4cb506 | ||
|
|
0b1d905866 | ||
|
|
52c15adaf2 | ||
|
|
3372626e34 | ||
|
|
98bb841a6a | ||
|
|
98bf0a9d66 | ||
|
|
17bb14fb18 | ||
|
|
70f98b352f | ||
|
|
efa4aeeeda | ||
|
|
a7d41eb5f3 | ||
|
|
76eead976f | ||
|
|
922d589b09 | ||
|
|
a975ab627a | ||
|
|
9bedd96774 | ||
|
|
383c85231a | ||
|
|
de62035889 | ||
|
|
b456232d00 | ||
|
|
c9e95ecea6 | ||
|
|
7987bd8a37 | ||
|
|
3411265dc1 | ||
|
|
098b8dbe43 | ||
|
|
f48f16830e | ||
|
|
955c23d321 | ||
|
|
d3e1d8219c | ||
|
|
4480839db0 | ||
|
|
8e25567ff2 | ||
|
|
8f65bd184e | ||
|
|
ae9c248987 | ||
|
|
bd6aaa1e43 | ||
|
|
2c65edfffd | ||
|
|
6c2c01e67b | ||
|
|
ef343dbec0 | ||
|
|
a58a398427 | ||
|
|
d510c8a534 | ||
|
|
dc5206f9d3 | ||
|
|
89e65ca331 | ||
|
|
17916b411a | ||
|
|
3ef2c38eb4 | ||
|
|
4c63d6660d | ||
|
|
a453b77f04 | ||
|
|
8e1004fe6f | ||
|
|
663f30feeb | ||
|
|
6653d81a80 | ||
|
|
21df7c8348 | ||
|
|
97381e65d0 | ||
|
|
f8ced6d819 | ||
|
|
f254588409 | ||
|
|
3e7c79fa69 | ||
|
|
47c12f9405 | ||
|
|
bd0914b7ed | ||
|
|
079cee4e9c | ||
|
|
edf2f22ef9 | ||
|
|
e1ef56cdbc | ||
|
|
4d0abc61d5 | ||
|
|
7c2b22bafa | ||
|
|
5e5c8a42a9 | ||
|
|
2b3094ffc5 | ||
|
|
9c91eb5d22 | ||
|
|
2df60fbf07 | ||
|
|
a2d17d693c | ||
|
|
c4917619ee | ||
|
|
760bdae278 | ||
|
|
1b946113c6 | ||
|
|
b0e51c6f89 | ||
|
|
7455fff401 | ||
|
|
aaf1843b09 | ||
|
|
a4737b92e4 | ||
|
|
75ae810866 | ||
|
|
9d7526ee57 | ||
|
|
fa0b0edd63 | ||
|
|
bfc6d199c0 | ||
|
|
4d6e59b80a | ||
|
|
4646aae320 | ||
|
|
eddeca82d3 | ||
|
|
69edeecc45 | ||
|
|
6088e476a7 | ||
|
|
ce171fa954 | ||
|
|
113da76c36 | ||
|
|
4ef11901db | ||
|
|
29abf609f3 | ||
|
|
e13d93a3ed | ||
|
|
b9850b1710 | ||
|
|
905a9f2856 | ||
|
|
1d8b83c37c | ||
|
|
62dbf35ea3 | ||
|
|
e9ccd9bc98 | ||
|
|
cc8e0648db | ||
|
|
ff80cc96fd | ||
|
|
295c1db5f1 | ||
|
|
0521149cf1 | ||
|
|
b4a6569d0d | ||
|
|
1833f594cb | ||
|
|
1615838f33 | ||
|
|
7fa8f4ebde | ||
|
|
6354454cc9 | ||
|
|
c47c193ad1 | ||
|
|
db10bfbbca | ||
|
|
dac9d89f91 | ||
|
|
81936e9f56 | ||
|
|
86f467bd65 | ||
|
|
7d480e2ad0 | ||
|
|
ad136c3fe8 | ||
|
|
9c4a999e54 | ||
|
|
3f5634a44f | ||
|
|
f4bc60d659 | ||
|
|
7055548260 | ||
|
|
477271907e | ||
|
|
50923bb684 | ||
|
|
d2e40dcffb | ||
|
|
58878958d8 | ||
|
|
b47d22cf3b | ||
|
|
e804ff3b8e | ||
|
|
cd6f119883 | ||
|
|
321776480e | ||
|
|
7c0647f87a | ||
|
|
efd7b217ca | ||
|
|
3d7ef9e945 | ||
|
|
7fce7a7ce5 | ||
|
|
f5018bdcbc | ||
|
|
b82c22b888 | ||
|
|
f839a461e6 | ||
|
|
8525666913 | ||
|
|
3f24248e01 | ||
|
|
6b86673af4 | ||
|
|
d924c665f8 | ||
|
|
aa51644f5f | ||
|
|
378b7559e4 | ||
|
|
05121fbc99 | ||
|
|
bf518223e5 | ||
|
|
332d24a646 | ||
|
|
c5e022f056 | ||
|
|
4065ac7e6a | ||
|
|
ce08fc2aec | ||
|
|
ea6d829c29 | ||
|
|
6a7ff330f6 | ||
|
|
7e1aeeb9fe | ||
|
|
7d682c90d5 | ||
|
|
cc9fc3b544 | ||
|
|
c9101e3450 | ||
|
|
51daf3e388 | ||
|
|
09d351a0e7 | ||
|
|
b5078003ce | ||
|
|
f2c32cfb8e | ||
|
|
1453fa4a33 | ||
|
|
efe106dfda | ||
|
|
ec63056bd7 | ||
|
|
09becb3d00 | ||
|
|
8d2ec5e867 | ||
|
|
0e9839e73d | ||
|
|
05ab963951 | ||
|
|
8142484347 | ||
|
|
0d91b82f86 | ||
|
|
90fc86d437 | ||
|
|
2fe58073e4 | ||
|
|
bc03e82687 | ||
|
|
3f983e6d76 | ||
|
|
134ec9a68e | ||
|
|
9cf177b198 | ||
|
|
a5fae489e0 | ||
|
|
8d0083d4e4 | ||
|
|
9f177557d2 | ||
|
|
46fce4a767 | ||
|
|
2d9595b0e5 | ||
|
|
a7d7456279 | ||
|
|
44a792c34f |
@@ -1,22 +1,50 @@
|
|||||||
# Server-side mirror of the local `siftlode check` gate (E1 S3 / DX-1), so `main` is always green
|
# Server-side mirror of the local `siftlode check` gate (E1 S3 / DX-1), so `main` is always green
|
||||||
# independent of any one machine. Two parallel jobs match the two local lanes:
|
# independent of any one machine. Two jobs match the two local lanes:
|
||||||
# frontend — tsc (3 projects) + eslint + prettier --check + knip + vitest
|
# frontend — tsc (3 projects) + eslint + prettier --check + knip + vitest
|
||||||
# backend — ruff + the full pytest suite (pure-logic + the DB-backed lane) against a real Postgres
|
# backend — ruff + the full pytest suite (pure-logic + the DB-backed lane) against a real Postgres
|
||||||
#
|
#
|
||||||
|
# They are declared side by side but they DO NOT run in parallel: the lab has a single act_runner
|
||||||
|
# with one slot, so they queue. Measured across runs #86-#91 — the backend job starts at the exact
|
||||||
|
# second the frontend one ends, every time, and the pair costs ~4 + ~6 = **~10 minutes from the
|
||||||
|
# push**. Anything that waits on this gate has to budget for the sum, not the max; the ops
|
||||||
|
# `release` read-back timed out on its first real run for believing otherwise.
|
||||||
|
#
|
||||||
# Runner: the lab act_runner (LXC 115) mounts the Docker socket, so service containers work. The backend
|
# Runner: the lab act_runner (LXC 115) mounts the Docker socket, so service containers work. The backend
|
||||||
# runs pytest NATIVELY against a `postgres` service (not the local docker-compose.test.yml, whose
|
# runs pytest NATIVELY against a `postgres` service (not the local docker-compose.test.yml, whose
|
||||||
# bind-mount is unreliable under a mounted-socket runner); fidelity is kept by installing the SAME pinned
|
# bind-mount is unreliable under a mounted-socket runner); fidelity is kept by installing the SAME pinned
|
||||||
# requirements, Postgres 16, and ffmpeg, and by setting SIFTLODE_TEST_REQUIRE_DB=1 so a DB/ffmpeg that
|
# requirements, Postgres 16, and ffmpeg, and by setting SIFTLODE_TEST_REQUIRE_DB=1 so a DB/ffmpeg that
|
||||||
# failed to come up is a hard error, never a silent skip. node 22 matches the prod build image
|
# failed to come up is a hard error, never a silent skip.
|
||||||
# (Dockerfile `node:22-alpine`).
|
#
|
||||||
|
# The frontend container's node major is NOT free: it must equal the prod build image's and the dev
|
||||||
|
# machine's, because vitest/tsc read the engine's own Intl and web-API implementations and those split
|
||||||
|
# between majors. `.nvmrc` is the single source of truth and
|
||||||
|
# `frontend/src/lib/nodeEngine.contract.test.ts` fails this gate if any of them drift apart.
|
||||||
name: gate
|
name: gate
|
||||||
|
|
||||||
on: [push, pull_request]
|
# Branch pushes only. A release pushes `main` and then the `v<ver>` tag, and a bare `on: [push]`
|
||||||
|
# treated the tag as a second push of the IDENTICAL commit — every release ran this whole gate
|
||||||
|
# twice, to the same verdict, for nothing (runs #81-#90 are five such pairs). `branches: ['**']`
|
||||||
|
# keeps every branch covered and drops tag refs.
|
||||||
|
#
|
||||||
|
# It has to be `branches`, NOT the `tags-ignore` this obviously wants to be. Gitea's matchPushEvent
|
||||||
|
# requires EVERY named filter to match, and each one bails on the wrong ref kind: `tags-ignore`
|
||||||
|
# starts with `if !refName.IsTag() { break }`, so a filter meant to skip tags rejects every BRANCH
|
||||||
|
# push and the gate stops running entirely. `branches` bails the same way on a tag, which is the
|
||||||
|
# direction we want. (Measured in go-gitea v1.27.3 modules/actions/workflows.go, not assumed — it
|
||||||
|
# differs from GitHub, where `tags-ignore` alone leaves branches unfiltered.)
|
||||||
|
#
|
||||||
|
# If that filter were ever wrong the gate would not run at all — which is why `siftlode release`
|
||||||
|
# now ends by reading the verdict back for the sha it pushed, and says so out loud when there is
|
||||||
|
# no run for it. A gate that silently stops running is the failure this repo already lived through.
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ["**"]
|
||||||
|
pull_request:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
frontend:
|
frontend:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
container: node:22-bookworm
|
container: node:24-bookworm
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
working-directory: frontend
|
working-directory: frontend
|
||||||
@@ -34,7 +62,11 @@ jobs:
|
|||||||
# A combined python+node image: python 3.12 (matches the prod Dockerfile) for the tests, and node
|
# A combined python+node image: python 3.12 (matches the prod Dockerfile) for the tests, and node
|
||||||
# so the JS actions (checkout) run. actions/setup-python is NOT usable here — the runner can't reach
|
# so the JS actions (checkout) run. actions/setup-python is NOT usable here — the runner can't reach
|
||||||
# GitHub's python-versions download, so it must be baked into the image.
|
# GitHub's python-versions download, so it must be baked into the image.
|
||||||
container: nikolaik/python-nodejs:python3.12-nodejs20
|
#
|
||||||
|
# This node runs the checkout action, never our code, so it is deliberately OUTSIDE the engine
|
||||||
|
# contract the frontend job is held to — it tracks `.nvmrc` as hygiene (node 20 went EOL in April
|
||||||
|
# 2026), but a missing upstream tag here must never be able to block the gate.
|
||||||
|
container: nikolaik/python-nodejs:python3.12-nodejs24
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
@@ -61,6 +93,22 @@ jobs:
|
|||||||
run: python -m ruff check --config backend/ruff.toml backend scripts
|
run: python -m ruff check --config backend/ruff.toml backend scripts
|
||||||
- name: ruff format --check
|
- name: ruff format --check
|
||||||
run: python -m ruff format --check --config backend/ruff.toml backend scripts
|
run: python -m ruff format --check --config backend/ruff.toml backend scripts
|
||||||
|
# The ninth lane (E27 S3). Run from backend/ so it resolves exactly as the local lane does —
|
||||||
|
# pyrightconfig.json's include/extraPaths are relative to the config, but the baseline is
|
||||||
|
# found relative to the working directory. New errors fail; the ~200 pre-existing ones are in
|
||||||
|
# .basedpyright/baseline.json and burn down epic by epic. Matching is per (file, rule, column
|
||||||
|
# span) plus a count, so a new error of the SAME shape in a file where a sibling was just
|
||||||
|
# fixed is absorbed — burn the hot files down first.
|
||||||
|
#
|
||||||
|
# The second step is not redundant. basedpyright TIGHTENS the baseline in place when the count
|
||||||
|
# drops, with no --writebaseline, and CI throws its workspace away — so without this a
|
||||||
|
# burn-down is green here and red on the developer's `siftlode check`, which has the same
|
||||||
|
# guard. Two gates over one tree must not answer differently.
|
||||||
|
- name: basedpyright
|
||||||
|
run: python -m basedpyright --project pyrightconfig.json
|
||||||
|
working-directory: backend
|
||||||
|
- name: basedpyright baseline is committed
|
||||||
|
run: git diff --exit-code -- backend/.basedpyright/baseline.json
|
||||||
- name: pytest
|
- name: pytest
|
||||||
run: python -m pytest
|
run: python -m pytest
|
||||||
working-directory: backend
|
working-directory: backend
|
||||||
|
|||||||
@@ -37,3 +37,7 @@ backups/
|
|||||||
Thumbs.db
|
Thumbs.db
|
||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
|
|
||||||
|
# TypeScript incremental build artifacts — machine-local, absolute-path-bearing, and no gate lane
|
||||||
|
# reads or regenerates them, so a committed one is stale from the commit that adds it.
|
||||||
|
*.tsbuildinfo
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
3.12
|
||||||
@@ -7,17 +7,18 @@ worse than no rule — if you change how the code works, change this file in the
|
|||||||
|
|
||||||
Siftlode is a self-hosted YouTube reader (+ a local Plex browser/player and a download center). It is a
|
Siftlode is a self-hosted YouTube reader (+ a local Plex browser/player and a download center). It is a
|
||||||
**modular monolith**: one Postgres, one FastAPI process (uvicorn + APScheduler), one worker process
|
**modular monolith**: one Postgres, one FastAPI process (uvicorn + APScheduler), one worker process
|
||||||
(yt-dlp/ffmpeg), one PO-token sidecar. React 18 + react-query frontend with a router-free navigation
|
(yt-dlp/ffmpeg), one PO-token sidecar. React 19 (with the React Compiler) + react-query frontend, with a
|
||||||
provider.
|
router-free navigation provider.
|
||||||
|
|
||||||
## The gate — never merge or ship red
|
## The gate — never merge or ship red
|
||||||
|
|
||||||
The local gate is **ruff (`check` + `format --check`) + typecheck (3 tsc projects) + eslint + prettier
|
The local gate is **ruff (`check` + `format --check`) + basedpyright (backend) + typecheck (3 tsc projects) + eslint + prettier
|
||||||
--check + knip + vitest + pytest**. It runs on every `publish`, which **refuses to build on failure — there is deliberately no
|
--check + knip + vitest + pytest**. It runs on every `publish`, which **refuses to build on failure — there is deliberately no
|
||||||
`--skip` flag.** Run it and get it green **before** any merge, release, or handing work to review.
|
`--skip` flag.** Run it and get it green **before** any merge, release, or handing work to review.
|
||||||
|
|
||||||
- Full gate (agents with the private ops dispatcher): `siftlode check`.
|
- Full gate (agents with the private ops dispatcher): `siftlode check`.
|
||||||
- Directly, without the dispatcher: `cd frontend && npm run typecheck && npm run lint && npm run knip && npm run test`, plus backend `ruff check backend` + `ruff format --check backend` and `pytest` (the DB-lane needs Postgres).
|
- Directly, without the dispatcher: `cd frontend && npm run typecheck && npm run lint && npm run knip && npm run test`, plus backend `ruff check backend` + `ruff format --check backend` and `pytest` (the DB-lane needs Postgres).
|
||||||
|
- **The backend type lane runs in the test image, not on the host** — `requirements.lock` cannot be installed on Windows (uvloop), and a type checker without the real packages reports nonsense. It fails on NEW errors only; the pre-existing ones sit in `backend/.basedpyright/baseline.json` and burn down epic by epic. Fixed some? Regenerate with `--writebaseline`.
|
||||||
- New DB-backed test? It must actually run, not silently skip — assume `SIFTLODE_TEST_REQUIRE_DB=1`.
|
- New DB-backed test? It must actually run, not silently skip — assume `SIFTLODE_TEST_REQUIRE_DB=1`.
|
||||||
|
|
||||||
## Workflow
|
## Workflow
|
||||||
@@ -45,20 +46,22 @@ how it gets built. Concretely:
|
|||||||
behind each option and the weight of the decision. These decision points are where the product owner
|
behind each option and the weight of the decision. These decision points are where the product owner
|
||||||
engages with the build and learns from it, so make them understandable — a reasoned menu, not a bare one.
|
engages with the build and learns from it, so make them understandable — a reasoned menu, not a bare one.
|
||||||
- **Mockups for design/UX decisions.** For any visual or interaction choice, show mockup options to
|
- **Mockups for design/UX decisions.** For any visual or interaction choice, show mockup options to
|
||||||
choose from before building.
|
choose from before building. How the UI looks and behaves is decided in
|
||||||
|
[`docs/DESIGN.md`](docs/DESIGN.md) (its variants render in the dev-only `/design-lab`): build to it,
|
||||||
|
and change it only through its decision log.
|
||||||
- **Challenge unsound requests.** If a request is technically ill-advised and you can back it with real
|
- **Challenge unsound requests.** If a request is technically ill-advised and you can back it with real
|
||||||
arguments, push back or propose an alternative — don't comply blindly.
|
arguments, push back or propose an alternative — don't comply blindly.
|
||||||
- **Verify UI in the browser before calling it done.** tsc/unit/DOM tests miss visual and interaction
|
- **Verify UI in the browser before calling it done.** tsc/unit/DOM tests miss visual and interaction
|
||||||
bugs; do a real visual check of any UI change.
|
bugs; do a real visual check of any UI change, and run DESIGN.md's four-step Conformance check.
|
||||||
- **Be terse everywhere else.** Outside those decision points, lead with the answer or decision. During
|
- **Be terse everywhere else.** Outside those decision points, lead with the answer or decision. During
|
||||||
implementation and debugging especially: no restating the same point reworded, no filler.
|
implementation and debugging especially: no restating the same point reworded, no filler.
|
||||||
|
|
||||||
## Architecture shape (don't fight it)
|
## Architecture shape (don't fight it)
|
||||||
|
|
||||||
`routes/*` (HTTP only: validation, auth-dependency, serialization) → domain packages `sync/`, `plex/`,
|
`routes/*` (HTTP only: validation, auth-dependency, serialization) → domain packages `sync/`, `plex/`,
|
||||||
`downloads/`, `youtube/` (shared by routes + scheduler + worker) → flat core (`auth`, `quota`,
|
`downloads/`, `youtube/`, `issues/` (shared by routes + scheduler + worker) → flat core (`auth`, `quota`,
|
||||||
`sysconfig`, `state`, `audit`, `notifications`, `userscope`, `realtime`, `ratelimit`, `progress`,
|
`sysconfig`, `state`, `audit`, `notifications`, `userscope`, `realtime`, `ratelimit`, `bodylimit`, `progress`,
|
||||||
`security`, `logs`, `logsink`, `diagnostics`) → `models/db/config`. **Dependency direction is one-way: core never imports routes.**
|
`security`, `logs`, `logsink`, `diagnostics`, `debugsession`) → `models/db/config`. **Dependency direction is one-way: core never imports routes.**
|
||||||
Frontend: `main.tsx` provider tree → `App.tsx` shell → `moduleRegistry` route-chunks → module
|
Frontend: `main.tsx` provider tree → `App.tsx` shell → `moduleRegistry` route-chunks → module
|
||||||
components → the **platform layer `components/ui/`** (domain-free primitives: the popover engine and
|
components → the **platform layer `components/ui/`** (domain-free primitives: the popover engine and
|
||||||
its menus, `Modal`/`Overlay`, `Button`/`form`, `DataTable`/`VirtualGrid`, `QueryState`, …). That
|
its menus, `Modal`/`Overlay`, `Button`/`form`, `DataTable`/`VirtualGrid`, `QueryState`, …). That
|
||||||
@@ -74,29 +77,37 @@ CQRS, a repository/DI framework, a router library, a generic CRUD-router factory
|
|||||||
|
|
||||||
## Canonical patterns — when you have a choice, pick THIS
|
## Canonical patterns — when you have a choice, pick THIS
|
||||||
|
|
||||||
| Area | Use | Not |
|
| Area | Use | Not |
|
||||||
| ----------------------- | ------------------------------------------------------------ | ------------------------------------------------------ |
|
| ------------------------------------ | ----------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
|
||||||
| ORM | SQLAlchemy 2.0 `select()` | `db.query(...)` |
|
| ORM | SQLAlchemy 2.0 `select()` | `db.query(...)` |
|
||||||
| Request body | a Pydantic model | `payload: dict` |
|
| Request body | a Pydantic model | `payload: dict` |
|
||||||
| Ownership check | `owned_or_404` / `get_or_404` | ad-hoc `_own_playlist`/`_owned`/`_own_job` |
|
| Request SIZE | nothing — `bodylimit` caps every route (SEC-20); need more? add a ceiling to `ROUTE_MAX_BODY_BYTES` | `capped_body` on a route that does not read raw bytes itself |
|
||||||
| Admin gate import | `app.auth` | the `app.routes.admin` re-export |
|
| Ownership check | `owned_or_404` / `get_or_404` | ad-hoc `_own_playlist`/`_owned`/`_own_job` |
|
||||||
| Upstream failure status | `422` with JSON detail | `502` (reserve 502 for raw byte-proxies) |
|
| Admin gate import | `app.auth` | the `app.routes.admin` re-export |
|
||||||
| User-facing error | structured `detail` (`{code, reason}`) | a bare English string under a bilingual UI |
|
| Upstream failure status | `422` with JSON detail | `502` (reserve 502 for raw byte-proxies) |
|
||||||
| Mutation response | `{"ok": true, …}` (`{"deleted": id}`), test-held | a bare `{"unsubscribed"}`/`{"cleared": n}`, no `ok` |
|
| User-facing error | structured `detail` (`{code, reason}`) | a bare English string under a bilingual UI |
|
||||||
| Transaction commit | the handler commits; helpers `flush` (test-held list) | helpers that commit (only `quota` owns its session) |
|
| Mutation response | `{"ok": true, …}` (`{"deleted": id}`), test-held | a bare `{"unsubscribed"}`/`{"cleared": n}`, no `ok` |
|
||||||
| Admin knob storage | `sysconfig` (values) | env / `app_state` / `scheduler_settings` scatter |
|
| Transaction commit | the handler commits; helpers `flush` (test-held list) | helpers that commit (only `quota` owns its session) |
|
||||||
| Job status word | the `downloads/status.py` StrEnum | loose `"canceled"`/`"cancelled"` literals |
|
| Admin knob storage | `sysconfig` (values) | env / `app_state` / `scheduler_settings` scatter |
|
||||||
| Current time | inline `datetime.now(timezone.utc)` | new `now_utc`/`_now` helpers |
|
| Job status word | the `downloads/status.py` StrEnum | loose `"canceled"`/`"cancelled"` literals |
|
||||||
| FE per-account state | `usePref` | raw `readAccount`/`writeAccount` or bespoke hooks |
|
| Current time | inline `datetime.now(timezone.utc)` | new `now_utc`/`_now` helpers |
|
||||||
| FE action button | `ui/Button` — pick a `SIZES` rung | a hand-rolled `<button className="px-3 py-2 …">` |
|
| FE per-account state | `usePref` | raw `readAccount`/`writeAccount` or bespoke hooks |
|
||||||
| FE keyboard shortcut | a row in `lib/keymap.ts` + `dispatch()` | a private `keydown` listener with its own key literals |
|
| FE button | `ui/Button` (a `SIZES` rung) · `IconButton` · `Pressable` (a clickable card/row) · `Chip` · `DragHandle` | a raw `<button>` or `FocusSafeButton` outside the control primitives (lint-enforced) |
|
||||||
| FE list/grid keys | `NavGrid`/`keyboardNav` + `navItemAttrs` + `data-nav-open` | a private arrow-key / roving-tabindex handler |
|
| FE keyboard shortcut | a row in `lib/keymap.ts` + `dispatch()` | a private `keydown` listener with its own key literals |
|
||||||
| FE view push (history) | `rememberNavPosition()` right before `history.pushState` | a push that forgets: Back strands focus on `<body>` |
|
| FE list/grid keys | `NavGrid`/`keyboardNav` + `navItemAttrs` + `data-nav-open` | a private arrow-key / roving-tabindex handler |
|
||||||
| FE control | `SegmentedToggle`; a long or user's list: `FilterSelect` | a native `<select>` (lint-enforced) |
|
| FE view push (history) | `rememberNavPosition()` right before `history.pushState` | a push that forgets: Back strands focus on `<body>` |
|
||||||
| FE loading/empty/error | `QueryState` / `StateMessage` | ad-hoc conditionals |
|
| FE text field / checkbox / slider | `ui/Input` · `Textarea` · `InnerInput` in a `fieldClass` frame · `Checkbox`/`CheckRow` · `Slider` | a raw `<input>`/`<textarea>` anywhere but those primitives (lint-enforced) |
|
||||||
| FE failed write | `meta.failTitle`, else `notifyActionError(err, title)` | a bare `catch {}` or `notify()` with no error attached |
|
| FE glass surface | a role: `SURFACE.panel` / `card` / `menu` / `popover` / `tooltip` (`ui/surfaces.ts`) | a bare `glass`/`glass-card`/`glass-menu` beside a hand-picked radius (lint-enforced) |
|
||||||
| FE network call | `req`/`uploadReq`/`beacon`, or `lib/http.ts` `send` (public pages) | a bare `fetch`/`XMLHttpRequest`/`WebSocket` (lint-enforced) |
|
| FE static label / button-like link | `Badge` (flat, no edge) · `LinkButton` / `IconLink` | a glass pill that reads as a chip; an `<a>` hand-painted like a button |
|
||||||
| FE diagnostics | `lib/log.ts` (`log.info("event.code", msg, attrs)`); a filter change → `lib/filterLog.ts`, a player → `lib/mediaLog.ts` | `console.*` (lint-enforced); a value only a higher level may carry, logged unguarded |
|
| FE control | `SegmentedToggle`; a long or user's list: `FilterSelect` | a native `<select>` (lint-enforced) |
|
||||||
|
| FE loading/empty/error | `QueryState` / `StateMessage` | ad-hoc conditionals |
|
||||||
|
| FE failed write | `meta.failTitle`, else `notifyActionError(err, title)` | a bare `catch {}` or `notify()` with no error attached |
|
||||||
|
| FE network call | `req`/`uploadReq`/`beacon`, or `lib/http.ts` `send` (public pages) | a bare `fetch`/`XMLHttpRequest`/`WebSocket` (lint-enforced) |
|
||||||
|
| FE diagnostics | `lib/log.ts` (`log.info("event.code", msg, attrs)`); a filter change → `lib/filterLog.ts`, a player → `lib/mediaLog.ts` | `console.*` (lint-enforced); a value only a higher level may carry, logged unguarded |
|
||||||
|
| FE state that MIRRORS a prop/query | derive it, or hold a draft (`draft ?? server`) | a copy an effect re-syncs — that paints the stale value first |
|
||||||
|
| FE state reset on a key change | adjust it DURING render against a remembered key | `useEffect(() => setX(init), [key])` |
|
||||||
|
| FE effect that must not re-subscribe | `useEffectEvent` (React 19.2+), else a latest-value ref | an excused dep list asserting the same thing in prose |
|
||||||
|
| FE hook suppression | an inline `eslint-disable-next-line` NAMING the invariant | `eslint-suppressions.json` (deliberately absent — E27 S2) |
|
||||||
|
|
||||||
## House rules that bite (learned from real bugs)
|
## House rules that bite (learned from real bugs)
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ ARG GIT_SHA=unknown
|
|||||||
ARG BUILD_DATE=
|
ARG BUILD_DATE=
|
||||||
|
|
||||||
# Stage 1: build the frontend SPA
|
# Stage 1: build the frontend SPA
|
||||||
FROM node:22-alpine AS frontend
|
# The major is pinned to `.nvmrc` and held there by frontend/src/lib/nodeEngine.contract.test.ts:
|
||||||
|
# this image, the CI gate's image and the dev machine must be ONE engine (see that test for why).
|
||||||
|
FROM node:24-alpine AS frontend
|
||||||
WORKDIR /fe
|
WORKDIR /fe
|
||||||
# Deps first, keyed on the lockfile, so editing app source never re-resolves node_modules.
|
# Deps first, keyed on the lockfile, so editing app source never re-resolves node_modules.
|
||||||
COPY frontend/package.json frontend/package-lock.json ./
|
COPY frontend/package.json frontend/package-lock.json ./
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
"""add debug_sessions + debug_screenshots, and index log_events.debug_session_id (E16)
|
||||||
|
|
||||||
|
Revision ID: 0089_debug_sessions
|
||||||
|
Revises: 0088_channels_feed_fallback_at
|
||||||
|
Create Date: 2026-09-19
|
||||||
|
|
||||||
|
A debug session is one recording: Start → reproduce → Stop (app/debugsession.py). Every log line
|
||||||
|
of that user's requests while it runs carries its id in `log_events.debug_session_id` — the column
|
||||||
|
E22 created and left NULL — and the issue it ends in is built from exactly those rows, so the column
|
||||||
|
gets its index now. Partial: nearly every row has no session, and an index of NULLs would be most
|
||||||
|
of the table for nothing.
|
||||||
|
|
||||||
|
At most one OPEN session per user, held by a partial unique index rather than by the start handler
|
||||||
|
alone: two tabs pressing Start at once would otherwise both pass a "none open" check.
|
||||||
|
|
||||||
|
Screenshots are rows, not files: bounded (size and count per session, app/debugsession.py), gone
|
||||||
|
with their session by cascade, and a self-host has no file volume to configure for them.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0089_debug_sessions"
|
||||||
|
down_revision: str | None = "0088_channels_feed_fallback_at"
|
||||||
|
branch_labels: str | None = None
|
||||||
|
depends_on: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"debug_sessions",
|
||||||
|
sa.Column("id", sa.String(16), primary_key=True),
|
||||||
|
sa.Column(
|
||||||
|
"user_id",
|
||||||
|
sa.Integer,
|
||||||
|
sa.ForeignKey("users.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
),
|
||||||
|
sa.Column("level", sa.String(8), nullable=False),
|
||||||
|
sa.Column("started_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("until", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("ended_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("ended_reason", sa.String(16), nullable=True),
|
||||||
|
sa.Column("issue_url", sa.String(512), nullable=True),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"uq_debug_sessions_open_per_user",
|
||||||
|
"debug_sessions",
|
||||||
|
["user_id"],
|
||||||
|
unique=True,
|
||||||
|
postgresql_where=sa.text("ended_at IS NULL"),
|
||||||
|
)
|
||||||
|
op.create_table(
|
||||||
|
"debug_screenshots",
|
||||||
|
sa.Column("id", sa.Integer, primary_key=True),
|
||||||
|
sa.Column(
|
||||||
|
"session_id",
|
||||||
|
sa.String(16),
|
||||||
|
sa.ForeignKey("debug_sessions.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("mime", sa.String(16), nullable=False),
|
||||||
|
sa.Column("size", sa.Integer, nullable=False),
|
||||||
|
sa.Column("data", sa.LargeBinary, nullable=False),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_log_events_debug_session",
|
||||||
|
"log_events",
|
||||||
|
["debug_session_id"],
|
||||||
|
postgresql_where=sa.text("debug_session_id IS NOT NULL"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_log_events_debug_session", table_name="log_events")
|
||||||
|
op.drop_table("debug_screenshots")
|
||||||
|
op.drop_index("uq_debug_sessions_open_per_user", table_name="debug_sessions")
|
||||||
|
op.drop_table("debug_sessions")
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"""add debug_sessions.draft — the issue being written from a session (E16 S3)
|
||||||
|
|
||||||
|
Revision ID: 0090_debug_session_draft
|
||||||
|
Revises: 0089_debug_sessions
|
||||||
|
Create Date: 2026-09-19
|
||||||
|
|
||||||
|
The composer saves as it goes: the title, the rendered markdown body that an export or an issue
|
||||||
|
carries, the log rows the user took out, and the form's own fields so reopening it later (Settings →
|
||||||
|
Diagnosztika lists past sessions) shows what was written. NULL until the composer first saves.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
|
|
||||||
|
revision: str = "0090_debug_session_draft"
|
||||||
|
down_revision: str | None = "0089_debug_sessions"
|
||||||
|
branch_labels: str | None = None
|
||||||
|
depends_on: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("debug_sessions", sa.Column("draft", JSONB, nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("debug_sessions", "draft")
|
||||||
@@ -13,16 +13,17 @@ Three layers, by *who* runs the code — not by feature:
|
|||||||
response models, status-code mapping, serialization. It owns logic that runs *only* on a request.
|
response models, status-code mapping, serialization. It owns logic that runs *only* on a request.
|
||||||
Business logic that a request happens to trigger does **not** live here if anything else runs it too.
|
Business logic that a request happens to trigger does **not** live here if anything else runs it too.
|
||||||
|
|
||||||
2. **`plex/`, `downloads/`, `sync/` — domain logic shared by more than one caller.** Anything the
|
2. **`plex/`, `downloads/`, `sync/`, `issues/` — domain logic shared by more than one caller.** Anything the
|
||||||
**scheduler or the worker** runs (not just a request) lives in one of these domain packages, so the
|
**scheduler or the worker** runs (not just a request) lives in one of these domain packages, so the
|
||||||
route, the worker, and the scheduler share one definition. Examples from this codebase:
|
route, the worker, and the scheduler share one definition. Examples from this codebase:
|
||||||
`downloads/service.py` owns enqueue + the job state transitions (routes call it); `plex/sync.py` is
|
`downloads/service.py` owns enqueue + the job state transitions (routes call it); `plex/sync.py` is
|
||||||
run by both the admin route and the scheduler; `downloads/pipeline.py` is the worker's child pipeline.
|
run by both the admin route and the scheduler; `downloads/pipeline.py` is the worker's child pipeline;
|
||||||
|
`issues/bundle.py` is the one definition of a bug report's files, for the zip export and the tracker.
|
||||||
|
|
||||||
3. **Flat `app/*.py` core modules — cross-cutting primitives.** `auth`, `sysconfig`, `quota`, `audit`,
|
3. **Flat `app/*.py` core modules — cross-cutting primitives.** `auth`, `sysconfig`, `quota`, `audit`,
|
||||||
`notifications`, `userscope`, `realtime`, `ratelimit`, `progress`, `logs` (the one logging
|
`notifications`, `userscope`, `realtime`, `ratelimit`, `progress`, `logs` (the one logging
|
||||||
configuration), `logsink` (its `log_events` table) and `diagnostics` (a user's consented log
|
configuration), `logsink` (its `log_events` table), `diagnostics` (a user's consented log
|
||||||
level), … are single-file primitives many layers import. They depend on nothing above them (a core
|
level) and `debugsession` (a recording that tags its user's lines, E16), … are single-file primitives many layers import. They depend on nothing above them (a core
|
||||||
module must never import a `routes/…` module).
|
module must never import a `routes/…` module).
|
||||||
|
|
||||||
**Extraction trigger: the SECOND consumer.** Keep new logic inline in its first caller. The moment a
|
**Extraction trigger: the SECOND consumer.** Keep new logic inline in its first caller. The moment a
|
||||||
|
|||||||
@@ -58,6 +58,9 @@ class AuditAction:
|
|||||||
DOWNLOAD_CREDENTIAL_DELETE = "download_credential_delete"
|
DOWNLOAD_CREDENTIAL_DELETE = "download_credential_delete"
|
||||||
# The log itself was cleared (a single row survives the clear as tamper-evidence)
|
# The log itself was cleared (a single row survives the clear as tamper-evidence)
|
||||||
AUDIT_CLEAR = "audit_clear"
|
AUDIT_CLEAR = "audit_clear"
|
||||||
|
# E16: a user sent a debug session's issue to the tracker — the one act that takes their log
|
||||||
|
# off this instance, so it is recorded (target = the session, summary = the issue's URL).
|
||||||
|
ISSUE_CREATE = "issue_create"
|
||||||
|
|
||||||
|
|
||||||
def record(
|
def record(
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from sqlalchemy import delete, func, select
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from starlette.requests import HTTPConnection
|
from starlette.requests import HTTPConnection
|
||||||
|
|
||||||
from app import diagnostics
|
from app import debugsession
|
||||||
from app import email as email_mod
|
from app import email as email_mod
|
||||||
from app import logs
|
from app import logs
|
||||||
from app import mailtext
|
from app import mailtext
|
||||||
@@ -367,7 +367,11 @@ def _session_user(request: Request, db: Session, user_id: int | None, is_default
|
|||||||
# 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 (S5).
|
||||||
logs.set_user(user.id, diagnostics.effective_level(user), user.diagnostics_until)
|
# So does a debug session the request names (E16): only the caller's own open one counts, and a
|
||||||
|
# header naming anything else is ignored rather than refused — a stale tab must keep working.
|
||||||
|
session = debugsession.from_header(db, user, request.headers.get(debugsession.HEADER))
|
||||||
|
level, until = debugsession.level_for(user, session)
|
||||||
|
logs.set_user(user.id, level, until, session.id if session else None)
|
||||||
request.state.user_id = user.id
|
request.state.user_id = user.id
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,231 @@
|
|||||||
|
"""The global request-body cap (SEC-20).
|
||||||
|
|
||||||
|
**The hole.** FastAPI reads a declared body IN FULL before auth or any other dependency runs — the
|
||||||
|
body parameter has to be parsed to be passed in. So `POST /auth/password-login` drains a 32 MiB
|
||||||
|
upload from an anonymous caller and only then answers 401. 70 of the 72 body-declaring routes behave
|
||||||
|
the same way: one unauthenticated client can spend the process's memory and time at will.
|
||||||
|
|
||||||
|
**Why a middleware and not `capped_body` on every route.** `routes/_common.py:capped_body` is the
|
||||||
|
right tool where a route genuinely takes bulk bytes, and the two routes that use it use it
|
||||||
|
correctly. But putting it on all 70 others would (a) fight the canonical "Request body → a Pydantic
|
||||||
|
model" row, since capping means taking the body as raw bytes and parsing it by hand, and (b) leave
|
||||||
|
the NEXT route added uncovered, because nothing would remind its author. A default that has to be
|
||||||
|
opted into is not a default. This module inverts it: the cap applies to everything, and the few
|
||||||
|
routes that need more opt OUT, in one list, next to the code that reads it.
|
||||||
|
|
||||||
|
**Why raw ASGI and not `@app.middleware("http")`.** Starlette's BaseHTTPMiddleware cannot wrap
|
||||||
|
`receive`, so it can only read `Content-Length` — which a client may omit entirely (chunked) or
|
||||||
|
simply understate. The bytes have to be counted as they arrive, which is the same rule `capped_body`
|
||||||
|
already applies per-route, and counting needs the receive channel.
|
||||||
|
|
||||||
|
**Why the refusal is byte-identical to `capped_body`'s.** Two shapes for one fact is how the
|
||||||
|
`cancelled`/`canceled` bug happened. The 413 body here is built through the same `JSONResponse`
|
||||||
|
FastAPI renders `HTTPException(413, {"code": "too_large"})` with — not a hand-rolled `json.dumps`,
|
||||||
|
whose default separators differ by two bytes — and `tests/test_body_limit.py` compares the RAW BYTES
|
||||||
|
of the two against each other rather than against a literal, so they cannot drift apart. The same
|
||||||
|
reasoning decides where the middleware is registered (`main.py`): just inside CORS, so this 413
|
||||||
|
carries the same headers `capped_body`'s does.
|
||||||
|
|
||||||
|
**No admin knob, deliberately.** The ceiling is a constant, not a `sysconfig` value: an attacker-
|
||||||
|
facing limit that an admin can raise from the UI is a limit that can be raised by whoever gets the
|
||||||
|
admin session, and nobody has asked to tune it. If a self-hoster ever needs a different ceiling it
|
||||||
|
belongs in `config.py` (env, set before the process starts), not in the database.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
from starlette.responses import JSONResponse
|
||||||
|
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
||||||
|
|
||||||
|
from app import debugsession
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
# 1 MiB. The largest body any route on the default ceiling legitimately takes is 512 KiB (the client
|
||||||
|
# log sink, `routes/diagnostics.py:BODY_MAX_BYTES`), so this leaves 2x headroom — a ceiling that
|
||||||
|
# refuses a legitimate request would be a worse bug than the one this module fixes. That ratio is
|
||||||
|
# the load-bearing fact, so `test_body_limit.py` holds it rather than the figure above.
|
||||||
|
DEFAULT_MAX_BODY_BYTES = 1024 * 1024
|
||||||
|
|
||||||
|
# The routes that need MORE than the default, as (method, path TEMPLATE) → their own ceiling. The
|
||||||
|
# template, not a concrete path, so the list reads like the route decorators it mirrors and
|
||||||
|
# `test_body_limit.py` can assert that each one still resolves to a real route: a renamed route
|
||||||
|
# fails the gate instead of silently losing its allowance in prod.
|
||||||
|
#
|
||||||
|
# These are RAISED CEILINGS, NOT EXEMPTIONS, and the difference is the whole point. An exemption
|
||||||
|
# would mean "this route is counted by something else" — which is only true when the route's own
|
||||||
|
# limit applies to the bytes as they ARRIVE. That, in turn, is only true of a route with NO DECLARED
|
||||||
|
# BODY PARAMETER: FastAPI parses and spools a declared body BEFORE the first dependency runs, so a
|
||||||
|
# route that declares one has already stored the bytes by the time its own cap — or its auth — gets
|
||||||
|
# a say.
|
||||||
|
#
|
||||||
|
# Both listed routes now satisfy that. The screenshot route always did (`capped_body` streams it,
|
||||||
|
# and the owner check resolves first). The upload route did NOT until P1: it declared
|
||||||
|
# `file: UploadFile = File(...)`, so an anonymous caller could make the process write the whole
|
||||||
|
# per-file allowance to temp before `require_human` refused them — this middleware bounded that
|
||||||
|
# number but could not stop the writing. It now reads its own body from a dependency declared after
|
||||||
|
# the auth one, so its in-handler cap measures bytes as they arrive, like everything else here.
|
||||||
|
#
|
||||||
|
# That rule is no longer only written here: `test_body_limit.py` parses the routes and fails the
|
||||||
|
# gate if any route in this dict declares a body parameter. A comment could not have stopped the
|
||||||
|
# defect it describes; the test can.
|
||||||
|
#
|
||||||
|
# A route belongs here ONLY IF ITS OWN CAP IS LARGER THAN THE DEFAULT. That is why
|
||||||
|
# `POST /api/diagnostics/events` is absent: its `capped_body` limit is 512 KiB, already below the
|
||||||
|
# default, so listing it could only widen what it accepts.
|
||||||
|
ROUTE_MAX_BODY_BYTES: dict[tuple[str, str], int] = {
|
||||||
|
# Ingesting a local file. The body IS the file since P1 — no multipart envelope to allow for —
|
||||||
|
# so this ceiling and the route's own cap count exactly the same bytes. `sysconfig`'s
|
||||||
|
# `download_upload_max_bytes` is bounded by this same constant (see sysconfig.SPECS), so an
|
||||||
|
# admin can only ever LOWER the route's cap below the edge, never raise it past one.
|
||||||
|
#
|
||||||
|
# WHICH LAYER ANSWERS, measured rather than assumed (deep-review round 1):
|
||||||
|
# * knob LOWERED (an override below the default) — the route's cap is the smaller number, so
|
||||||
|
# `capped_stream_to` trips first and answers 422 `upload_too_large` carrying the admin's own
|
||||||
|
# figure, after draining the rest of the body so the client can read it.
|
||||||
|
# * knob at the DEFAULT — the two numbers are EQUAL, and the edge always wins: a declared
|
||||||
|
# Content-Length over the cap is refused here before a byte is read, and an undeclared body
|
||||||
|
# trips this counter on the same chunk the route would have tripped on. The route's 422 is
|
||||||
|
# unreachable in that configuration, and no amount of headroom would change it — the
|
||||||
|
# declared-length check fires first for any body big enough to matter, and the route's drain
|
||||||
|
# pushes the count past the edge for the rest.
|
||||||
|
#
|
||||||
|
# So this 413 is a refusal a real user meets on a default install, not a theoretical edge, and
|
||||||
|
# it has to be as readable as the route's own: the client localizes `too_large` (`errors.tooLarge`)
|
||||||
|
# exactly as it does `upload_too_large`, and the upload dialog refuses an over-cap file before
|
||||||
|
# sending it at all, off the `upload_max_bytes` that `GET /api/downloads/usage` reports.
|
||||||
|
("POST", "/api/downloads/upload"): settings.download_upload_max_bytes,
|
||||||
|
("POST", "/api/debug-sessions/{sid}/screenshots"): debugsession.SCREENSHOT_MAX_BYTES,
|
||||||
|
}
|
||||||
|
|
||||||
|
# The refusal, byte-for-byte what `capped_body`'s `HTTPException(413, {"code": "too_large"})` renders
|
||||||
|
# as — built through the same JSONResponse so the separators match too, and asserted against a real
|
||||||
|
# route's raw bytes in `test_body_limit.py` rather than against a literal here.
|
||||||
|
TOO_LARGE_BODY = JSONResponse({"detail": {"code": "too_large"}}, status_code=413).body
|
||||||
|
|
||||||
|
_PATH_PARAM = re.compile(r"\{[^/{}]+\}")
|
||||||
|
|
||||||
|
|
||||||
|
def template_to_regex(template: str) -> re.Pattern[str]:
|
||||||
|
"""`/api/debug-sessions/{sid}/screenshots` → a regex matching one concrete path.
|
||||||
|
|
||||||
|
A path parameter matches `[^/]+` — one segment, never across a `/`, so `{sid}` cannot swallow a
|
||||||
|
deeper path and hand an exemption to a route that was never granted one.
|
||||||
|
"""
|
||||||
|
literals = [re.escape(part) for part in _PATH_PARAM.split(template)]
|
||||||
|
return re.compile("^" + "[^/]+".join(literals) + "$")
|
||||||
|
|
||||||
|
|
||||||
|
def _declared_length(scope: Scope) -> int | None:
|
||||||
|
"""The `Content-Length` header as an int, or None when absent or unparseable (a chunked body has
|
||||||
|
none, and a malformed one is not a number we may trust — both fall through to counting)."""
|
||||||
|
for name, value in scope.get("headers", ()):
|
||||||
|
if name == b"content-length":
|
||||||
|
try:
|
||||||
|
return int(value)
|
||||||
|
except ValueError:
|
||||||
|
# silent-ok: a malformed Content-Length is the caller's doing, not a fault of ours,
|
||||||
|
# and it is about to be answered anyway — returning None drops the request into the
|
||||||
|
# counting path, which refuses on the real bytes. Logging it would hand an anonymous
|
||||||
|
# client a free line in our log for every request it cares to malform.
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def _send_too_large(send: Send) -> None:
|
||||||
|
await send(
|
||||||
|
{
|
||||||
|
"type": "http.response.start",
|
||||||
|
"status": 413,
|
||||||
|
"headers": [
|
||||||
|
(b"content-type", b"application/json"),
|
||||||
|
(b"content-length", str(len(TOO_LARGE_BODY)).encode()),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
await send({"type": "http.response.body", "body": TOO_LARGE_BODY})
|
||||||
|
|
||||||
|
|
||||||
|
class BodyLimitMiddleware:
|
||||||
|
"""Refuses any request body over its route's ceiling, before the application sees it.
|
||||||
|
|
||||||
|
Registered just inside CORS in `main.py` — so the 413 is decorated like every other response
|
||||||
|
and a browser can actually read it — but outside the session, the setup gate and every auth
|
||||||
|
dependency, none of which run for a request that is already too large.
|
||||||
|
|
||||||
|
Every request gets a finite ceiling: `ROUTE_MAX_BODY_BYTES` raises it for the two routes that
|
||||||
|
genuinely take bulk bytes, and there is no path that skips counting altogether.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
app: ASGIApp,
|
||||||
|
*,
|
||||||
|
limit: int = DEFAULT_MAX_BODY_BYTES,
|
||||||
|
routes: dict[tuple[str, str], int] | None = None,
|
||||||
|
) -> None:
|
||||||
|
self.app = app
|
||||||
|
self.limit = limit
|
||||||
|
self._routes = tuple(
|
||||||
|
(method, template_to_regex(path), cap)
|
||||||
|
for (method, path), cap in (ROUTE_MAX_BODY_BYTES if routes is None else routes).items()
|
||||||
|
)
|
||||||
|
|
||||||
|
def limit_for(self, method: str, path: str) -> int:
|
||||||
|
"""This request's ceiling: the route's own if it has one, else the global default. Every
|
||||||
|
request gets a finite number — there is no path through here that is simply not counted."""
|
||||||
|
for m, rx, cap in self._routes:
|
||||||
|
if m == method and rx.match(path):
|
||||||
|
return cap
|
||||||
|
return self.limit
|
||||||
|
|
||||||
|
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||||
|
if scope["type"] != "http":
|
||||||
|
await self.app(scope, receive, send)
|
||||||
|
return
|
||||||
|
limit = self.limit_for(scope.get("method", ""), scope.get("path", ""))
|
||||||
|
|
||||||
|
declared = _declared_length(scope)
|
||||||
|
if declared is not None and declared > limit:
|
||||||
|
# Refused on the declaration alone: nothing downstream is called, and not one byte of
|
||||||
|
# the body is read.
|
||||||
|
await _send_too_large(send)
|
||||||
|
return
|
||||||
|
|
||||||
|
seen = 0
|
||||||
|
refused = False
|
||||||
|
started = False
|
||||||
|
|
||||||
|
async def counting_receive() -> Message:
|
||||||
|
nonlocal seen, refused
|
||||||
|
message = await receive()
|
||||||
|
if message["type"] != "http.request":
|
||||||
|
return message
|
||||||
|
seen += len(message.get("body", b""))
|
||||||
|
if seen > limit:
|
||||||
|
# The app is blocked on this receive, so it has not answered yet. Telling it the
|
||||||
|
# client went away is the only in-band way to stop it reading: `request.stream()`
|
||||||
|
# turns an `http.disconnect` into ClientDisconnect, which unwinds the handler. Our
|
||||||
|
# own 413 goes out below, once the app has finished unwinding.
|
||||||
|
refused = True
|
||||||
|
return {"type": "http.disconnect"}
|
||||||
|
return message
|
||||||
|
|
||||||
|
async def guarded_send(message: Message) -> None:
|
||||||
|
nonlocal started
|
||||||
|
if refused and not started:
|
||||||
|
return # discard the app's answer to the disconnect; the 413 below is the response
|
||||||
|
started = True
|
||||||
|
await send(message)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self.app(scope, counting_receive, guarded_send)
|
||||||
|
except Exception: # noqa: BLE001 — only swallowed when WE caused it, see below
|
||||||
|
# ClientDisconnect (or whatever a handler raises on a truncated read) is the expected
|
||||||
|
# consequence of the disconnect we just faked, and the caller must get the 413 rather
|
||||||
|
# than a 500. Anything raised when we did NOT refuse is a real failure and is re-raised
|
||||||
|
# untouched.
|
||||||
|
if not refused:
|
||||||
|
raise
|
||||||
|
|
||||||
|
if refused and not started:
|
||||||
|
await _send_too_large(send)
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Literal, get_args, get_origin
|
from typing import Literal, get_args, get_origin
|
||||||
|
|
||||||
from pydantic import ValidationInfo, field_validator, model_validator
|
from pydantic import Field, ValidationInfo, field_validator, model_validator
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
# Shipped placeholder; production must override it. Used by the startup guard below.
|
# Shipped placeholder; production must override it. Used by the startup guard below.
|
||||||
@@ -247,7 +247,11 @@ class Settings(BaseSettings):
|
|||||||
# Per-file cap for local uploads (E7). Unlike a download (whose size is unknown until the worker
|
# Per-file cap for local uploads (E7). Unlike a download (whose size is unknown until the worker
|
||||||
# finishes), an upload's size is known at ingest, so this bounds a single file directly; the
|
# finishes), an upload's size is known at ingest, so this bounds a single file directly; the
|
||||||
# user's overall byte footprint is still governed by download_default_max_bytes.
|
# user's overall byte footprint is still governed by download_default_max_bytes.
|
||||||
download_upload_max_bytes: int = 524_288_000 # 500 MiB
|
# ge=1: this number is also the EDGE ceiling (`bodylimit.ROUTE_MAX_BODY_BYTES`), where 0 is a
|
||||||
|
# real limit of zero rather than the route's "no per-file cap" sentinel — an instance set to 0
|
||||||
|
# would refuse every upload, including the admin page's own way back. To lift the cap in
|
||||||
|
# practice, set a large number.
|
||||||
|
download_upload_max_bytes: int = Field(default=524_288_000, ge=1) # 500 MiB
|
||||||
# Retention: an asset expires this many days after its last access; the GC job then
|
# Retention: an asset expires this many days after its last access; the GC job then
|
||||||
# deletes it (once no job references it) after an additional grace window.
|
# deletes it (once no job references it) after an additional grace window.
|
||||||
download_retention_days: int = 30
|
download_retention_days: int = 30
|
||||||
@@ -351,6 +355,13 @@ class Settings(BaseSettings):
|
|||||||
tmdb_api_base: str = "https://api.themoviedb.org/3"
|
tmdb_api_base: str = "https://api.themoviedb.org/3"
|
||||||
# Metadata language for TMDB fetches (keyword/overview locale). Stored EN by default.
|
# Metadata language for TMDB fetches (keyword/overview locale). Stored EN by default.
|
||||||
tmdb_language: str = "en-US"
|
tmdb_language: str = "en-US"
|
||||||
|
|
||||||
|
# --- Bug reports (E16): where a debug session's issue goes. All three empty = no tracker; the
|
||||||
|
# composer still exports a zip. The repository must be PRIVATE — an issue carries the reporter's
|
||||||
|
# log — and app/issues/gitea.py refuses a public one. Runtime knobs (sysconfig, admin UI). ---
|
||||||
|
issues_gitea_url: str = ""
|
||||||
|
issues_gitea_repo: str = ""
|
||||||
|
issues_gitea_token: str = ""
|
||||||
# How often the TMDB enrichment job fetches a batch of not-yet-enriched Plex titles, in minutes.
|
# How often the TMDB enrichment job fetches a batch of not-yet-enriched Plex titles, in minutes.
|
||||||
tmdb_enrich_interval_min: int = 120
|
tmdb_enrich_interval_min: int = 120
|
||||||
# Titles fetched per TMDB enrichment run (TMDB is fast + generously rate-limited).
|
# Titles fetched per TMDB enrichment run (TMDB is fast + generously rate-limited).
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from collections.abc import Iterator
|
from collections.abc import Iterator
|
||||||
|
|
||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine, text
|
||||||
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
|
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
@@ -12,7 +12,9 @@ class Base(DeclarativeBase):
|
|||||||
|
|
||||||
# Pool sized above the sync-endpoint threadpool (~40) so a burst of short DB sessions can't starve on
|
# Pool sized above the sync-endpoint threadpool (~40) so a burst of short DB sessions can't starve on
|
||||||
# checkout. The image proxies deliberately DON'T hold a connection during their slow external fetch
|
# checkout. The image proxies deliberately DON'T hold a connection during their slow external fetch
|
||||||
# (see routes/plex/), so this headroom is only for the many brief sessions, not long ones.
|
# (see routes/plex/), so this headroom is only for the many brief sessions, not long ones. The one
|
||||||
|
# long hold — a playlist sync's advisory claim, for a whole push — opens its own unpooled connection
|
||||||
|
# (sync/playlists.py `_claim_engine`) instead of drawing a third one from here.
|
||||||
engine = create_engine(
|
engine = create_engine(
|
||||||
settings.database_url,
|
settings.database_url,
|
||||||
pool_pre_ping=True,
|
pool_pre_ping=True,
|
||||||
@@ -34,3 +36,50 @@ def get_db() -> Iterator[Session]:
|
|||||||
yield db
|
yield db
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def release_read_connection(db: Session) -> None:
|
||||||
|
"""End a request's READ transaction so its pooled connection goes back BEFORE a long await.
|
||||||
|
|
||||||
|
SQLAlchemy 2.0 autobegins on the first SELECT, so a request that has merely authenticated is
|
||||||
|
already holding one of the connections above — `idle in transaction` to Postgres, and nothing
|
||||||
|
times it out. A route that then awaits something slow (a request body still arriving, an
|
||||||
|
external fetch) pins it for the whole wait, which is precisely the invariant the pool is sized
|
||||||
|
on in the comment above. Measured: the next checkout after this call reuses the very backend
|
||||||
|
that was released, and a 1.8 s upload goes from one sustained `idle in transaction` to none.
|
||||||
|
|
||||||
|
Every route that reads its own body from a dependency needs this, and `test_body_limit.py`
|
||||||
|
holds that — the three that do are the ones with a raised (or self-imposed) body ceiling, and
|
||||||
|
they got here one at a time, which is how the upload route ended up the only one fixed.
|
||||||
|
|
||||||
|
The caller must have NOTHING PENDING, and that is checked rather than described. The session is
|
||||||
|
shared with every dependency that ran before, so a rollback here would silently discard a write
|
||||||
|
one of them staged — `service.enqueue` already learned that lesson the expensive way and uses a
|
||||||
|
SAVEPOINT for it. CLAUDE.md: a rule stated in a comment must also be enforced in code.
|
||||||
|
|
||||||
|
TWO checks, because the obvious one is blind to the shapes this codebase actually writes in.
|
||||||
|
`db.new`/`deleted`/`dirty` hold only UNFLUSHED unit-of-work: the moment a helper calls
|
||||||
|
`db.flush()` — CLAUDE.md's canonical split is "the handler commits; helpers flush" — the object
|
||||||
|
is persistent with no unflushed history and all three sets are empty again, and Core DML
|
||||||
|
(`db.execute(update(...))`) never enters them at all. Measured: after `add`+`flush` the sets are
|
||||||
|
empty, the old check passed, and the rollback lost the row. So the transaction is asked
|
||||||
|
directly. Postgres assigns a transaction id on the first WRITE and never for a read, so
|
||||||
|
`pg_current_xact_id_if_assigned()` is NULL for exactly the state this function is safe in — and
|
||||||
|
it sees a raw `text("UPDATE …")` too, which no session-level bookkeeping would.
|
||||||
|
"""
|
||||||
|
if db.new or db.deleted or db.dirty:
|
||||||
|
raise RuntimeError(
|
||||||
|
"release_read_connection() with pending work: something ahead of this dependency "
|
||||||
|
"staged a write, which ending the transaction would discard. Commit it first, or do "
|
||||||
|
"it after the await."
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
db.in_transaction()
|
||||||
|
and db.execute(text("SELECT pg_current_xact_id_if_assigned()")).scalar() is not None
|
||||||
|
):
|
||||||
|
raise RuntimeError(
|
||||||
|
"release_read_connection() on a transaction that has already WRITTEN: something ahead "
|
||||||
|
"of this dependency flushed a row or ran an UPDATE/INSERT/DELETE, which ending the "
|
||||||
|
"transaction would discard. Commit it first, or do it after the await."
|
||||||
|
)
|
||||||
|
db.rollback()
|
||||||
|
|||||||
@@ -0,0 +1,485 @@
|
|||||||
|
"""Debug sessions (E16): Start → reproduce → Stop, and the evidence a bug report is built from.
|
||||||
|
|
||||||
|
While a session is open its user's browser names it in `X-Debug-Session` on every request. The auth
|
||||||
|
dependency accepts the header only for the caller's OWN open session (anything else is ignored, never
|
||||||
|
an error — a stale tab must not break the app) and then logs that request at the session's level with
|
||||||
|
every line tagged by its id; the browser tags its own events the same way. The issue is later built
|
||||||
|
from exactly the rows carrying the tag, so what the review list shows is what gets sent.
|
||||||
|
|
||||||
|
Starting a session IS the consent for its level, for its duration: the start dialog carries the
|
||||||
|
consent text. It needs no change to the user's own diagnostics level, and none is made — when the
|
||||||
|
session ends the level in force is simply the user's own again.
|
||||||
|
|
||||||
|
A session is capped at `MAX_MINUTES`, set by the server at Start. An open one past its cap is over
|
||||||
|
whether or not anything has closed it yet: it reads as over at once, a new Start closes it, and the
|
||||||
|
hourly log job sweeps the rest. Sessions go with the logs they point at, after `log_retention_days`.
|
||||||
|
|
||||||
|
Core layer: imports `models`, `diagnostics`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import secrets
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from enum import StrEnum
|
||||||
|
from typing import Literal, get_args
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from sqlalchemy import and_, delete, func, not_, select, update
|
||||||
|
from sqlalchemy.orm import Session, defer
|
||||||
|
|
||||||
|
from app import diagnostics
|
||||||
|
from app.models import DebugScreenshot, DebugSession, LogEvent, User
|
||||||
|
|
||||||
|
HEADER = "x-debug-session"
|
||||||
|
MAX_MINUTES = diagnostics.SESSION_MINUTES
|
||||||
|
SCREENSHOT_MAX_BYTES = 10 * 1024 * 1024
|
||||||
|
SCREENSHOTS_MAX = 8
|
||||||
|
# What one user may keep in screenshots across ALL their sessions (until retention takes them): the
|
||||||
|
# per-session cap alone would let Start → 8 images → Stop repeat without end.
|
||||||
|
SCREENSHOT_USER_MAX_BYTES = 100 * 1024 * 1024
|
||||||
|
# The screenshot types kept, and the extension each is exported and sent under — one table, so an
|
||||||
|
# accepted type always has a name.
|
||||||
|
IMAGE_TYPES = {"image/png": "png", "image/jpeg": "jpg", "image/webp": "webp"}
|
||||||
|
# What a screenshot is called in the zip and on the issue. `issues/bundle.py` builds the names from
|
||||||
|
# this and `IMAGE_TYPES`; this module and `issues/gitea.py` read them back to tell a screenshot's
|
||||||
|
# link from a log's. One definition, because three copies of a name is how the next rename breaks
|
||||||
|
# `images_pending` into silence.
|
||||||
|
SCREENSHOT_PREFIX = "screenshot-"
|
||||||
|
# Rows an issue can carry. A 60-minute session at Teljes is a few thousand rows; past this the
|
||||||
|
# bundle says how many it left out rather than growing without bound.
|
||||||
|
BUNDLE_MAX = 5_000
|
||||||
|
|
||||||
|
SessionLevel = Literal["DEBUG", "TRACE"]
|
||||||
|
SESSION_LEVELS: tuple[str, ...] = get_args(SessionLevel)
|
||||||
|
# Detail order of the diagnostics levels: a session raises the level in force, never lowers it.
|
||||||
|
_DETAIL = {"INFO": 0, "DEBUG": 1, "TRACE": 2}
|
||||||
|
_ID_LEN = 16
|
||||||
|
|
||||||
|
|
||||||
|
class EndReason(StrEnum):
|
||||||
|
STOPPED = "stopped"
|
||||||
|
EXPIRED = "expired"
|
||||||
|
|
||||||
|
|
||||||
|
def is_live(session: DebugSession, now: datetime | None = None) -> bool:
|
||||||
|
"""Open and within its cap — THE liveness rule: an open session past its cap is over."""
|
||||||
|
return session.ended_at is None and session.until > (now or datetime.now(timezone.utc))
|
||||||
|
|
||||||
|
|
||||||
|
def open_session(db: Session, user: User) -> DebugSession | None:
|
||||||
|
"""The user's live session, or None. Read-only: one past its cap is over and reads as None;
|
||||||
|
`start` and the hourly sweep are what close it."""
|
||||||
|
session = _unclosed(db, user)
|
||||||
|
if session is None or not is_live(session, datetime.now(timezone.utc)):
|
||||||
|
return None
|
||||||
|
return session
|
||||||
|
|
||||||
|
|
||||||
|
def _unclosed(db: Session, user: User) -> DebugSession | None:
|
||||||
|
return db.scalar(
|
||||||
|
select(DebugSession).where(DebugSession.user_id == user.id, DebugSession.ended_at.is_(None))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def start(db: Session, user: User, level: SessionLevel) -> DebugSession:
|
||||||
|
"""Open a session, or return the live one — a second tab pressing Start joins the recording
|
||||||
|
rather than ending it. One past its cap is closed first: the one-open-per-user index would
|
||||||
|
refuse the new row beside it. Flushes; the caller commits. Two concurrent Starts can still both
|
||||||
|
get here: the index refuses the second, and the caller joins the one that won."""
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
current = _unclosed(db, user)
|
||||||
|
if current is not None:
|
||||||
|
if is_live(current, now):
|
||||||
|
return current
|
||||||
|
end(current, EndReason.EXPIRED)
|
||||||
|
db.flush()
|
||||||
|
session = DebugSession(
|
||||||
|
id=secrets.token_hex(_ID_LEN // 2),
|
||||||
|
user_id=user.id,
|
||||||
|
level=level,
|
||||||
|
started_at=now,
|
||||||
|
until=now + timedelta(minutes=MAX_MINUTES),
|
||||||
|
)
|
||||||
|
db.add(session)
|
||||||
|
db.flush()
|
||||||
|
return session
|
||||||
|
|
||||||
|
|
||||||
|
def stop(session: DebugSession) -> None:
|
||||||
|
"""The user's Stop. Past its cap the session had already ended on its own, and a late Stop must
|
||||||
|
not rename that."""
|
||||||
|
end(session, EndReason.STOPPED if is_live(session) else EndReason.EXPIRED)
|
||||||
|
|
||||||
|
|
||||||
|
def end(session: DebugSession, reason: EndReason) -> None:
|
||||||
|
"""Close a session. An expired one is dated at its cap, not at whenever it was noticed."""
|
||||||
|
if session.ended_at is not None:
|
||||||
|
return
|
||||||
|
session.ended_at = (
|
||||||
|
session.until
|
||||||
|
if reason is EndReason.EXPIRED
|
||||||
|
else min(datetime.now(timezone.utc), session.until)
|
||||||
|
)
|
||||||
|
session.ended_reason = reason
|
||||||
|
|
||||||
|
|
||||||
|
def from_header(db: Session, user: User, value: str | None) -> DebugSession | None:
|
||||||
|
"""The session a request names, if it is the caller's own and still live; else None."""
|
||||||
|
if not value or len(value) != _ID_LEN or not all(c in "0123456789abcdef" for c in value):
|
||||||
|
return None
|
||||||
|
session = db.get(DebugSession, value)
|
||||||
|
if (
|
||||||
|
session is None
|
||||||
|
or session.user_id != user.id
|
||||||
|
or not is_live(session, datetime.now(timezone.utc))
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
return session
|
||||||
|
|
||||||
|
|
||||||
|
def level_for(user: User, session: DebugSession | None) -> tuple[str, datetime | None]:
|
||||||
|
"""The diagnostics level (and its expiry) a request is logged at: the user's own, raised to the
|
||||||
|
session's while one is named — whichever records more.
|
||||||
|
|
||||||
|
A named session also caps the expiry at its own: this pair is what the process tracks under the
|
||||||
|
session (app/logs.py), and an expiry outliving the recording would hold the root logger down
|
||||||
|
after it ended."""
|
||||||
|
own = diagnostics.effective_level(user)
|
||||||
|
until = user.diagnostics_until if own != diagnostics.BASE else None
|
||||||
|
if session is None:
|
||||||
|
return own, until
|
||||||
|
if _DETAIL[session.level] <= _DETAIL[own]:
|
||||||
|
return own, min(until, session.until) if until else session.until
|
||||||
|
return session.level, session.until
|
||||||
|
|
||||||
|
|
||||||
|
def lock(db: Session, session: DebugSession) -> None:
|
||||||
|
"""Re-read the session under a row lock, held until the caller commits. Everything that changes
|
||||||
|
what an issue is made of — the draft, the screenshots, the send itself — takes it first: a send
|
||||||
|
in flight sees none of them change under it, and each of them sees a send that committed."""
|
||||||
|
db.refresh(session, with_for_update=True)
|
||||||
|
|
||||||
|
|
||||||
|
def save_draft(session: DebugSession, draft: dict) -> None:
|
||||||
|
"""Keep the composer's draft (the caller validated, locked and commits). Shape: `title`, `body`
|
||||||
|
(the rendered markdown an export or an issue carries), `excluded` (log row ids taken out of the
|
||||||
|
evidence) and `form` (the composer's own fields, to reopen it as it was). A delivery's progress
|
||||||
|
is not the composer's to replace, and is kept."""
|
||||||
|
progress = (session.draft or {}).get("delivery")
|
||||||
|
session.draft = {**draft, "delivery": progress} if progress else draft
|
||||||
|
|
||||||
|
|
||||||
|
# --- a delivery's progress -----------------------------------------------------------------
|
||||||
|
# Written by app/issues/gitea.py into the draft; read HERE, so the send's decision and what the
|
||||||
|
# composer shows are one reading of it.
|
||||||
|
def delivery(session: DebugSession) -> dict:
|
||||||
|
return dict((session.draft or {}).get("delivery") or {})
|
||||||
|
|
||||||
|
|
||||||
|
def not_uploaded(session: DebugSession) -> list[str]:
|
||||||
|
"""The files a send that stopped half-way has still to upload — by name, as the issue carries
|
||||||
|
them. What the composer names to the user."""
|
||||||
|
return list(delivery(session).get("pending") or [])
|
||||||
|
|
||||||
|
|
||||||
|
def images_pending(session: DebugSession) -> bool:
|
||||||
|
"""Whether the issue's body still has to be edited to show the screenshots it already carries
|
||||||
|
(every upload went, the edit that shows them did not)."""
|
||||||
|
state = delivery(session)
|
||||||
|
shown = state.get("images_shown")
|
||||||
|
return not shown and any(n.startswith(SCREENSHOT_PREFIX) for n in state.get("links") or {})
|
||||||
|
|
||||||
|
|
||||||
|
def unfinished(session: DebugSession) -> bool:
|
||||||
|
"""Whether a send owes the issue anything — the retry's question."""
|
||||||
|
return bool(not_uploaded(session)) or images_pending(session)
|
||||||
|
|
||||||
|
|
||||||
|
# A send claims the session while it talks to the tracker, instead of holding the row's lock across
|
||||||
|
# those calls: a Stop, an autosave or a screenshot would queue behind a lock for the whole send.
|
||||||
|
# They see the claim and are refused at once instead. A claim not renewed for this long was left by
|
||||||
|
# a request that died mid-send (the process restarted), and the next send takes it over — which is
|
||||||
|
# why a live send RENEWS it every time it reports progress (`set_delivery`). A claim is a lease, and
|
||||||
|
# a send slower than the lease must not lose it while it is still running.
|
||||||
|
SEND_CLAIM_SECONDS = 600
|
||||||
|
|
||||||
|
|
||||||
|
def _claim(session: DebugSession) -> dict:
|
||||||
|
claim = delivery(session).get("sending")
|
||||||
|
return claim if isinstance(claim, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _stamp(token: str) -> dict:
|
||||||
|
return {"at": datetime.now(timezone.utc).isoformat(), "by": token}
|
||||||
|
|
||||||
|
|
||||||
|
def sending(session: DebugSession) -> bool:
|
||||||
|
"""Whether a send is under way right now (a live claim)."""
|
||||||
|
at = _claim(session).get("at")
|
||||||
|
if not at:
|
||||||
|
return False
|
||||||
|
return (datetime.now(timezone.utc) - datetime.fromisoformat(at)).total_seconds() < (
|
||||||
|
SEND_CLAIM_SECONDS
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def claim_send(session: DebugSession) -> str:
|
||||||
|
"""Mark the session as being sent, and return the token that OWNS this claim. The caller holds
|
||||||
|
the lock and commits, which is what makes the claim visible to everyone else."""
|
||||||
|
token = uuid4().hex
|
||||||
|
_write_delivery(session, {**delivery(session), "sending": _stamp(token)})
|
||||||
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
def release_send(session: DebugSession, token: str) -> None:
|
||||||
|
"""Give back our OWN claim, and only ours: one that aged out and was taken over belongs to the
|
||||||
|
send holding it now, and handing that one back would let a third send in beside it."""
|
||||||
|
if _claim(session).get("by") != token:
|
||||||
|
return
|
||||||
|
_write_delivery(session, {k: v for k, v in delivery(session).items() if k != "sending"})
|
||||||
|
|
||||||
|
|
||||||
|
class SendClaimLost(Exception):
|
||||||
|
"""This send's claim is no longer on the row: it aged out and another send took it over. The
|
||||||
|
send must stop — what it uploads from here it can no longer record, so a retry would send it
|
||||||
|
again — and it must not write, because the row now holds the other send's progress."""
|
||||||
|
|
||||||
|
|
||||||
|
def claim_owner(db: Session, session: DebugSession) -> str | None:
|
||||||
|
"""Who holds the row's claim RIGHT NOW, read from the ROW and not from the object.
|
||||||
|
|
||||||
|
`expire_on_commit` is off (app/db.py), so a checkpoint does not re-read: a send that stalled
|
||||||
|
past its lease still carries its own dead token in memory long after another send took over.
|
||||||
|
A targeted scalar, not `lock`/`db.refresh` — a refresh here would discard the `issue_url` that
|
||||||
|
`gitea.deliver` assigns one line before its first progress report, and that link is the one
|
||||||
|
thing the send must not lose."""
|
||||||
|
return db.scalar(
|
||||||
|
select(DebugSession.draft["delivery"]["sending"]["by"].as_string()).where(
|
||||||
|
DebugSession.id == session.id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def set_delivery(db: Session, session: DebugSession, state: dict, token: str) -> None:
|
||||||
|
"""Write a delivery's progress (app/issues/gitea.py keeps it; this module reads it), and renew
|
||||||
|
the claim while doing it — a send still reporting progress is still alive, so one slower than
|
||||||
|
the lease cannot expire while it is still running.
|
||||||
|
|
||||||
|
The claim is not the caller's to carry: `gitea.deliver` threads ONE snapshot of this dict
|
||||||
|
through a whole send, so a progress report that carried the claim wrote a stale one back at
|
||||||
|
every step. It is stamped from `token` here instead, and only after the ROW confirms the claim
|
||||||
|
is still ours. Without that check the write is a full-column overwrite with no WHERE: a send
|
||||||
|
that lost its lease came back and stomped the take-over send's committed links, then put its
|
||||||
|
own dead token back on an idle session (deep-review round 4). `release_send` has always made
|
||||||
|
the same check; this is the other half of it."""
|
||||||
|
if claim_owner(db, session) != token:
|
||||||
|
raise SendClaimLost(session.id)
|
||||||
|
state = {k: v for k, v in state.items() if k != "sending"}
|
||||||
|
state["sending"] = _stamp(token)
|
||||||
|
_write_delivery(session, state)
|
||||||
|
|
||||||
|
|
||||||
|
def _write_delivery(session: DebugSession, state: dict) -> None:
|
||||||
|
# A new dict, so the JSONB column sees the change.
|
||||||
|
session.draft = {**(session.draft or {}), "delivery": state}
|
||||||
|
|
||||||
|
|
||||||
|
def serialize(session: DebugSession | None, screenshots: int = 0) -> dict | None:
|
||||||
|
if session is None:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"id": session.id,
|
||||||
|
"level": session.level,
|
||||||
|
"started_at": session.started_at.isoformat(),
|
||||||
|
"until": session.until.isoformat(),
|
||||||
|
"ended_at": session.ended_at.isoformat() if session.ended_at else None,
|
||||||
|
"ended_reason": session.ended_reason,
|
||||||
|
"issue_url": session.issue_url,
|
||||||
|
# A send that stopped half-way: the files it has still to upload, and whether the issue's
|
||||||
|
# body has still to be edited to show the screenshots it already carries. Both are true of a
|
||||||
|
# send that is RUNNING too — every step commits its progress — so `sending` goes with them:
|
||||||
|
# without it a reload or a second tab renders a live send as a failed one, and offers a
|
||||||
|
# retry that is certain to be refused.
|
||||||
|
"pending_files": not_uploaded(session),
|
||||||
|
"images_pending": images_pending(session),
|
||||||
|
"sending": sending(session),
|
||||||
|
"screenshots": screenshots,
|
||||||
|
"draft": session.draft,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def view(db: Session, session: DebugSession) -> dict:
|
||||||
|
"""The session as the API serves it — one shape wherever it is served."""
|
||||||
|
return serialize(session, screenshot_count(db, session)) or {}
|
||||||
|
|
||||||
|
|
||||||
|
def screenshot_count(db: Session, session: DebugSession) -> int:
|
||||||
|
return (
|
||||||
|
db.scalar(
|
||||||
|
select(func.count())
|
||||||
|
.select_from(DebugScreenshot)
|
||||||
|
.where(DebugScreenshot.session_id == session.id)
|
||||||
|
)
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --- screenshots ---------------------------------------------------------------------------
|
||||||
|
# By content, not by the declared type: what is stored is served back to a browser as an image.
|
||||||
|
_SIGNATURES = (
|
||||||
|
(b"\x89PNG\r\n\x1a\n", "image/png"),
|
||||||
|
(b"\xff\xd8\xff", "image/jpeg"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def sniff(data: bytes) -> str | None:
|
||||||
|
"""The image type `data` really is — one of IMAGE_TYPES — or None."""
|
||||||
|
for magic, mime in _SIGNATURES:
|
||||||
|
if data.startswith(magic):
|
||||||
|
return mime
|
||||||
|
if len(data) >= 12 and data[:4] == b"RIFF" and data[8:12] == b"WEBP":
|
||||||
|
return "image/webp"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def lock_user(db: Session, user_id: int) -> None:
|
||||||
|
"""Hold this user's own lock for the rest of the transaction. A session's row lock bounds what
|
||||||
|
THAT session may keep; the byte cap below is the user's across all of them, and a lock on one
|
||||||
|
session cannot serialize a check that reads them all — two uploads to two sessions both read the
|
||||||
|
total before either commits, and both pass."""
|
||||||
|
db.execute(select(func.pg_advisory_xact_lock(user_id)))
|
||||||
|
|
||||||
|
|
||||||
|
def stored_screenshot_bytes(db: Session, user_id: int) -> int:
|
||||||
|
return (
|
||||||
|
db.scalar(
|
||||||
|
select(func.coalesce(func.sum(DebugScreenshot.size), 0))
|
||||||
|
.join(DebugSession, DebugSession.id == DebugScreenshot.session_id)
|
||||||
|
.where(DebugSession.user_id == user_id)
|
||||||
|
)
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ScreenshotRefused(Exception):
|
||||||
|
"""A screenshot that cannot be kept; `code` is the refusal's machine word."""
|
||||||
|
|
||||||
|
def __init__(self, code: str) -> None:
|
||||||
|
super().__init__(code)
|
||||||
|
self.code = code
|
||||||
|
|
||||||
|
|
||||||
|
def add_screenshot(db: Session, session: DebugSession, data: bytes) -> DebugScreenshot:
|
||||||
|
"""Keep one screenshot. Locks the user and the session, and flushes; the caller commits.
|
||||||
|
Refused while a send is under way or once the issue was sent (it can no longer carry it), past
|
||||||
|
the per-session count or the user's storage, or when it is not an image we can show."""
|
||||||
|
if not data:
|
||||||
|
raise ScreenshotRefused("empty")
|
||||||
|
mime = sniff(data)
|
||||||
|
if mime is None:
|
||||||
|
raise ScreenshotRefused("not_an_image")
|
||||||
|
# Under both locks: two uploads at once cannot both pass the count (the session's), nor both
|
||||||
|
# pass the user's byte cap from two different sessions (the user's). Always in this order.
|
||||||
|
lock_user(db, session.user_id)
|
||||||
|
lock(db, session)
|
||||||
|
if sending(session):
|
||||||
|
raise ScreenshotRefused("sending")
|
||||||
|
if session.issue_url is not None:
|
||||||
|
raise ScreenshotRefused("already_sent")
|
||||||
|
if screenshot_count(db, session) >= SCREENSHOTS_MAX:
|
||||||
|
raise ScreenshotRefused("too_many")
|
||||||
|
if stored_screenshot_bytes(db, session.user_id) + len(data) > SCREENSHOT_USER_MAX_BYTES:
|
||||||
|
raise ScreenshotRefused("storage_full")
|
||||||
|
shot = DebugScreenshot(
|
||||||
|
session_id=session.id,
|
||||||
|
created_at=datetime.now(timezone.utc),
|
||||||
|
mime=mime,
|
||||||
|
size=len(data),
|
||||||
|
data=data,
|
||||||
|
)
|
||||||
|
db.add(shot)
|
||||||
|
db.flush()
|
||||||
|
return shot
|
||||||
|
|
||||||
|
|
||||||
|
def remove_screenshot(db: Session, session: DebugSession, shot: DebugScreenshot) -> None:
|
||||||
|
"""Delete one screenshot, under the lock. Refused while a send is under way or unfinished: a
|
||||||
|
delivery names the screenshots by their order, and removing one would renumber what it still
|
||||||
|
has to send. Once the issue has everything, this only drops OUR copy — the issue keeps its
|
||||||
|
own. The caller commits."""
|
||||||
|
lock(db, session)
|
||||||
|
if sending(session):
|
||||||
|
raise ScreenshotRefused("sending")
|
||||||
|
if unfinished(session):
|
||||||
|
raise ScreenshotRefused("already_sent")
|
||||||
|
db.delete(shot)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
|
||||||
|
def screenshots(db: Session, session: DebugSession) -> list[DebugScreenshot]:
|
||||||
|
"""The session's screenshots, oldest first, WITHOUT their bytes loaded."""
|
||||||
|
return list(
|
||||||
|
db.scalars(
|
||||||
|
select(DebugScreenshot)
|
||||||
|
.where(DebugScreenshot.session_id == session.id)
|
||||||
|
.options(defer(DebugScreenshot.data))
|
||||||
|
.order_by(DebugScreenshot.id)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --- the evidence --------------------------------------------------------------------------
|
||||||
|
# The browser's own log upload (`POST` here, every few seconds while recording) is how the evidence
|
||||||
|
# arrives, not part of it: its server lines would fill the bundle with the recording's plumbing. The
|
||||||
|
# browser leaves its copies out for the same reason (frontend lib/diagnostics `isOwnRequest`).
|
||||||
|
INGEST_PATH = "/api/diagnostics/events"
|
||||||
|
|
||||||
|
|
||||||
|
def _evidence(session: DebugSession):
|
||||||
|
upload_line = and_(
|
||||||
|
LogEvent.event == "http.request", LogEvent.attrs["path"].astext == INGEST_PATH
|
||||||
|
)
|
||||||
|
# `IS TRUE`, not the bare condition: for a row with no event or no attrs it is NULL, and
|
||||||
|
# `NOT NULL` would drop exactly the rows that are evidence.
|
||||||
|
return (LogEvent.debug_session_id == session.id, not_(upload_line.is_(True)))
|
||||||
|
|
||||||
|
|
||||||
|
def bundle(
|
||||||
|
db: Session, session: DebugSession, *, drop_excluded: bool = False
|
||||||
|
) -> tuple[list[LogEvent], int]:
|
||||||
|
"""The session's log rows, browser and server interleaved in time, capped at BUNDLE_MAX — and
|
||||||
|
how many rows it has in all, so a cut bundle can say so. `drop_excluded` leaves out the rows
|
||||||
|
the user took out in the composer: what an export or an issue carries."""
|
||||||
|
where = list(_evidence(session))
|
||||||
|
excluded = (session.draft or {}).get("excluded") or [] if drop_excluded else []
|
||||||
|
if excluded:
|
||||||
|
where.append(LogEvent.id.not_in(excluded))
|
||||||
|
rows = list(
|
||||||
|
db.scalars(
|
||||||
|
select(LogEvent).where(*where).order_by(LogEvent.ts, LogEvent.id).limit(BUNDLE_MAX)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
total = len(rows)
|
||||||
|
if total == BUNDLE_MAX:
|
||||||
|
total = db.scalar(select(func.count()).select_from(LogEvent).where(*where)) or total
|
||||||
|
return rows, total
|
||||||
|
|
||||||
|
|
||||||
|
# --- housekeeping (the hourly log job) -----------------------------------------------------
|
||||||
|
def sweep(db: Session) -> int:
|
||||||
|
"""Stage closing every open session past its cap; returns how many."""
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
result = db.execute(
|
||||||
|
update(DebugSession)
|
||||||
|
.where(DebugSession.ended_at.is_(None), DebugSession.until <= now)
|
||||||
|
.values(ended_at=DebugSession.until, ended_reason=EndReason.EXPIRED.value)
|
||||||
|
.execution_options(synchronize_session=False)
|
||||||
|
)
|
||||||
|
return result.rowcount or 0
|
||||||
|
|
||||||
|
|
||||||
|
def gc(db: Session, retention_days: int) -> int:
|
||||||
|
"""Stage deleting sessions (and by cascade their screenshots) started before the log retention
|
||||||
|
window — the rows they would bundle are gone by then too. Returns how many."""
|
||||||
|
cutoff = datetime.now(timezone.utc) - timedelta(days=max(1, retention_days))
|
||||||
|
result = db.execute(delete(DebugSession).where(DebugSession.started_at < cutoff))
|
||||||
|
return result.rowcount or 0
|
||||||
@@ -27,6 +27,9 @@ 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
|
||||||
|
# consent window, because the start dialog's consent text states it and reads it from `state`.
|
||||||
|
SESSION_MINUTES = 60
|
||||||
|
|
||||||
|
|
||||||
def effective_level(user: User, now: datetime | None = None) -> DiagnosticsLevel:
|
def effective_level(user: User, now: datetime | None = None) -> DiagnosticsLevel:
|
||||||
@@ -47,6 +50,7 @@ def state(db: Session, user: User) -> dict:
|
|||||||
"level": level,
|
"level": level,
|
||||||
"until": user.diagnostics_until.isoformat() if level != BASE else None,
|
"until": user.diagnostics_until.isoformat() if level != BASE else None,
|
||||||
"retention_days": sysconfig.get_int(db, "log_retention_days"),
|
"retention_days": sysconfig.get_int(db, "log_retention_days"),
|
||||||
|
"session_minutes": SESSION_MINUTES,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ from app.downloads.jobdb import (
|
|||||||
)
|
)
|
||||||
from app.downloads.stated import StatedFailure
|
from app.downloads.stated import StatedFailure
|
||||||
from app.downloads.status import JobStatus
|
from app.downloads.status import JobStatus
|
||||||
from app.models import DownloadJob, MediaAsset
|
from app.models import DISPLAY_TEXT_MAX, DownloadJob, MediaAsset
|
||||||
from app.notifications import create_notification
|
from app.notifications import create_notification
|
||||||
from app.titles import normalize_title
|
from app.titles import normalize_title
|
||||||
|
|
||||||
@@ -597,7 +597,7 @@ def _download(
|
|||||||
}
|
}
|
||||||
if job and not job.display_name and meta.title:
|
if job and not job.display_name and meta.title:
|
||||||
# display_name is VARCHAR(255) — clip a long title (see _fill_asset_meta_early).
|
# display_name is VARCHAR(255) — clip a long title (see _fill_asset_meta_early).
|
||||||
fields["display_name"] = meta.title[:255]
|
fields["display_name"] = meta.title[:DISPLAY_TEXT_MAX]
|
||||||
# Atomic terminal write: finalizes ONLY while the row is still running, so a child that
|
# Atomic terminal write: finalizes ONLY while the row is still running, so a child that
|
||||||
# finishes just after a route cancel/pause can't resurrect it (see finalize_if_running).
|
# finishes just after a route cancel/pause can't resurrect it (see finalize_if_running).
|
||||||
finalized = service.finalize_if_running(db, job_id, **fields)
|
finalized = service.finalize_if_running(db, job_id, **fields)
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ from app.downloads import credentials
|
|||||||
from app.downloads import edit as editmod
|
from app.downloads import edit as editmod
|
||||||
from app.downloads import formats, mediakind, quota, storage
|
from app.downloads import formats, mediakind, quota, storage
|
||||||
from app.downloads.status import HOLDING, JobStatus
|
from app.downloads.status import HOLDING, JobStatus
|
||||||
from app.models import Channel, DownloadJob, MediaAsset, Video
|
from app.models import DISPLAY_TEXT_MAX, Channel, DownloadJob, MediaAsset, Video
|
||||||
|
|
||||||
|
|
||||||
class EditError(Exception):
|
class EditError(Exception):
|
||||||
@@ -179,7 +179,7 @@ def enqueue(
|
|||||||
profile_id=profile_id,
|
profile_id=profile_id,
|
||||||
profile_snapshot=spec,
|
profile_snapshot=spec,
|
||||||
display_name=display_name
|
display_name=display_name
|
||||||
or (asset.title[:255] if asset.status == "ready" and asset.title else None),
|
or (asset.title[:DISPLAY_TEXT_MAX] if asset.status == "ready" and asset.title else None),
|
||||||
status="queued",
|
status="queued",
|
||||||
queue_pos=next_queue_pos(db),
|
queue_pos=next_queue_pos(db),
|
||||||
)
|
)
|
||||||
@@ -198,10 +198,13 @@ def enqueue(
|
|||||||
|
|
||||||
|
|
||||||
def _clip_title(source_title: str | None, display_name: str | None) -> str:
|
def _clip_title(source_title: str | None, display_name: str | None) -> str:
|
||||||
|
# Clipped to DISPLAY_TEXT_MAX, not to a literal: this feeds `DownloadJob.display_name` through
|
||||||
|
# `enqueue_edit`, the same column `display_text` guards. A machine-made title, so it is clipped
|
||||||
|
# rather than run through `display_text` — blank here means "no title", not None.
|
||||||
if display_name:
|
if display_name:
|
||||||
return display_name[:255]
|
return display_name[:DISPLAY_TEXT_MAX]
|
||||||
base = source_title or "Clip"
|
base = source_title or "Clip"
|
||||||
return f"{base} (clip)"[:255]
|
return f"{base} (clip)"[:DISPLAY_TEXT_MAX]
|
||||||
|
|
||||||
|
|
||||||
def enqueue_edit(
|
def enqueue_edit(
|
||||||
@@ -255,7 +258,7 @@ def enqueue_edit(
|
|||||||
source_asset_id=src_asset.id,
|
source_asset_id=src_asset.id,
|
||||||
edit_spec=spec,
|
edit_spec=spec,
|
||||||
profile_snapshot={},
|
profile_snapshot={},
|
||||||
display_name=display_name or asset.title,
|
display_name=display_name or asset.title[:DISPLAY_TEXT_MAX],
|
||||||
status="queued",
|
status="queued",
|
||||||
queue_pos=next_queue_pos(db),
|
queue_pos=next_queue_pos(db),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ from sqlalchemy.orm import Session
|
|||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.downloads import edit as editmod
|
from app.downloads import edit as editmod
|
||||||
from app.downloads import mediakind, probe, quota, service, storage
|
from app.downloads import mediakind, probe, quota, service, storage
|
||||||
from app.models import DownloadJob, MediaAsset
|
from app.models import DownloadJob, MediaAsset, display_text
|
||||||
|
|
||||||
|
|
||||||
def _title_from_filename(filename: str) -> str:
|
def _title_from_filename(filename: str) -> str:
|
||||||
@@ -99,7 +99,7 @@ def ingest_upload(
|
|||||||
source_kind="upload",
|
source_kind="upload",
|
||||||
source_ref=ref,
|
source_ref=ref,
|
||||||
profile_snapshot={},
|
profile_snapshot={},
|
||||||
display_name=(display_name or "").strip() or None,
|
display_name=display_text(display_name),
|
||||||
status="done",
|
status="done",
|
||||||
progress=100,
|
progress=100,
|
||||||
queue_pos=service.next_queue_pos(db),
|
queue_pos=service.next_queue_pos(db),
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
"""Bug reports built from debug sessions (E16): the files an issue carries, and where they go —
|
||||||
|
downloaded as a zip, or (S4) sent to a Gitea issue tracker. The session itself — what is recorded
|
||||||
|
and tagged — is the core module `app/debugsession.py`."""
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"""What an issue from a debug session carries, as files — one definition for the zip export and the
|
||||||
|
Gitea upload, so the two cannot drift apart.
|
||||||
|
|
||||||
|
* `issue.md` — the title and the body the user wrote and reviewed in the composer (rendered there,
|
||||||
|
in their language; the server does not rewrite it);
|
||||||
|
* `client.log` / `server.log` — the session's evidence (`debugsession.bundle`) minus the rows the
|
||||||
|
user took out, one JSON object per line, browser rows and server rows apart. `.log`, not
|
||||||
|
`.jsonl`: Gitea refuses that extension (measured, plans/e16 S0);
|
||||||
|
* the screenshots, in the order they were taken.
|
||||||
|
|
||||||
|
Every row is already redacted twice (at capture, and again when stored), and nothing here adds a
|
||||||
|
field the review list did not show.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import zipfile
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app import debugsession, logsink
|
||||||
|
from app.models import DebugScreenshot, DebugSession
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class IssueFile:
|
||||||
|
name: str
|
||||||
|
data: bytes
|
||||||
|
mime: str
|
||||||
|
|
||||||
|
|
||||||
|
def title_of(session: DebugSession) -> str:
|
||||||
|
# Not a sentence of ours: the composer always saves a title in the user's language, and a
|
||||||
|
# session exported before it ever saved is named by its date alone, in no language at all.
|
||||||
|
title = ((session.draft or {}).get("title") or "").strip()
|
||||||
|
return title or f"{session.started_at:%Y-%m-%d %H:%M} · {session.id}"
|
||||||
|
|
||||||
|
|
||||||
|
def body_of(session: DebugSession) -> str:
|
||||||
|
return ((session.draft or {}).get("body") or "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _lines(rows) -> bytes:
|
||||||
|
return "".join(
|
||||||
|
json.dumps(logsink.serialize(r), ensure_ascii=False, default=str) + "\n" for r in rows
|
||||||
|
).encode()
|
||||||
|
|
||||||
|
|
||||||
|
def files(db: Session, session: DebugSession) -> list[IssueFile]:
|
||||||
|
"""The session's issue as files: the text, the two logs, the screenshots."""
|
||||||
|
rows, _ = debugsession.bundle(db, session, drop_excluded=True)
|
||||||
|
text = f"# {title_of(session)}\n\n{body_of(session)}\n"
|
||||||
|
out = [
|
||||||
|
IssueFile("issue.md", text.encode(), "text/markdown"),
|
||||||
|
IssueFile("client.log", _lines(r for r in rows if r.source == "web"), "text/plain"),
|
||||||
|
IssueFile("server.log", _lines(r for r in rows if r.source != "web"), "text/plain"),
|
||||||
|
]
|
||||||
|
shots = db.scalars(
|
||||||
|
select(DebugScreenshot)
|
||||||
|
.where(DebugScreenshot.session_id == session.id)
|
||||||
|
.order_by(DebugScreenshot.id)
|
||||||
|
)
|
||||||
|
for n, shot in enumerate(shots, start=1):
|
||||||
|
ext = debugsession.IMAGE_TYPES[shot.mime]
|
||||||
|
name = f"{debugsession.SCREENSHOT_PREFIX}{n}.{ext}"
|
||||||
|
out.append(IssueFile(name, shot.data, shot.mime))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def zipped(items: list[IssueFile]) -> bytes:
|
||||||
|
buf = io.BytesIO()
|
||||||
|
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z:
|
||||||
|
for item in items:
|
||||||
|
# The images are compressed already: deflating them again costs time and wins nothing.
|
||||||
|
method = zipfile.ZIP_STORED if item.mime.startswith("image/") else zipfile.ZIP_DEFLATED
|
||||||
|
z.writestr(item.name, item.data, compress_type=method)
|
||||||
|
return buf.getvalue()
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
"""The Gitea issue tracker a debug session's issue goes to (E16 S4) — measured against Gitea 1.27.1
|
||||||
|
before a line of this was written (plans/e16-debug-issue.md, S0).
|
||||||
|
|
||||||
|
Configured by an admin (sysconfig `issues_gitea_url` / `_repo` / `_token`, the token encrypted at
|
||||||
|
rest). The repository must be PRIVATE: an issue carries its reporter's log — page titles, filter
|
||||||
|
values, at Teljes (TRACE) what they typed into search — and a public repository would publish it. That is
|
||||||
|
checked on every send, not only in the admin's "test connection", because a repository can be made
|
||||||
|
public after the fact.
|
||||||
|
|
||||||
|
What an issue is made of comes from `bundle.files` (the same files the zip export carries). Sending
|
||||||
|
is three kinds of call, measured:
|
||||||
|
* `POST /repos/{repo}/issues` — the title and the body the user wrote;
|
||||||
|
* `POST /repos/{repo}/issues/{n}/assets?name=…` — ONE file per request (field `attachment`);
|
||||||
|
* `PATCH /repos/{repo}/issues/{n}` — the body again, with the screenshots shown inline once their
|
||||||
|
links exist.
|
||||||
|
A private repository's attachment answers 404 to a browser not signed in to Gitea, which is why the
|
||||||
|
composer's own preview shows our copies, never these links.
|
||||||
|
|
||||||
|
Delivery keeps its progress in the session's draft (`delivery`: the issue number, the files still to
|
||||||
|
upload, the links of those uploaded, whether the body shows the screenshots yet), so a failure
|
||||||
|
half-way keeps the issue that exists and a retry does only what is missing — never a second issue.
|
||||||
|
Every step is checkpointed (the caller's `db.commit`), so what did happen is kept whatever fails
|
||||||
|
next — and the first checkpoint comes before the first call, with the tracker's settings read ABOVE
|
||||||
|
it, so neither a row lock nor a read snapshot nor a pooled connection is held across the calls. The
|
||||||
|
caller claims the session for the send first (`debugsession.claim_send`) and passes the claim's
|
||||||
|
token in, which is what keeps a second send, an autosave or a screenshot out of the way; the claim
|
||||||
|
is renewed by every progress report, and a report whose claim is gone raises rather than writing
|
||||||
|
over the send that took it. `debugsession.not_uploaded` / `images_pending` read that progress.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Callable, Iterator
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app import debugsession, logs, sysconfig
|
||||||
|
from app.issues import bundle
|
||||||
|
from app.models import DebugSession
|
||||||
|
|
||||||
|
log = logs.get_logger(__name__)
|
||||||
|
|
||||||
|
TIMEOUT_SECONDS = 20.0
|
||||||
|
|
||||||
|
Reason = Literal[
|
||||||
|
"not_configured", # url, repository or token missing
|
||||||
|
"unreachable", # no answer, or a 5xx
|
||||||
|
"bad_token", # 401/403: the token is wrong or lacks write:issue / read:repository
|
||||||
|
"repo_not_found", # 404 on the repository
|
||||||
|
"public_repo", # the repository is public — an issue would publish the reporter's log
|
||||||
|
"issues_disabled", # the repository has its issue tracker switched off
|
||||||
|
"rejected", # any other 4xx (a file type the instance does not allow, …), or an answer that is
|
||||||
|
# not the API's (a redirect — an http:// address behind https —, a page that is not JSON)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class GiteaError(Exception):
|
||||||
|
def __init__(self, reason: Reason) -> None:
|
||||||
|
super().__init__(reason)
|
||||||
|
self.reason: Reason = reason
|
||||||
|
|
||||||
|
|
||||||
|
def _settings(db: Session) -> tuple[str, str, str]:
|
||||||
|
return (
|
||||||
|
sysconfig.get_str(db, "issues_gitea_url").strip().rstrip("/"),
|
||||||
|
sysconfig.get_str(db, "issues_gitea_repo").strip().strip("/"),
|
||||||
|
sysconfig.get_str(db, "issues_gitea_token").strip(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def target(db: Session) -> dict | None:
|
||||||
|
"""Where a report goes, for the composer to say so — the repository — or None when no tracker is
|
||||||
|
configured (the report can then only be downloaded)."""
|
||||||
|
url, repo, token = _settings(db)
|
||||||
|
return {"repo": repo} if url and repo and token else None
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def _client(settings: tuple[str, str, str]) -> Iterator[tuple[httpx.Client, str]]:
|
||||||
|
# Takes the settings already read, not the session: reading them HERE opened a transaction one
|
||||||
|
# line after `deliver` had ended one, and held it across the calls below (deep-review round 4).
|
||||||
|
url, repo, token = settings
|
||||||
|
if not (url and repo and token):
|
||||||
|
raise GiteaError("not_configured")
|
||||||
|
with httpx.Client(
|
||||||
|
base_url=f"{url}/api/v1",
|
||||||
|
headers={"Authorization": f"token {token}", "Accept": "application/json"},
|
||||||
|
timeout=TIMEOUT_SECONDS,
|
||||||
|
) as client:
|
||||||
|
yield client, repo
|
||||||
|
|
||||||
|
|
||||||
|
def _call(client: httpx.Client, method: str, path: str, **kwargs) -> httpx.Response:
|
||||||
|
try:
|
||||||
|
r = client.request(method, path, **kwargs)
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
log.warning(
|
||||||
|
"the issue tracker did not answer",
|
||||||
|
extra=logs.event("issues.gitea.unreachable", path=path, error=type(exc).__name__),
|
||||||
|
)
|
||||||
|
raise GiteaError("unreachable") from None
|
||||||
|
if r.status_code < 300:
|
||||||
|
return r
|
||||||
|
# The status only: Gitea's message can quote back what was sent — the user's own title.
|
||||||
|
log.warning(
|
||||||
|
"the issue tracker refused a call",
|
||||||
|
extra=logs.event("issues.gitea.refused", path=path, status=r.status_code),
|
||||||
|
)
|
||||||
|
if r.status_code in (401, 403):
|
||||||
|
raise GiteaError("bad_token")
|
||||||
|
if r.status_code == 404:
|
||||||
|
raise GiteaError("repo_not_found")
|
||||||
|
if r.status_code >= 500:
|
||||||
|
raise GiteaError("unreachable")
|
||||||
|
raise GiteaError("rejected")
|
||||||
|
|
||||||
|
|
||||||
|
def _json(r: httpx.Response, *keys: str) -> dict[str, Any]:
|
||||||
|
"""The answer as the API's JSON, holding `keys` — anything else is refused, not a crash, so a
|
||||||
|
step after the issue was created cannot take down the request that holds its record.
|
||||||
|
|
||||||
|
The CREATE call is the one place this cannot keep that record: its answer IS the record. An
|
||||||
|
answer it cannot read there leaves the session unsent, and a retry opens another issue — the
|
||||||
|
price of not being able to tell a refused create from an unreadable one."""
|
||||||
|
try:
|
||||||
|
data = r.json()
|
||||||
|
# silent-ok: not JSON is one of the answers refused (and logged) just below.
|
||||||
|
except ValueError:
|
||||||
|
data = None
|
||||||
|
if not isinstance(data, dict) or any(k not in data for k in keys):
|
||||||
|
log.warning(
|
||||||
|
"the issue tracker answered something that is not its API",
|
||||||
|
extra=logs.event("issues.gitea.unexpected", status=r.status_code),
|
||||||
|
)
|
||||||
|
raise GiteaError("rejected")
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def _private_repo(client: httpx.Client, repo: str) -> dict:
|
||||||
|
data = _json(_call(client, "GET", f"/repos/{repo}"))
|
||||||
|
if not data.get("private"):
|
||||||
|
raise GiteaError("public_repo")
|
||||||
|
if not data.get("has_issues"):
|
||||||
|
raise GiteaError("issues_disabled")
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def check(db: Session) -> dict:
|
||||||
|
"""The admin's "test connection": the repository answers, is private and takes issues."""
|
||||||
|
with _client(_settings(db)) as (client, repo):
|
||||||
|
data = _private_repo(client, repo)
|
||||||
|
return {"repo": data.get("full_name") or repo, "url": data.get("html_url")}
|
||||||
|
|
||||||
|
|
||||||
|
def preview(db: Session, text: str) -> str:
|
||||||
|
"""The body as Gitea itself will render it (its markdown flavour, its sanitiser).
|
||||||
|
|
||||||
|
**No `Context`, deliberately — a repository context here does not fail the call, it kills the
|
||||||
|
tracker.** With one, Gitea's post-processor turns every hex-shaped word into a commit link, and
|
||||||
|
on the API's render path the commit checker it asks is nil: the whole Gitea process segfaults
|
||||||
|
and restarts, taking the git server (and, here, the container registry) down with it. Every
|
||||||
|
report carries hex — a tab id, a request id — so this was not an edge case but the only case.
|
||||||
|
Measured on throwaway instances (2026-09-20): `{"Mode": "gfm", "Context": "<repo>"}` with
|
||||||
|
`5645b1c0ae01` in the text crashes 1.26.4 and 1.27.1 and is fixed in 1.27.3 — but a self-hoster
|
||||||
|
runs whatever version they run, so the payload stays safe here rather than relying on theirs.
|
||||||
|
Dropping `Context` is not a downgrade of the preview: measured against the same body, the HTML
|
||||||
|
keeps its tables, its task lists and its highlighted code blocks. It only stops resolving
|
||||||
|
repo-relative links (a hash, a `#123`), which the issue page itself still resolves.
|
||||||
|
"""
|
||||||
|
with _client(_settings(db)) as (client, _repo):
|
||||||
|
r = _call(client, "POST", "/markdown", json={"Text": text, "Mode": "gfm"})
|
||||||
|
return r.text
|
||||||
|
|
||||||
|
|
||||||
|
def deliver(
|
||||||
|
db: Session,
|
||||||
|
session: DebugSession,
|
||||||
|
*,
|
||||||
|
token: str,
|
||||||
|
checkpoint: Callable[[], None],
|
||||||
|
before_create: Callable[[], None] | None = None,
|
||||||
|
on_created: Callable[[], None] | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Send the session's issue, or finish sending it; returns its URL.
|
||||||
|
|
||||||
|
`token` is the caller's claim on this session (`debugsession.claim_send`). Every progress report
|
||||||
|
renews it and checks the row still holds it, so a send that outlived its lease stops here rather
|
||||||
|
than writing over the one that took over (`debugsession.SendClaimLost`).
|
||||||
|
|
||||||
|
`checkpoint` persists what has happened so far and is called after every step (the caller's
|
||||||
|
commit — this module does not decide when a transaction ends). `before_create` runs right before
|
||||||
|
the tracker is asked to open a NEW issue, after the checks that can refuse it without asking,
|
||||||
|
and may raise to stop it; `on_created` runs right after one was opened, in time to be committed
|
||||||
|
by the SAME checkpoint that first writes `issue_url` — anything the caller must not be able to
|
||||||
|
lose separately from the link belongs there.
|
||||||
|
"""
|
||||||
|
state = debugsession.delivery(session)
|
||||||
|
files = bundle.files(db, session)
|
||||||
|
attachments = [f for f in files if f.name != "issue.md"]
|
||||||
|
settings = _settings(db)
|
||||||
|
# Reading the bundle and the tracker's settings opened a transaction; end it before the first
|
||||||
|
# call, so nothing holds a snapshot (and an idle connection) across minutes of tracker I/O.
|
||||||
|
# The settings are read HERE and handed to `_client`, not read inside it: reading them there
|
||||||
|
# was a SELECT one line after this commit, which reopened the transaction and held it — idle,
|
||||||
|
# with its snapshot pinned — across the repository check and the create (deep-review round 4
|
||||||
|
# measured it in pg_stat_activity). A comment saying otherwise sat right here.
|
||||||
|
checkpoint()
|
||||||
|
with _client(settings) as (client, repo):
|
||||||
|
_private_repo(client, repo)
|
||||||
|
if "number" not in state:
|
||||||
|
if before_create is not None:
|
||||||
|
before_create()
|
||||||
|
created = _json(
|
||||||
|
_call(
|
||||||
|
client,
|
||||||
|
"POST",
|
||||||
|
f"/repos/{repo}/issues",
|
||||||
|
json={"title": bundle.title_of(session), "body": bundle.body_of(session)},
|
||||||
|
),
|
||||||
|
"number",
|
||||||
|
"html_url",
|
||||||
|
)
|
||||||
|
# Built ON the state read at the start so nothing already in it is dropped. The claim
|
||||||
|
# is NOT among those things, whatever this comment used to say: `set_delivery` strips
|
||||||
|
# any `sending` passed to it and stamps the row's own from `token` (round 4 — removing
|
||||||
|
# the spread left the whole suite green, because the claim never travelled here).
|
||||||
|
state = {
|
||||||
|
**state,
|
||||||
|
"number": created["number"],
|
||||||
|
"pending": [f.name for f in attachments],
|
||||||
|
"links": {},
|
||||||
|
}
|
||||||
|
session.issue_url = created["html_url"]
|
||||||
|
try:
|
||||||
|
debugsession.set_delivery(db, session, state, token)
|
||||||
|
except debugsession.SendClaimLost:
|
||||||
|
# The one step where losing the claim costs something irreversible: the issue is
|
||||||
|
# already open on the tracker and this is what would have recorded its link. We
|
||||||
|
# still must not write — another send owns the row and is opening its own issue —
|
||||||
|
# so say which issue was left behind, because nothing else will.
|
||||||
|
# noqa TRY400: the traceback is one SendClaimLost carrying the session id and
|
||||||
|
# nothing else; the issue number below is the whole news.
|
||||||
|
log.error( # noqa: TRY400
|
||||||
|
"an issue was created by a send that had already lost its claim",
|
||||||
|
extra=logs.event(
|
||||||
|
"issues.gitea.orphaned",
|
||||||
|
number=created["number"],
|
||||||
|
session_id=session.id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
if on_created is not None:
|
||||||
|
on_created()
|
||||||
|
checkpoint()
|
||||||
|
log.info(
|
||||||
|
"issue created",
|
||||||
|
extra=logs.event("issues.gitea.created", number=created["number"]),
|
||||||
|
)
|
||||||
|
number = state["number"]
|
||||||
|
for item in attachments:
|
||||||
|
if item.name not in state["pending"]:
|
||||||
|
continue
|
||||||
|
asset = _json(
|
||||||
|
_call(
|
||||||
|
client,
|
||||||
|
"POST",
|
||||||
|
f"/repos/{repo}/issues/{number}/assets",
|
||||||
|
params={"name": item.name},
|
||||||
|
files={"attachment": (item.name, item.data, item.mime)},
|
||||||
|
),
|
||||||
|
"browser_download_url",
|
||||||
|
)
|
||||||
|
state = {
|
||||||
|
**state,
|
||||||
|
"pending": [n for n in state["pending"] if n != item.name],
|
||||||
|
"links": {**state["links"], item.name: asset["browser_download_url"]},
|
||||||
|
}
|
||||||
|
debugsession.set_delivery(db, session, state, token)
|
||||||
|
checkpoint()
|
||||||
|
shots = [n for n in state["links"] if n.startswith(debugsession.SCREENSHOT_PREFIX)]
|
||||||
|
if shots and not state.get("images_shown"):
|
||||||
|
images = "\n".join(f"" for n in sorted(shots))
|
||||||
|
body = f"{bundle.body_of(session)}\n\n---\n\n{images}"
|
||||||
|
_call(client, "PATCH", f"/repos/{repo}/issues/{number}", json={"body": body})
|
||||||
|
debugsession.set_delivery(db, session, {**state, "images_shown": True}, token)
|
||||||
|
checkpoint()
|
||||||
|
return session.issue_url or ""
|
||||||
@@ -100,12 +100,14 @@ class _RequestScope:
|
|||||||
request; every copied context shares the same object, so what the dependency writes is seen by
|
request; every copied context shares the same object, so what the dependency writes is seen by
|
||||||
the handler and by the request line itself."""
|
the handler and by the request line itself."""
|
||||||
|
|
||||||
__slots__ = ("user_id", "level")
|
__slots__ = ("user_id", "level", "debug_session_id")
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
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.
|
||||||
|
self.debug_session_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
_scope: contextvars.ContextVar[_RequestScope | None] = contextvars.ContextVar(
|
_scope: contextvars.ContextVar[_RequestScope | None] = contextvars.ContextVar(
|
||||||
@@ -113,11 +115,23 @@ _scope: contextvars.ContextVar[_RequestScope | None] = contextvars.ContextVar(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def set_user(user_id: int | None, level: str = "INFO", until: datetime | None = None) -> None:
|
def set_user(
|
||||||
|
user_id: int | None,
|
||||||
|
level: str = "INFO",
|
||||||
|
until: datetime | None = None,
|
||||||
|
debug_session: str | None = None,
|
||||||
|
) -> None:
|
||||||
"""Attribute the rest of this request's log lines to `user_id`, and log them at the user's
|
"""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` (E22 S5; "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
|
||||||
|
already checked it is theirs and open; a session's level arrives here as `level`/`until`.
|
||||||
|
|
||||||
|
The root logger's floor is tracked per elevation: a request without the session's header (an
|
||||||
|
`<img>`, a `<video>` — the browser sends those itself) carries only the user's own level, and
|
||||||
|
must not lower the floor a concurrent request of the session still needs."""
|
||||||
_user_id.set(user_id)
|
_user_id.set(user_id)
|
||||||
scope = _scope.get()
|
scope = _scope.get()
|
||||||
if scope is None: # outside log_requests (a script, a unit test): this context only
|
if scope is None: # outside log_requests (a script, a unit test): this context only
|
||||||
@@ -125,9 +139,11 @@ def set_user(user_id: int | None, level: str = "INFO", until: datetime | None =
|
|||||||
_scope.set(scope)
|
_scope.set(scope)
|
||||||
elevated = level != "INFO" and until is not None and until > datetime.now(timezone.utc)
|
elevated = level != "INFO" and until is not None and until > datetime.now(timezone.utc)
|
||||||
scope.user_id = user_id
|
scope.user_id = user_id
|
||||||
|
scope.debug_session_id = debug_session
|
||||||
scope.level = resolve_level(level) if elevated else None
|
scope.level = resolve_level(level) if elevated else None
|
||||||
if user_id is not None:
|
if user_id is not None:
|
||||||
_track_elevation(user_id, scope.level, until)
|
key = user_id if debug_session is None else (user_id, debug_session)
|
||||||
|
_track_elevation(key, scope.level, until)
|
||||||
|
|
||||||
|
|
||||||
def set_job(job_id: int | None) -> None:
|
def set_job(job_id: int | None) -> None:
|
||||||
@@ -328,6 +344,12 @@ class ContextFilter(logging.Filter):
|
|||||||
scope = _scope.get()
|
scope = _scope.get()
|
||||||
if scope is not None and scope.user_id is not None and not hasattr(record, "user_id"):
|
if scope is not None and scope.user_id is not None and not hasattr(record, "user_id"):
|
||||||
record.user_id = scope.user_id
|
record.user_id = scope.user_id
|
||||||
|
if (
|
||||||
|
scope is not None
|
||||||
|
and scope.debug_session_id is not None
|
||||||
|
and not hasattr(record, "debug_session_id")
|
||||||
|
):
|
||||||
|
record.debug_session_id = scope.debug_session_id
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
@@ -444,7 +466,6 @@ _THIRD_PARTY_LEVELS = {
|
|||||||
"httpx": "WARNING",
|
"httpx": "WARNING",
|
||||||
"httpcore": "WARNING",
|
"httpcore": "WARNING",
|
||||||
"asyncio": "WARNING",
|
"asyncio": "WARNING",
|
||||||
"multipart": "WARNING",
|
|
||||||
"PIL": "WARNING",
|
"PIL": "WARNING",
|
||||||
"alembic": "INFO",
|
"alembic": "INFO",
|
||||||
# uvicorn's own startup/shutdown/WebSocket lines are worth keeping; its ACCESS log is not —
|
# uvicorn's own startup/shutdown/WebSocket lines are worth keeping; its ACCESS log is not —
|
||||||
@@ -545,18 +566,21 @@ def current_level() -> str:
|
|||||||
# 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
|
||||||
# user id → (level number, expiry) for every elevation this process has seen and not yet expired.
|
# → (level number, expiry) for every elevation this process has seen and not yet expired: a user's
|
||||||
_elevations: dict[int, tuple[int, datetime]] = {}
|
# own, by user id, and a debug session's (E16), by (user id, session id) — its requests alone carry
|
||||||
|
# its level, so it is tracked apart from the user's own requests.
|
||||||
|
_ElevationKey = int | tuple[int, str]
|
||||||
|
_elevations: dict[_ElevationKey, tuple[int, datetime]] = {}
|
||||||
_elevations_lock = threading.Lock()
|
_elevations_lock = threading.Lock()
|
||||||
_root_applied: int | None = None
|
_root_applied: int | None = None
|
||||||
|
|
||||||
|
|
||||||
def _track_elevation(user_id: int, level: int | None, until: datetime | None) -> None:
|
def _track_elevation(key: _ElevationKey, level: int | None, until: datetime | None) -> None:
|
||||||
with _elevations_lock:
|
with _elevations_lock:
|
||||||
if level is None or until is None:
|
if level is None or until is None:
|
||||||
_elevations.pop(user_id, None)
|
_elevations.pop(key, None)
|
||||||
else:
|
else:
|
||||||
_elevations[user_id] = (level, until)
|
_elevations[key] = (level, until)
|
||||||
active = bool(_elevations)
|
active = bool(_elevations)
|
||||||
if active or _root_applied != _base_level:
|
if active or _root_applied != _base_level:
|
||||||
_apply_root_level()
|
_apply_root_level()
|
||||||
@@ -573,8 +597,8 @@ def _apply_root_level(force: bool = False) -> None:
|
|||||||
# different answer would otherwise apply them in either order, and the older one could win —
|
# different answer would otherwise apply them in either order, and the older one could win —
|
||||||
# the root back at the base while an elevation is still active.
|
# the root back at the base while an elevation is still active.
|
||||||
with _elevations_lock:
|
with _elevations_lock:
|
||||||
for user_id in [u for u, (_, until) in _elevations.items() if until <= now]:
|
for key in [k for k, (_, until) in _elevations.items() if until <= now]:
|
||||||
del _elevations[user_id]
|
del _elevations[key]
|
||||||
lowest = min([_base_level, *(lvl for lvl, _ in _elevations.values())])
|
lowest = min([_base_level, *(lvl for lvl, _ in _elevations.values())])
|
||||||
if force or lowest != _root_applied:
|
if force or lowest != _root_applied:
|
||||||
logging.getLogger().setLevel(lowest)
|
logging.getLogger().setLevel(lowest)
|
||||||
@@ -611,13 +635,24 @@ def forget_user(user_id: int) -> None:
|
|||||||
del _erased[stale]
|
del _erased[stale]
|
||||||
_erased[user_id] = now
|
_erased[user_id] = now
|
||||||
with _elevations_lock:
|
with _elevations_lock:
|
||||||
_elevations.pop(user_id, None)
|
for key in [
|
||||||
|
k for k in _elevations if k == user_id or (isinstance(k, tuple) and k[0] == user_id)
|
||||||
|
]:
|
||||||
|
del _elevations[key]
|
||||||
# That pop can have removed the lowest elevation, and the root level is the lowest of the base
|
# That pop can have removed the lowest elevation, and the root level is the lowest of the base
|
||||||
# and every unexpired one. Recomputed here rather than left to whichever later request happens
|
# and every unexpired one. Recomputed here rather than left to whichever later request happens
|
||||||
# to call `_track_elevation` — the invariant `_apply_root_level` documents holds at every pop.
|
# to call `_track_elevation` — the invariant `_apply_root_level` documents holds at every pop.
|
||||||
_apply_root_level()
|
_apply_root_level()
|
||||||
|
|
||||||
|
|
||||||
|
def end_session_elevation(session_id: str) -> None:
|
||||||
|
"""A debug session was stopped: its elevation ends now, not at its cap."""
|
||||||
|
with _elevations_lock:
|
||||||
|
for key in [k for k in _elevations if isinstance(k, tuple) and k[1] == session_id]:
|
||||||
|
del _elevations[key]
|
||||||
|
_apply_root_level()
|
||||||
|
|
||||||
|
|
||||||
def is_erased(user_id: int | None) -> bool:
|
def is_erased(user_id: int | None) -> bool:
|
||||||
if user_id is None:
|
if user_id is None:
|
||||||
return False
|
return False
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ from app import logs
|
|||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.db import engine
|
from app.db import engine
|
||||||
from app.models import LogEvent
|
from app.models import LogEvent
|
||||||
|
from app.utils import iso
|
||||||
|
|
||||||
QUEUE_MAX = 10_000
|
QUEUE_MAX = 10_000
|
||||||
BATCH_MAX = 500
|
BATCH_MAX = 500
|
||||||
@@ -343,3 +344,24 @@ def gc(db: Session, retention_days: int) -> int:
|
|||||||
cutoff = datetime.now(timezone.utc) - timedelta(days=max(1, retention_days))
|
cutoff = datetime.now(timezone.utc) - timedelta(days=max(1, retention_days))
|
||||||
result = db.execute(delete(LogEvent).where(LogEvent.ts < cutoff))
|
result = db.execute(delete(LogEvent).where(LogEvent.ts < cutoff))
|
||||||
return result.rowcount or 0
|
return result.rowcount or 0
|
||||||
|
|
||||||
|
|
||||||
|
def serialize(row: LogEvent) -> dict:
|
||||||
|
"""A stored row as the API sends it — one shape for every reader: the admin console (E22.6) and
|
||||||
|
a debug session's evidence list (E16)."""
|
||||||
|
return {
|
||||||
|
"id": row.id,
|
||||||
|
"ts": iso(row.ts),
|
||||||
|
"level": row.level,
|
||||||
|
"source": row.source,
|
||||||
|
"logger": row.logger,
|
||||||
|
"event": row.event,
|
||||||
|
"msg": row.msg,
|
||||||
|
"attrs": row.attrs,
|
||||||
|
"exc": row.exc,
|
||||||
|
"request_id": row.request_id,
|
||||||
|
"user_id": row.user_id,
|
||||||
|
"job_id": row.job_id,
|
||||||
|
"debug_session_id": row.debug_session_id,
|
||||||
|
"version": row.version,
|
||||||
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ from fastapi.staticfiles import StaticFiles
|
|||||||
from sqlalchemy.exc import DataError
|
from sqlalchemy.exc import DataError
|
||||||
from starlette.middleware.sessions import SessionMiddleware
|
from starlette.middleware.sessions import SessionMiddleware
|
||||||
|
|
||||||
|
from app import bodylimit
|
||||||
from app import logsink
|
from app import logsink
|
||||||
from app import state
|
from app import state
|
||||||
from app import sysconfig
|
from app import sysconfig
|
||||||
@@ -37,6 +38,7 @@ from app.routes import (
|
|||||||
auth as auth_routes,
|
auth as auth_routes,
|
||||||
channels,
|
channels,
|
||||||
config as config_routes,
|
config as config_routes,
|
||||||
|
debug_sessions as debug_sessions_routes,
|
||||||
diagnostics as diagnostics_routes,
|
diagnostics as diagnostics_routes,
|
||||||
discover as discover_routes,
|
discover as discover_routes,
|
||||||
downloads,
|
downloads,
|
||||||
@@ -188,15 +190,6 @@ app.add_middleware(
|
|||||||
https_only=settings.session_https_only, # Secure flag when served over HTTPS (prod)
|
https_only=settings.session_https_only, # Secure flag when served over HTTPS (prod)
|
||||||
)
|
)
|
||||||
|
|
||||||
if settings.frontend_origin:
|
|
||||||
app.add_middleware(
|
|
||||||
CORSMiddleware,
|
|
||||||
allow_origins=[settings.frontend_origin],
|
|
||||||
allow_credentials=True,
|
|
||||||
allow_methods=["*"],
|
|
||||||
allow_headers=["*"],
|
|
||||||
)
|
|
||||||
|
|
||||||
# Paths reachable while the instance is unconfigured (setup mode). Everything else under /api or
|
# Paths reachable while the instance is unconfigured (setup mode). Everything else under /api or
|
||||||
# /auth is locked until the wizard finishes; static/SPA always loads so the wizard page can render.
|
# /auth is locked until the wizard finishes; static/SPA always loads so the wizard page can render.
|
||||||
_SETUP_OPEN_PREFIXES = ("/api/setup", "/api/version")
|
_SETUP_OPEN_PREFIXES = ("/api/setup", "/api/version")
|
||||||
@@ -216,6 +209,34 @@ async def setup_gate(request, call_next):
|
|||||||
return await call_next(request)
|
return await call_next(request)
|
||||||
|
|
||||||
|
|
||||||
|
# SEC-20 — the global request-body cap (app/bodylimit.py). FastAPI reads a declared body IN FULL
|
||||||
|
# before the first dependency, so without this the anonymous login route is a free memory/time sink.
|
||||||
|
#
|
||||||
|
# WHERE it sits is the whole design, and it is pinned by `test_body_limit.py` rather than by this
|
||||||
|
# comment, because `add_middleware` inverts the reading order (last registered = OUTERMOST) and a
|
||||||
|
# stack described in prose drifts. Registered here it lands directly INSIDE CORS and directly
|
||||||
|
# OUTSIDE the setup gate, which is the narrowest position that satisfies both constraints:
|
||||||
|
#
|
||||||
|
# * inside CORS and the security headers, so the 413 is decorated like every other response. A
|
||||||
|
# refusal the browser drops for a missing Access-Control-Allow-Origin is no better than a hang,
|
||||||
|
# and `capped_body`'s 413 one layer down IS decorated — two shapes for one fact is exactly what
|
||||||
|
# bodylimit.py exists to avoid.
|
||||||
|
# * outside the setup gate, the session and every auth dependency under the router — none of which
|
||||||
|
# run for a body that is already too large. The setup gate matters specifically: on an instance
|
||||||
|
# that is not yet configured it opens a DB session per request, which is precisely the anonymous
|
||||||
|
# pre-auth work SEC-20 exists to remove.
|
||||||
|
app.add_middleware(bodylimit.BodyLimitMiddleware)
|
||||||
|
|
||||||
|
if settings.frontend_origin:
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=[settings.frontend_origin],
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.middleware("http")
|
@app.middleware("http")
|
||||||
async def static_cache_headers(request, call_next):
|
async def static_cache_headers(request, call_next):
|
||||||
"""Long-cache the content-hashed SPA bundles. Vite hashes their filename, so a given
|
"""Long-cache the content-hashed SPA bundles. Vite hashes their filename, so a given
|
||||||
@@ -258,7 +279,8 @@ async def security_headers(request, call_next):
|
|||||||
|
|
||||||
# The access log (app/logs.py): request id, route template, status, duration — one line, never a
|
# The access log (app/logs.py): request id, route template, status, duration — one line, never a
|
||||||
# query string. Registered LAST so it is the OUTERMOST http middleware and therefore times the whole
|
# query string. Registered LAST so it is the OUTERMOST http middleware and therefore times the whole
|
||||||
# request, including what the three above do. Being outermost also puts it OUTSIDE SessionMiddleware,
|
# request, including what every layer above does — the body cap included, so a 413 is still logged
|
||||||
|
# with its request id and duration. Being outermost also puts it OUTSIDE SessionMiddleware,
|
||||||
# which is why the account comes from `request.state.user_id` (set by the auth dependency, the one
|
# which is why the account comes from `request.state.user_id` (set by the auth dependency, the one
|
||||||
# place a session is actually resolved into an account) rather than from `request.session`.
|
# place a session is actually resolved into an account) rather than from `request.session`.
|
||||||
app.middleware("http")(log_requests)
|
app.middleware("http")(log_requests)
|
||||||
@@ -273,6 +295,7 @@ app.include_router(discover_routes.router)
|
|||||||
app.include_router(search_routes.router)
|
app.include_router(search_routes.router)
|
||||||
app.include_router(me.router)
|
app.include_router(me.router)
|
||||||
app.include_router(diagnostics_routes.router)
|
app.include_router(diagnostics_routes.router)
|
||||||
|
app.include_router(debug_sessions_routes.router)
|
||||||
app.include_router(notifications.router)
|
app.include_router(notifications.router)
|
||||||
app.include_router(messages.router)
|
app.include_router(messages.router)
|
||||||
app.include_router(channels.router)
|
app.include_router(channels.router)
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from sqlalchemy import (
|
|||||||
Index,
|
Index,
|
||||||
Integer,
|
Integer,
|
||||||
JSON,
|
JSON,
|
||||||
|
LargeBinary,
|
||||||
SmallInteger,
|
SmallInteger,
|
||||||
String,
|
String,
|
||||||
Text,
|
Text,
|
||||||
@@ -22,6 +23,34 @@ from sqlalchemy.dialects.postgresql import JSONB, TSVECTOR
|
|||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship, validates
|
from sqlalchemy.orm import Mapped, mapped_column, relationship, validates
|
||||||
|
|
||||||
from app.db import Base
|
from app.db import Base
|
||||||
|
|
||||||
|
# Every user-supplied display string in this schema shares one width, and it lives HERE so the
|
||||||
|
# columns below are declared with it rather than with a literal: a name a person typed goes into
|
||||||
|
# `User.display_name` (their Google profile name), `DownloadJob.display_name` and
|
||||||
|
# `DownloadJob.display_uploader`. A value wider than the column is a psycopg DataError, which
|
||||||
|
# `main.py` re-raises for everything but the NUL case — i.e. a bare 500 for a name somebody pasted.
|
||||||
|
DISPLAY_TEXT_MAX = 255
|
||||||
|
|
||||||
|
|
||||||
|
def display_text(raw: str | None) -> str | None:
|
||||||
|
"""A user-supplied display string on its way into one of those columns: stripped, truncated,
|
||||||
|
blank -> None.
|
||||||
|
|
||||||
|
One definition because there are SIX writers and they had drifted: `POST /api/downloads`,
|
||||||
|
`POST /api/downloads/edit`, `POST /api/downloads/upload`, the metadata PATCH's two fields, and
|
||||||
|
the Google sign-in callback. Only the PATCH truncated. Truncating is the right answer rather
|
||||||
|
than refusing: the name is a label on work the user has already done — a file already
|
||||||
|
transferred, an account already authenticated — and losing its tail is not worth failing the
|
||||||
|
request over.
|
||||||
|
|
||||||
|
`test_upload.py` holds both halves: that every route assigning one of these fields goes through
|
||||||
|
this helper (parsed, not grepped), and that each of those routes truncates rather than 500s. A
|
||||||
|
sweep is exactly the kind of thing that gets done five-sixths of the way, which is how this
|
||||||
|
ended up being written twice.
|
||||||
|
"""
|
||||||
|
return (raw or "").strip()[:DISPLAY_TEXT_MAX] or None
|
||||||
|
|
||||||
|
|
||||||
from app.utils import canonical_email
|
from app.utils import canonical_email
|
||||||
|
|
||||||
|
|
||||||
@@ -113,7 +142,7 @@ class User(Base, TimestampMixin, CanonicalEmailMixin):
|
|||||||
# not cleared eagerly — it simply stops counting).
|
# not cleared eagerly — it simply stops counting).
|
||||||
diagnostics_level: Mapped[str | None] = mapped_column(String(8))
|
diagnostics_level: Mapped[str | None] = mapped_column(String(8))
|
||||||
diagnostics_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
diagnostics_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
display_name: Mapped[str | None] = mapped_column(String(255))
|
display_name: Mapped[str | None] = mapped_column(String(DISPLAY_TEXT_MAX))
|
||||||
avatar_url: Mapped[str | None] = mapped_column(String(1024))
|
avatar_url: Mapped[str | None] = mapped_column(String(1024))
|
||||||
role: Mapped[str] = mapped_column(String(16), default="user", server_default="user")
|
role: Mapped[str] = mapped_column(String(16), default="user", server_default="user")
|
||||||
# The user's own YouTube channel id, resolved lazily (channels.list?mine=true) and
|
# The user's own YouTube channel id, resolved lazily (channels.list?mine=true) and
|
||||||
@@ -565,7 +594,15 @@ class LogEvent(Base):
|
|||||||
the retention job (`log_retention_days`) bounds whatever another process writes late."""
|
the retention job (`log_retention_days`) bounds whatever another process writes late."""
|
||||||
|
|
||||||
__tablename__ = "log_events"
|
__tablename__ = "log_events"
|
||||||
__table_args__ = (Index("ix_log_events_user_ts", "user_id", "ts"),)
|
__table_args__ = (
|
||||||
|
Index("ix_log_events_user_ts", "user_id", "ts"),
|
||||||
|
# E16: an issue's bundle is `WHERE debug_session_id = …`; nearly every row has none.
|
||||||
|
Index(
|
||||||
|
"ix_log_events_debug_session",
|
||||||
|
"debug_session_id",
|
||||||
|
postgresql_where="debug_session_id IS NOT NULL",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||||
ts: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
|
ts: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
|
||||||
@@ -584,6 +621,52 @@ class LogEvent(Base):
|
|||||||
version: Mapped[str | None] = mapped_column(String(64))
|
version: Mapped[str | None] = mapped_column(String(64))
|
||||||
|
|
||||||
|
|
||||||
|
class DebugSession(Base):
|
||||||
|
"""One recording: Start → reproduce → Stop (E16, app/debugsession.py). While it is open, every
|
||||||
|
request of its user that names it (`X-Debug-Session`) is logged at `level` and tagged with `id`,
|
||||||
|
and the issue it ends in is built from the rows carrying that tag. `until` is the hard cap the
|
||||||
|
server set at Start; an open session past it is over whether or not the sweep has closed it yet.
|
||||||
|
One OPEN session per user (`uq_debug_sessions_open_per_user`, partial on `ended_at IS NULL`)."""
|
||||||
|
|
||||||
|
__tablename__ = "debug_sessions"
|
||||||
|
__table_args__ = (
|
||||||
|
Index(
|
||||||
|
"uq_debug_sessions_open_per_user",
|
||||||
|
"user_id",
|
||||||
|
unique=True,
|
||||||
|
postgresql_where="ended_at IS NULL",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(16), primary_key=True)
|
||||||
|
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||||
|
level: Mapped[str] = mapped_column(String(8))
|
||||||
|
started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||||
|
until: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||||
|
ended_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
# app/debugsession.py `EndReason`: stopped by the user, or past `until`.
|
||||||
|
ended_reason: Mapped[str | None] = mapped_column(String(16))
|
||||||
|
issue_url: Mapped[str | None] = mapped_column(String(512))
|
||||||
|
# The issue being written from it (E16 S3; app/debugsession.py `save_draft` holds its shape).
|
||||||
|
draft: Mapped[dict | None] = mapped_column(JSONB)
|
||||||
|
|
||||||
|
|
||||||
|
class DebugScreenshot(Base):
|
||||||
|
"""A screenshot attached to a debug session (E16) — pasted, dropped, or captured from the pill.
|
||||||
|
Stored in the row: bounded per session (app/debugsession.py), and gone with it by cascade."""
|
||||||
|
|
||||||
|
__tablename__ = "debug_screenshots"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
session_id: Mapped[str] = mapped_column(
|
||||||
|
ForeignKey("debug_sessions.id", ondelete="CASCADE"), index=True
|
||||||
|
)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||||
|
mime: Mapped[str] = mapped_column(String(16))
|
||||||
|
size: Mapped[int] = mapped_column(Integer)
|
||||||
|
data: Mapped[bytes] = mapped_column(LargeBinary)
|
||||||
|
|
||||||
|
|
||||||
class AppState(Base):
|
class AppState(Base):
|
||||||
"""Single-row global app state (admin-controlled)."""
|
"""Single-row global app state (admin-controlled)."""
|
||||||
|
|
||||||
@@ -898,11 +981,11 @@ class DownloadJob(Base, TimestampMixin, UpdatedAtMixin):
|
|||||||
ForeignKey("download_profiles.id", ondelete="SET NULL")
|
ForeignKey("download_profiles.id", ondelete="SET NULL")
|
||||||
)
|
)
|
||||||
profile_snapshot: Mapped[dict] = mapped_column(JSON)
|
profile_snapshot: Mapped[dict] = mapped_column(JSON)
|
||||||
display_name: Mapped[str | None] = mapped_column(String(255))
|
display_name: Mapped[str | None] = mapped_column(String(DISPLAY_TEXT_MAX))
|
||||||
# Per-user display overrides + extras (like display_name — metadata only, never the file).
|
# Per-user display overrides + extras (like display_name — metadata only, never the file).
|
||||||
# `display_uploader`/`display_uploader_url` override the asset's auto channel name/link;
|
# `display_uploader`/`display_uploader_url` override the asset's auto channel name/link;
|
||||||
# `extra_links` is an optional list of extra reference URLs shown on the card + watch page.
|
# `extra_links` is an optional list of extra reference URLs shown on the card + watch page.
|
||||||
display_uploader: Mapped[str | None] = mapped_column(String(255))
|
display_uploader: Mapped[str | None] = mapped_column(String(DISPLAY_TEXT_MAX))
|
||||||
display_uploader_url: Mapped[str | None] = mapped_column(Text)
|
display_uploader_url: Mapped[str | None] = mapped_column(Text)
|
||||||
extra_links: Mapped[list | None] = mapped_column(JSON)
|
extra_links: Mapped[list | None] = mapped_column(JSON)
|
||||||
status: Mapped[str] = mapped_column(
|
status: Mapped[str] = mapped_column(
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ window still took the full 145 s and emitted the whole track), so windows are ex
|
|||||||
zero-based clock and shifted back to absolute time here with `shift_vtt`.
|
zero-based clock and shifted back to absolute time here with `shift_vtt`.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import codecs
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
@@ -217,7 +218,11 @@ _FULL_END_S = 1_000_000_000.0 # stand-in for "to the end of the file" (JSON can
|
|||||||
|
|
||||||
|
|
||||||
def cache_key(rating_key: str, sub_ord: int) -> str:
|
def cache_key(rating_key: str, sub_ord: int) -> str:
|
||||||
return f"sub_{rating_key}_{sub_ord}"
|
# Versioned whenever `decode_subtitle` changes what a track decodes to — `sub2_` when legacy code
|
||||||
|
# pages were cached as U+FFFD, `sub3_` when a stray byte, an untagged file or Serbian still went
|
||||||
|
# to the wrong code page. The cache has no eviction, so every track is read again once rather
|
||||||
|
# than a broken one being served for good; the older files are just left behind.
|
||||||
|
return f"sub3_{rating_key}_{sub_ord}"
|
||||||
|
|
||||||
|
|
||||||
def _vtt_path(key: str) -> Path:
|
def _vtt_path(key: str) -> Path:
|
||||||
@@ -904,15 +909,90 @@ def _full_pass_start(rating_key: str, targets: dict[int, int]) -> float:
|
|||||||
return max(0.0, float(f"{min(ends) - WINDOW_PRE_S:.3f}"))
|
return max(0.0, float(f"{min(ends) - WINDOW_PRE_S:.3f}"))
|
||||||
|
|
||||||
|
|
||||||
|
# The legacy code page a subtitle in this language (Plex's ISO 639-2 `languageCode`) was most likely
|
||||||
|
# saved in. Serbian sits here, not with Cyrillic: its releases are overwhelmingly Latin script in
|
||||||
|
# Windows-1250, like Croatian and Bosnian.
|
||||||
|
_CP1250 = {
|
||||||
|
"hun",
|
||||||
|
"ces",
|
||||||
|
"cze",
|
||||||
|
"pol",
|
||||||
|
"slk",
|
||||||
|
"slo",
|
||||||
|
"slv",
|
||||||
|
"hrv",
|
||||||
|
"bos",
|
||||||
|
"srp",
|
||||||
|
"ron",
|
||||||
|
"rum",
|
||||||
|
"sqi",
|
||||||
|
"alb",
|
||||||
|
}
|
||||||
|
_CP1251 = {"rus", "ukr", "bel", "bul", "mkd", "mac"}
|
||||||
|
# Western European languages keep Windows-1252 (cp1250 has no à/è/ñ). An UNTAGGED file falls to
|
||||||
|
# Windows-1250: its ASCII and á/é/í/ó/ö/ú/ü match Windows-1252 byte for byte, and it also gets ő/ű
|
||||||
|
# right, which Windows-1252 turns into õ/û — the library this reads is mostly Hungarian.
|
||||||
|
_CP1252 = {
|
||||||
|
"eng",
|
||||||
|
"fre",
|
||||||
|
"fra",
|
||||||
|
"ger",
|
||||||
|
"deu",
|
||||||
|
"spa",
|
||||||
|
"ita",
|
||||||
|
"por",
|
||||||
|
"dut",
|
||||||
|
"nld",
|
||||||
|
"swe",
|
||||||
|
"dan",
|
||||||
|
"nor",
|
||||||
|
"fin",
|
||||||
|
"ice",
|
||||||
|
"isl",
|
||||||
|
"cat",
|
||||||
|
}
|
||||||
|
# A file that is almost all valid UTF-8 is UTF-8 with a few stray bytes (a pasted Windows-1252 quote),
|
||||||
|
# not a legacy file: accents in a legacy code page are nearly never valid UTF-8 sequences, so a real
|
||||||
|
# legacy file has hardly any. At least this many valid multi-byte characters per invalid byte.
|
||||||
|
_UTF8_VALID_PER_STRAY = 10
|
||||||
|
|
||||||
|
|
||||||
|
def decode_subtitle(raw: bytes, language_code: str | None) -> str:
|
||||||
|
"""A sidecar subtitle file as text. SubRip carries no charset, and a Hungarian release is
|
||||||
|
typically Windows-1250: decoded as UTF-8 with `replace`, every ő/ű/é came out as U+FFFD (found
|
||||||
|
live, 1391 of them in one episode). So: a BOM decides; else valid UTF-8 stays UTF-8 (a legacy
|
||||||
|
code page with accents is almost never valid UTF-8), and so does NEARLY valid UTF-8 — one stray
|
||||||
|
byte must not send a whole UTF-8 file to a legacy code page; else the language's legacy code page."""
|
||||||
|
if raw.startswith(codecs.BOM_UTF8):
|
||||||
|
return raw[len(codecs.BOM_UTF8) :].decode("utf-8", "replace")
|
||||||
|
if raw.startswith((codecs.BOM_UTF16_LE, codecs.BOM_UTF16_BE)):
|
||||||
|
return raw.decode("utf-16", "replace")
|
||||||
|
try:
|
||||||
|
return raw.decode("utf-8")
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
# silent-ok: not strictly UTF-8 — nearly-UTF-8 or a legacy code page, told apart below.
|
||||||
|
pass
|
||||||
|
lenient = raw.decode("utf-8", "replace")
|
||||||
|
stray = lenient.count("�")
|
||||||
|
valid = sum(1 for ch in lenient if ch > "" and ch != "�")
|
||||||
|
if valid >= stray * _UTF8_VALID_PER_STRAY:
|
||||||
|
return lenient
|
||||||
|
lang = (language_code or "").lower()
|
||||||
|
legacy = "cp1251" if lang in _CP1251 else "cp1252" if lang in _CP1252 else "cp1250"
|
||||||
|
return raw.decode(legacy, "replace")
|
||||||
|
|
||||||
|
|
||||||
def _sidecar_vtt(db: Session, stream: dict) -> str:
|
def _sidecar_vtt(db: Session, stream: dict) -> str:
|
||||||
"""An external subtitle file, fetched through Plex and converted to WebVTT."""
|
"""An external subtitle file, fetched through Plex and converted to WebVTT. Its bytes are made
|
||||||
|
UTF-8 first (`decode_subtitle`), on the ffmpeg path too: ffmpeg reads a text subtitle as UTF-8."""
|
||||||
with PlexClient(db) as plex:
|
with PlexClient(db) as plex:
|
||||||
raw = plex.raw_get(stream["key"])
|
raw = plex.raw_get(stream["key"])
|
||||||
|
text = decode_subtitle(raw, stream.get("languageCode"))
|
||||||
codec = (stream.get("codec") or stream.get("format") or "").lower()
|
codec = (stream.get("codec") or stream.get("format") or "").lower()
|
||||||
if codec in ("srt", "subrip", ""):
|
if codec in ("srt", "subrip", ""):
|
||||||
return srt_to_vtt(raw.decode("utf-8", "replace"))
|
return srt_to_vtt(text)
|
||||||
with tempfile.NamedTemporaryFile(suffix="." + (codec or "sub"), delete=False) as fh:
|
with tempfile.NamedTemporaryFile(suffix="." + (codec or "sub"), delete=False) as fh:
|
||||||
fh.write(raw)
|
fh.write(text.encode("utf-8"))
|
||||||
tmp = fh.name
|
tmp = fh.name
|
||||||
try:
|
try:
|
||||||
return ffmpeg_to_vtt(tmp, None)
|
return ffmpeg_to_vtt(tmp, None)
|
||||||
|
|||||||
@@ -6,13 +6,15 @@ and "fetch a row this user owns or 404" idioms have one definition, one status c
|
|||||||
one existence-leak policy.
|
one existence-leak policy.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Any, TypeVar
|
from typing import IO, Any, TypeVar
|
||||||
|
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException, Request
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.db import Base
|
from app.db import Base
|
||||||
from app.models import User
|
from app.models import User
|
||||||
|
from app.issues import gitea
|
||||||
|
from app.refusal import refused
|
||||||
from app.youtube.client import YouTubeError, refusal_reason
|
from app.youtube.client import YouTubeError, refusal_reason
|
||||||
|
|
||||||
M = TypeVar("M", bound=Base)
|
M = TypeVar("M", bound=Base)
|
||||||
@@ -26,10 +28,13 @@ def not_found(what: str = "generic") -> HTTPException:
|
|||||||
return HTTPException(status_code=404, detail={"code": "not_found", "reason": what})
|
return HTTPException(status_code=404, detail={"code": "not_found", "reason": what})
|
||||||
|
|
||||||
|
|
||||||
def get_or_404(db: Session, model: type[M], id_: Any, *, what: str = "generic") -> M:
|
def get_or_404(
|
||||||
|
db: Session, model: type[M], id_: Any, *, what: str = "generic", lock: bool = False
|
||||||
|
) -> M:
|
||||||
"""Fetch a row by primary key or raise 404. Replaces the `db.get(Model, id); if None: 404`
|
"""Fetch a row by primary key or raise 404. Replaces the `db.get(Model, id); if None: 404`
|
||||||
idiom repeated across routes."""
|
idiom repeated across routes. `lock` takes it FOR UPDATE (bypassing the identity map), so two
|
||||||
obj = db.get(model, id_)
|
requests that read-then-write its children (a playlist's item positions) run one after the other."""
|
||||||
|
obj = db.get(model, id_, with_for_update=True if lock else None)
|
||||||
if obj is None:
|
if obj is None:
|
||||||
raise not_found(what)
|
raise not_found(what)
|
||||||
return obj
|
return obj
|
||||||
@@ -43,12 +48,13 @@ def owned_or_404(
|
|||||||
*,
|
*,
|
||||||
owner_attr: str = "user_id",
|
owner_attr: str = "user_id",
|
||||||
what: str = "generic",
|
what: str = "generic",
|
||||||
|
lock: bool = False,
|
||||||
) -> M:
|
) -> M:
|
||||||
"""Fetch a row by primary key that `user` owns, or raise 404. A row owned by someone
|
"""Fetch a row by primary key that `user` owns, or raise 404 (`lock`: see `get_or_404`). A row owned by someone
|
||||||
else 404s (NOT 403) on purpose — a 403 would confirm the id exists to a stranger.
|
else 404s (NOT 403) on purpose — a 403 would confirm the id exists to a stranger.
|
||||||
It replaced the near-identical per-route ownership helpers, and is called directly — a thin
|
It replaced the near-identical per-route ownership helpers, and is called directly — a thin
|
||||||
`_own_x` wrapper around it is the ad-hoc shape CLAUDE.md's patterns table rules out."""
|
`_own_x` wrapper around it is the ad-hoc shape CLAUDE.md's patterns table rules out."""
|
||||||
obj = get_or_404(db, model, id_, what=what)
|
obj = get_or_404(db, model, id_, what=what, lock=lock)
|
||||||
if getattr(obj, owner_attr) != user.id:
|
if getattr(obj, owner_attr) != user.id:
|
||||||
raise not_found(what)
|
raise not_found(what)
|
||||||
return obj
|
return obj
|
||||||
@@ -63,3 +69,77 @@ def youtube_refusal(exc: Exception) -> HTTPException:
|
|||||||
YouTube being unavailable to us — the retryable answer."""
|
YouTube being unavailable to us — the retryable answer."""
|
||||||
reason = refusal_reason(exc) if isinstance(exc, YouTubeError) else "unavailable"
|
reason = refusal_reason(exc) if isinstance(exc, YouTubeError) else "unavailable"
|
||||||
return HTTPException(status_code=422, detail={"code": "youtube", "reason": reason})
|
return HTTPException(status_code=422, detail={"code": "youtube", "reason": reason})
|
||||||
|
|
||||||
|
|
||||||
|
async def capped_body(request: Request, limit: int) -> bytes:
|
||||||
|
"""The request body, refused (413 `too_large`) the moment it passes `limit` bytes while still
|
||||||
|
arriving. Counted, not taken from Content-Length: a declared length can be absent (chunked) or
|
||||||
|
wrong, and counting stops reading at the same point a declared-length check would have."""
|
||||||
|
body = bytearray()
|
||||||
|
async for chunk in request.stream():
|
||||||
|
body += chunk
|
||||||
|
if len(body) > limit:
|
||||||
|
raise HTTPException(status_code=413, detail={"code": "too_large"})
|
||||||
|
return bytes(body)
|
||||||
|
|
||||||
|
|
||||||
|
async def capped_stream_to(
|
||||||
|
request: Request, out: IO[bytes], limit: int, refusal: HTTPException
|
||||||
|
) -> int:
|
||||||
|
"""`capped_body`'s sibling for a body too large to hold: the same counting rule, written
|
||||||
|
straight to `out` instead of accumulated, returning the size.
|
||||||
|
|
||||||
|
Two deliberate differences from `capped_body`. It never builds a `bytearray` — the upload route
|
||||||
|
admits 500 MiB by default, so holding the body would trade a disk problem for a memory one. And
|
||||||
|
the refusal is the CALLER'S, passed in rather than assumed: `capped_body`'s flat 413 is right
|
||||||
|
for a screenshot, while the upload route answers 422 `upload_too_large` carrying the admin
|
||||||
|
knob's own number, which is what its dialog shows the user. One counting rule, two answers,
|
||||||
|
neither of them a second copy of the loop.
|
||||||
|
|
||||||
|
The caller owns `out` and owns cleaning it up: a body refused half-way has already written what
|
||||||
|
arrived before the limit was passed.
|
||||||
|
|
||||||
|
**The refusal DRAINS the rest of the body before it is raised, and that is not politeness.**
|
||||||
|
Answering mid-upload and closing the connection is what the client sees as a network failure,
|
||||||
|
not as a refusal: the browser's XHR fires `onerror` before it can read the response, so a file
|
||||||
|
over the limit reported itself as "the server couldn't be reached" instead of naming the limit —
|
||||||
|
measured in a real browser, not reasoned about. Reading the remainder to nowhere lets the client
|
||||||
|
finish sending, read the 422 and say the true thing.
|
||||||
|
|
||||||
|
The bytes are discarded, never written, and the whole request is already bounded by
|
||||||
|
`bodylimit`'s per-route ceiling — so the worst case is bandwidth an authenticated caller was
|
||||||
|
sending anyway, against the old behaviour which wrote exactly those bytes to disk."""
|
||||||
|
size = 0
|
||||||
|
over = False
|
||||||
|
async for chunk in request.stream():
|
||||||
|
size += len(chunk)
|
||||||
|
if over:
|
||||||
|
continue # draining — see the docstring; counted, not stored
|
||||||
|
if size > limit:
|
||||||
|
over = True
|
||||||
|
continue
|
||||||
|
out.write(chunk)
|
||||||
|
if over:
|
||||||
|
raise refusal
|
||||||
|
return size
|
||||||
|
|
||||||
|
|
||||||
|
def issue_refused(exc: gitea.GiteaError) -> HTTPException:
|
||||||
|
"""The refusal an issue-tracker failure (E16) answers as — the send, the preview and the
|
||||||
|
admin's connection test alike. Literal reasons, one per case: the frontend's contract test reads
|
||||||
|
them to require a sentence for each in both languages."""
|
||||||
|
match exc.reason:
|
||||||
|
case "not_configured":
|
||||||
|
return refused(400, "issues_not_configured")
|
||||||
|
case "unreachable":
|
||||||
|
return refused(422, "issues_unreachable")
|
||||||
|
case "bad_token":
|
||||||
|
return refused(422, "issues_bad_token")
|
||||||
|
case "repo_not_found":
|
||||||
|
return refused(422, "issues_repo_not_found")
|
||||||
|
case "public_repo":
|
||||||
|
return refused(422, "issues_public_repo")
|
||||||
|
case "issues_disabled":
|
||||||
|
return refused(422, "issues_disabled")
|
||||||
|
case _:
|
||||||
|
return refused(422, "issues_rejected")
|
||||||
|
|||||||
@@ -26,10 +26,11 @@ from fastapi import APIRouter, Depends, HTTPException, Query
|
|||||||
from sqlalchemy import Text, and_, cast, func, or_, select
|
from sqlalchemy import Text, and_, cast, func, or_, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app import logsink
|
||||||
from app.auth import admin_user
|
from app.auth import admin_user
|
||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.models import LogEvent, User
|
from app.models import LogEvent, User
|
||||||
from app.utils import iso, like_escape
|
from app.utils import like_escape
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/admin/logs", tags=["admin"])
|
router = APIRouter(prefix="/api/admin/logs", tags=["admin"])
|
||||||
|
|
||||||
@@ -66,25 +67,6 @@ def _decode_cursor(cursor: str) -> tuple[datetime, int]:
|
|||||||
raise HTTPException(status_code=400, detail={"code": "bad_cursor"}) from exc
|
raise HTTPException(status_code=400, detail={"code": "bad_cursor"}) from exc
|
||||||
|
|
||||||
|
|
||||||
def _serialize(row: LogEvent) -> dict:
|
|
||||||
return {
|
|
||||||
"id": row.id,
|
|
||||||
"ts": iso(row.ts),
|
|
||||||
"level": row.level,
|
|
||||||
"source": row.source,
|
|
||||||
"logger": row.logger,
|
|
||||||
"event": row.event,
|
|
||||||
"msg": row.msg,
|
|
||||||
"attrs": row.attrs,
|
|
||||||
"exc": row.exc,
|
|
||||||
"request_id": row.request_id,
|
|
||||||
"user_id": row.user_id,
|
|
||||||
"job_id": row.job_id,
|
|
||||||
"debug_session_id": row.debug_session_id,
|
|
||||||
"version": row.version,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _accounts(db: Session, user_ids: set[int]) -> dict[str, str]:
|
def _accounts(db: Session, user_ids: set[int]) -> dict[str, str]:
|
||||||
"""The accounts a page names, by id → address. An id with no account left (erased after the
|
"""The accounts a page names, by id → address. An id with no account left (erased after the
|
||||||
line was written) is simply absent, and the client shows the bare id."""
|
line was written) is simply absent, and the client shows the bare id."""
|
||||||
@@ -196,7 +178,7 @@ def list_logs(
|
|||||||
more = len(rows) > limit
|
more = len(rows) > limit
|
||||||
rows = rows[:limit]
|
rows = rows[:limit]
|
||||||
return {
|
return {
|
||||||
"items": [_serialize(r) for r in rows],
|
"items": [logsink.serialize(r) for r in rows],
|
||||||
"accounts": _accounts(db, {r.user_id for r in rows if r.user_id is not None}),
|
"accounts": _accounts(db, {r.user_id for r in rows if r.user_id is not None}),
|
||||||
"next_cursor": _encode_cursor(rows[-1]) if more else None,
|
"next_cursor": _encode_cursor(rows[-1]) if more else None,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ from app.auth import (
|
|||||||
)
|
)
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.db import SessionLocal, get_db
|
from app.db import SessionLocal, get_db
|
||||||
from app.models import AuthToken, DemoWhitelist, Invite, OAuthToken, User
|
from app.models import AuthToken, DemoWhitelist, Invite, OAuthToken, User, display_text
|
||||||
from app.ratelimit import RateLimiter, client_key
|
from app.ratelimit import RateLimiter, client_key
|
||||||
from app.security import encrypt, hash_password, hash_token, verify_password
|
from app.security import encrypt, hash_password, hash_token, verify_password
|
||||||
from app.utils import canonical_email, valid_email
|
from app.utils import canonical_email, valid_email
|
||||||
@@ -472,7 +472,7 @@ async def callback(
|
|||||||
if email:
|
if email:
|
||||||
user.google_email = email
|
user.google_email = email
|
||||||
_sync_google_email(db, user, email)
|
_sync_google_email(db, user, email)
|
||||||
user.display_name = userinfo.get("name")
|
user.display_name = display_text(userinfo.get("name"))
|
||||||
user.avatar_url = userinfo.get("picture")
|
user.avatar_url = userinfo.get("picture")
|
||||||
# ADMIN_EMAILS (env) is the bootstrap admin list and always wins, so the configured admin can't
|
# ADMIN_EMAILS (env) is the bootstrap admin list and always wins, so the configured admin can't
|
||||||
# be locked out. Everyone else's role is managed in the admin UI and stored in the DB, so a
|
# be locked out. Everyone else's role is managed in the admin UI and stored in the DB, so a
|
||||||
@@ -594,7 +594,7 @@ def _complete_link(
|
|||||||
if linked_email:
|
if linked_email:
|
||||||
user.google_email = linked_email # see the sign-in callback: the other half of the identity
|
user.google_email = linked_email # see the sign-in callback: the other half of the identity
|
||||||
if not user.display_name:
|
if not user.display_name:
|
||||||
user.display_name = userinfo.get("name")
|
user.display_name = display_text(userinfo.get("name"))
|
||||||
if not user.avatar_url:
|
if not user.avatar_url:
|
||||||
user.avatar_url = userinfo.get("picture")
|
user.avatar_url = userinfo.get("picture")
|
||||||
_store_token(db, user, token)
|
_store_token(db, user, token)
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ from app.models import (
|
|||||||
from app.routes._common import get_or_404, not_found, owned_or_404, youtube_refusal
|
from app.routes._common import get_or_404, not_found, owned_or_404, youtube_refusal
|
||||||
from app.sync.explore import explore_ingest_page
|
from app.sync.explore import explore_ingest_page
|
||||||
from app.sync.runner import backfill_channel_now, run_recent_backfill
|
from app.sync.runner import backfill_channel_now, run_recent_backfill
|
||||||
from app.sync.subscriptions import apply_channel_details
|
from app.sync.subscriptions import apply_channel_details, details_stale
|
||||||
from app.utils import iso
|
from app.utils import iso
|
||||||
from app.youtube.client import YouTubeClient, YouTubeError
|
from app.youtube.client import YouTubeClient, YouTubeError
|
||||||
from app.refusal import refused
|
from app.refusal import refused
|
||||||
@@ -190,7 +190,7 @@ def discover_channels(
|
|||||||
quota.attribute(user.id, quota.QuotaAction.CHANNELS_DISCOVER),
|
quota.attribute(user.id, quota.QuotaAction.CHANNELS_DISCOVER),
|
||||||
YouTubeClient(db, user) as yt,
|
YouTubeClient(db, user) as yt,
|
||||||
):
|
):
|
||||||
apply_channel_details(db, yt.get_channels(need))
|
apply_channel_details(db, yt.get_channels(need), need)
|
||||||
db.commit()
|
db.commit()
|
||||||
# Match _discovery_rows' ORDER BY exactly: video-count DESC, then title (NULLs LAST,
|
# Match _discovery_rows' ORDER BY exactly: video-count DESC, then title (NULLs LAST,
|
||||||
# as Postgres orders them — coercing NULL→"" would wrongly float untitled channels up).
|
# as Postgres orders them — coercing NULL→"" would wrongly float untitled channels up).
|
||||||
@@ -296,17 +296,20 @@ def channel_detail(
|
|||||||
"""One channel's "About" detail for the channel page header/About tab, plus this user's
|
"""One channel's "About" detail for the channel page header/About tab, plus this user's
|
||||||
relationship to it (subscribed / explored) and how many of its videos are in the catalog.
|
relationship to it (subscribed / explored) and how many of its videos are in the catalog.
|
||||||
Lazily fills the About metadata (view count, join date, banner, links) the first time it's
|
Lazily fills the About metadata (view count, join date, banner, links) the first time it's
|
||||||
needed — a stub, or a channel last enriched before those fields existed (published_at is a
|
needed (a stub) and refreshes it once it is stale (`DETAILS_MAX_AGE`), which is how a channel nobody subscribes to (an explored one, outside the
|
||||||
reliable sentinel: every real channel has a creation date). The enrich uses the API key
|
subscription import) gets a banner that still resolves. The enrich uses the API key (1 unit,
|
||||||
(1 unit, cached after), so it's skipped for the quota-less demo account."""
|
cached after), so it's skipped for the quota-less demo account."""
|
||||||
channel = get_or_404(db, Channel, channel_id, what="channel")
|
channel = get_or_404(db, Channel, channel_id, what="channel")
|
||||||
if channel.published_at is None and not user.is_demo:
|
# No `published_at is None` sentinel: a terminated channel never gets one, so it re-fetched on
|
||||||
|
# every view; rows enriched before that field existed are long past DETAILS_MAX_AGE anyway.
|
||||||
|
stale = details_stale(channel, datetime.now(timezone.utc))
|
||||||
|
if stale and not user.is_demo:
|
||||||
try:
|
try:
|
||||||
with (
|
with (
|
||||||
quota.attribute(user.id, quota.QuotaAction.CHANNELS_DISCOVER),
|
quota.attribute(user.id, quota.QuotaAction.CHANNELS_DISCOVER),
|
||||||
YouTubeClient(db, user) as yt,
|
YouTubeClient(db, user) as yt,
|
||||||
):
|
):
|
||||||
apply_channel_details(db, yt.get_channels([channel_id]))
|
apply_channel_details(db, yt.get_channels([channel_id]), [channel_id])
|
||||||
db.commit()
|
db.commit()
|
||||||
channel = db.get(Channel, channel_id)
|
channel = db.get(Channel, channel_id)
|
||||||
except YouTubeError as exc:
|
except YouTubeError as exc:
|
||||||
@@ -412,12 +415,10 @@ def explore_channel(
|
|||||||
YouTubeClient(db, user) as yt,
|
YouTubeClient(db, user) as yt,
|
||||||
):
|
):
|
||||||
# Enrich for the About panel + the uploads playlist id (needed to ingest videos).
|
# Enrich for the About panel + the uploads playlist id (needed to ingest videos).
|
||||||
if (
|
# Stale, not "a field is missing": a terminated channel never gets an uploads playlist
|
||||||
channel.details_synced_at is None
|
# or a creation date, and asking again on every explore would spend quota on a miss.
|
||||||
or channel.uploads_playlist_id is None
|
if details_stale(channel, datetime.now(timezone.utc)):
|
||||||
or channel.published_at is None
|
apply_channel_details(db, yt.get_channels([channel_id]), [channel_id])
|
||||||
):
|
|
||||||
apply_channel_details(db, yt.get_channels([channel_id]))
|
|
||||||
db.commit()
|
db.commit()
|
||||||
channel = db.get(Channel, channel_id)
|
channel = db.get(Channel, channel_id)
|
||||||
result = explore_ingest_page(db, yt, channel, page_token)
|
result = explore_ingest_page(db, yt, channel, page_token)
|
||||||
@@ -590,7 +591,7 @@ def subscribe(
|
|||||||
# must not block recording the subscription the user asked for.
|
# must not block recording the subscription the user asked for.
|
||||||
if channel.details_synced_at is None:
|
if channel.details_synced_at is None:
|
||||||
try:
|
try:
|
||||||
apply_channel_details(db, yt.get_channels([channel_id]))
|
apply_channel_details(db, yt.get_channels([channel_id]), [channel_id])
|
||||||
except YouTubeError as exc:
|
except YouTubeError as exc:
|
||||||
log.warning(
|
log.warning(
|
||||||
"subscribe: stub enrich failed: %s",
|
"subscribe: stub enrich failed: %s",
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ from app.auth import admin_user
|
|||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.models import User
|
from app.models import User
|
||||||
from app.refusal import refused
|
from app.refusal import refused
|
||||||
from app.routes._common import not_found
|
from app.issues import gitea
|
||||||
|
from app.routes._common import issue_refused, not_found
|
||||||
from app.refusal import setting_refused
|
from app.refusal import setting_refused
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/admin/config", tags=["admin"])
|
router = APIRouter(prefix="/api/admin/config", tags=["admin"])
|
||||||
@@ -131,3 +132,16 @@ def send_test_email(
|
|||||||
if not sent:
|
if not sent:
|
||||||
raise refused(422, "smtp_failed")
|
raise refused(422, "smtp_failed")
|
||||||
return {"ok": True, "sent": True, "to": user.email}
|
return {"ok": True, "sent": True, "to": user.email}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/test-issues")
|
||||||
|
def test_issue_tracker(
|
||||||
|
_: User = Depends(admin_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
"""Check the configured issue tracker (E16): the repository answers with this token, takes
|
||||||
|
issues, and is PRIVATE — the one it must be, since an issue carries its reporter's log."""
|
||||||
|
try:
|
||||||
|
return {"ok": True, **gitea.check(db)}
|
||||||
|
except gitea.GiteaError as exc:
|
||||||
|
raise issue_refused(exc) from None
|
||||||
|
|||||||
@@ -0,0 +1,418 @@
|
|||||||
|
"""Debug sessions (E16): Start → reproduce → Stop, the screenshots, and the evidence under review.
|
||||||
|
|
||||||
|
The session itself — what tags a request, its cap, its level — is `app/debugsession.py`. These routes
|
||||||
|
are the user's handle on their OWN sessions only: another user's id 404s (`owned_or_404`), including
|
||||||
|
for an admin, who reads everyone's lines in the diagnostics console instead. The shared demo account
|
||||||
|
cannot record: its log would interleave every visitor under one account.
|
||||||
|
|
||||||
|
Screenshots arrive as a raw image body (`image/png`, `image/jpeg`, `image/webp`), not a multipart
|
||||||
|
form — one image per request, capped while it arrives, and kept only if its bytes really are one of
|
||||||
|
those types, whatever the Content-Type claimed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app import audit, debugsession, logs, logsink
|
||||||
|
from app.audit import AuditAction
|
||||||
|
from app.auth import require_human
|
||||||
|
from app.db import get_db, release_read_connection
|
||||||
|
from app.issues import bundle, gitea
|
||||||
|
from app.ratelimit import RateLimiter
|
||||||
|
from app.refusal import refused
|
||||||
|
from app.models import DebugScreenshot, DebugSession, User
|
||||||
|
from app.routes._common import capped_body, issue_refused, not_found, owned_or_404
|
||||||
|
from app.utils import iso
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/debug-sessions", tags=["diagnostics"])
|
||||||
|
log = logs.get_logger(__name__)
|
||||||
|
|
||||||
|
# Sessions listed in Settings → Diagnosztika, newest first: enough to reach last week's unsent draft.
|
||||||
|
LIST_MAX = 20
|
||||||
|
|
||||||
|
|
||||||
|
class StartIn(BaseModel):
|
||||||
|
level: debugsession.SessionLevel
|
||||||
|
|
||||||
|
|
||||||
|
_view = debugsession.view
|
||||||
|
|
||||||
|
# New sessions a user may open per hour (joining the running one is not counted): a session is a
|
||||||
|
# place to keep screenshots, and this — with the per-user storage cap — bounds how many there are.
|
||||||
|
_new_sessions = RateLimiter(max_events=20, window_seconds=3600)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("")
|
||||||
|
def start_session(
|
||||||
|
body: StartIn,
|
||||||
|
user: User = Depends(require_human),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
"""Start recording, or join the recording already running (another tab started it)."""
|
||||||
|
joining = debugsession.open_session(db, user) is not None
|
||||||
|
if not joining and not _new_sessions.allow(str(user.id)):
|
||||||
|
raise refused(429, "debug_session_rate_limited", limit=_new_sessions.max_events)
|
||||||
|
try:
|
||||||
|
session = debugsession.start(db, user, body.level)
|
||||||
|
db.commit()
|
||||||
|
except IntegrityError:
|
||||||
|
# A concurrent Start won the one-open-per-user index: join that one.
|
||||||
|
db.rollback()
|
||||||
|
session = debugsession.open_session(db, user)
|
||||||
|
if session is None:
|
||||||
|
raise refused(409, "debug_session_conflict") from None
|
||||||
|
# The rest of THIS request is part of the session too.
|
||||||
|
level, until = debugsession.level_for(user, session)
|
||||||
|
logs.set_user(user.id, level, until, session.id)
|
||||||
|
log.info(
|
||||||
|
"debug session started",
|
||||||
|
extra=logs.event("debug.session.started", level=session.level, until=iso(session.until)),
|
||||||
|
)
|
||||||
|
return {"ok": True, "session": _view(db, session)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{sid}/stop")
|
||||||
|
def stop_session(
|
||||||
|
sid: str, user: User = Depends(require_human), db: Session = Depends(get_db)
|
||||||
|
) -> dict:
|
||||||
|
session = owned_or_404(db, DebugSession, sid, user, what="debug_session")
|
||||||
|
if session.ended_at is None:
|
||||||
|
# Logged BEFORE the session closes, while this request still carries its tag: the last line
|
||||||
|
# of the evidence is the Stop itself.
|
||||||
|
log.info("debug session stopped", extra=logs.event("debug.session.stopped"))
|
||||||
|
debugsession.stop(session)
|
||||||
|
db.commit()
|
||||||
|
logs.end_session_elevation(session.id)
|
||||||
|
return {"ok": True, "session": _view(db, session)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
def list_sessions(user: User = Depends(require_human), db: Session = Depends(get_db)) -> dict:
|
||||||
|
sessions = db.scalars(
|
||||||
|
select(DebugSession)
|
||||||
|
.where(DebugSession.user_id == user.id)
|
||||||
|
.order_by(DebugSession.started_at.desc())
|
||||||
|
.limit(LIST_MAX)
|
||||||
|
).all()
|
||||||
|
return {"items": [_view(db, s) for s in sessions]}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{sid}")
|
||||||
|
def get_session(
|
||||||
|
sid: str, user: User = Depends(require_human), db: Session = Depends(get_db)
|
||||||
|
) -> dict:
|
||||||
|
return _view(db, owned_or_404(db, DebugSession, sid, user, what="debug_session"))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{sid}/events")
|
||||||
|
def session_events(
|
||||||
|
sid: str, user: User = Depends(require_human), db: Session = Depends(get_db)
|
||||||
|
) -> dict:
|
||||||
|
"""The rows an issue from this session would carry — browser and server, in time order."""
|
||||||
|
session = owned_or_404(db, DebugSession, sid, user, what="debug_session")
|
||||||
|
rows, total = debugsession.bundle(db, session)
|
||||||
|
return {"items": [logsink.serialize(r) for r in rows], "total": total}
|
||||||
|
|
||||||
|
|
||||||
|
# --- the draft and its export ---------------------------------------------------------------
|
||||||
|
# The composer's own fields (steps, description…) are its business, kept whole so it reopens as it
|
||||||
|
# was — bounded here so a draft cannot become a place to store anything else.
|
||||||
|
FORM_MAX_BYTES = 64 * 1024
|
||||||
|
# Zips a user may build per minute: each holds every screenshot in memory while it is built.
|
||||||
|
_exports = RateLimiter(max_events=6, window_seconds=60)
|
||||||
|
|
||||||
|
|
||||||
|
class DraftIn(BaseModel):
|
||||||
|
title: str = Field(max_length=200)
|
||||||
|
# The rendered markdown the export and the issue carry.
|
||||||
|
body: str = Field(max_length=50_000)
|
||||||
|
# Log rows taken out of the evidence in the review.
|
||||||
|
excluded: list[int] = Field(default_factory=list, max_length=debugsession.BUNDLE_MAX)
|
||||||
|
form: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{sid}/draft")
|
||||||
|
def save_draft(
|
||||||
|
sid: str,
|
||||||
|
body: DraftIn,
|
||||||
|
user: User = Depends(require_human),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
session = owned_or_404(db, DebugSession, sid, user, what="debug_session")
|
||||||
|
if len(json.dumps(body.form, ensure_ascii=False).encode()) > FORM_MAX_BYTES:
|
||||||
|
raise HTTPException(status_code=413, detail={"code": "too_large"})
|
||||||
|
# Under the lock, and refused while a send is under way: the issue is being built from the draft
|
||||||
|
# as it was saved, and this would change it under the send.
|
||||||
|
debugsession.lock(db, session)
|
||||||
|
if debugsession.sending(session):
|
||||||
|
raise refused(409, "issue_sending")
|
||||||
|
if session.issue_url is not None:
|
||||||
|
# Sent: the issue is on the tracker now, and a draft edited after it would say otherwise.
|
||||||
|
raise refused(409, "issue_already_sent")
|
||||||
|
debugsession.save_draft(session, body.model_dump())
|
||||||
|
db.commit()
|
||||||
|
return {"ok": True, "session": _view(db, session)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{sid}/export")
|
||||||
|
def export_session(
|
||||||
|
sid: str, user: User = Depends(require_human), db: Session = Depends(get_db)
|
||||||
|
) -> Response:
|
||||||
|
"""The issue as a zip — the text, the two logs and the screenshots — for an instance without an
|
||||||
|
issue tracker, or for sending it by hand. Built from the SAVED draft: what the composer shows is
|
||||||
|
what it saved."""
|
||||||
|
session = owned_or_404(db, DebugSession, sid, user, what="debug_session")
|
||||||
|
if not _exports.allow(str(user.id)):
|
||||||
|
raise refused(429, "export_rate_limited")
|
||||||
|
data = bundle.zipped(bundle.files(db, session))
|
||||||
|
name = f"siftlode-debug-{session.started_at:%Y%m%d-%H%M}-{session.id[:6]}.zip"
|
||||||
|
return Response(
|
||||||
|
content=data,
|
||||||
|
media_type="application/zip",
|
||||||
|
headers={
|
||||||
|
"Content-Disposition": f'attachment; filename="{name}"',
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --- the issue tracker (S4) -----------------------------------------------------------------
|
||||||
|
# Issues a user may open per hour — counted once the tracker is ASKED to open one (an answer that
|
||||||
|
# goes missing may still have created it, so an attempt cannot be given back); finishing a half-sent
|
||||||
|
# one, or an attempt refused before the tracker was asked, is not counted. The composer sends one at
|
||||||
|
# a time; this is room for a bad afternoon, not a pace anything legitimate keeps up.
|
||||||
|
_new_issues = RateLimiter(max_events=5, window_seconds=3600)
|
||||||
|
# Attempts on the tracker per hour, creating or finishing. Deliberately NOT the budget above: a
|
||||||
|
# half-sent issue must always be finishable, so the pace that guards creating one must never be what
|
||||||
|
# stands between a user and their own report. This one only bounds a loop — every attempt re-reads
|
||||||
|
# the whole bundle into memory and re-offers the pending file to the tracker, and a retry that is
|
||||||
|
# refused for good (the tracker's own limits) never leaves `pending`. No hand reaches it.
|
||||||
|
_sends = RateLimiter(max_events=60, window_seconds=3600)
|
||||||
|
# The composer's live preview, rendered BY the tracker: one authenticated call each, with our token.
|
||||||
|
# The composer's toggle fires it once per switch INTO preview — the editor is the other branch, so
|
||||||
|
# no keystroke can change the query key while the preview is up — and react-query's cache makes an
|
||||||
|
# unchanged draft free. (It does NOT debounce, whatever this comment used to say: a key that changes
|
||||||
|
# per keystroke is one fetch per keystroke, measured. Showing the preview BESIDE the fields would
|
||||||
|
# need a real debounce first.) This bounds what a loop can aim at the tracker.
|
||||||
|
_previews = RateLimiter(max_events=60, window_seconds=60)
|
||||||
|
|
||||||
|
|
||||||
|
def _issues_rate_limited() -> HTTPException:
|
||||||
|
return refused(429, "issues_rate_limited", limit=_new_issues.max_events)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{sid}/issue")
|
||||||
|
def send_issue(
|
||||||
|
sid: str, user: User = Depends(require_human), db: Session = Depends(get_db)
|
||||||
|
) -> dict:
|
||||||
|
"""Send the session's issue to the tracker — from the SAVED draft, as the export is built — or
|
||||||
|
finish a send that stopped half-way (the issue exists; only what is missing is sent)."""
|
||||||
|
session = owned_or_404(db, DebugSession, sid, user, what="debug_session")
|
||||||
|
# The session is CLAIMED for the send rather than locked through it: the tracker is slow (a
|
||||||
|
# dozen calls, 20 s each), and a row lock held across them would queue this user's Stop, their
|
||||||
|
# autosave and their screenshots behind it. They see the claim instead and are refused at once.
|
||||||
|
debugsession.lock(db, session)
|
||||||
|
if debugsession.sending(session):
|
||||||
|
raise refused(409, "issue_sending")
|
||||||
|
new = session.issue_url is None
|
||||||
|
if not new and not debugsession.unfinished(session):
|
||||||
|
return {"ok": True, "session": _view(db, session)}
|
||||||
|
key = str(user.id)
|
||||||
|
if new:
|
||||||
|
if not ((session.draft or {}).get("title") or "").strip():
|
||||||
|
raise refused(422, "issue_title_required")
|
||||||
|
if _new_issues.exhausted(key):
|
||||||
|
raise _issues_rate_limited()
|
||||||
|
if not _sends.allow(key):
|
||||||
|
raise refused(429, "issue_rate_limited", limit=_sends.max_events)
|
||||||
|
token = debugsession.claim_send(session)
|
||||||
|
db.commit() # the claim is what everything else sees; the lock ends here
|
||||||
|
|
||||||
|
def count_new_issue() -> None:
|
||||||
|
if not _new_issues.allow(key):
|
||||||
|
raise _issues_rate_limited()
|
||||||
|
|
||||||
|
try:
|
||||||
|
gitea.deliver(
|
||||||
|
db,
|
||||||
|
session,
|
||||||
|
token=token,
|
||||||
|
checkpoint=db.commit,
|
||||||
|
before_create=count_new_issue,
|
||||||
|
on_created=lambda: _record_created(db, user, session),
|
||||||
|
)
|
||||||
|
except debugsession.SendClaimLost:
|
||||||
|
# This send outlived its lease and another one took the session over while it was still
|
||||||
|
# talking to the tracker. It stopped rather than writing over that one's progress — and a
|
||||||
|
# send IS under way, just not ours, which is what the caller needs to hear.
|
||||||
|
raise refused(409, "issue_sending") from None
|
||||||
|
except gitea.GiteaError as exc:
|
||||||
|
# Keep what did happen: an issue created before an upload failed stays linked, and the
|
||||||
|
# retry does only what is still missing.
|
||||||
|
raise issue_refused(exc) from None
|
||||||
|
except HTTPException:
|
||||||
|
raise # the pace, refused before the tracker was asked
|
||||||
|
finally:
|
||||||
|
_end_send(db, session, token)
|
||||||
|
return {"ok": True, "session": _view(db, session)}
|
||||||
|
|
||||||
|
|
||||||
|
def _end_send(db: Session, session: DebugSession, token: str) -> None:
|
||||||
|
"""Give the claim back, whatever else happened. A failure here must not replace the failure that
|
||||||
|
brought us: a dead transaction (the database went away mid-send) is rolled back, and the claim
|
||||||
|
then simply ages out."""
|
||||||
|
try:
|
||||||
|
debugsession.lock(db, session)
|
||||||
|
debugsession.release_send(session, token)
|
||||||
|
db.commit()
|
||||||
|
except Exception: # noqa: BLE001 — whatever went wrong here, the send's own failure is the news
|
||||||
|
db.rollback()
|
||||||
|
log.warning(
|
||||||
|
"a send could not give its claim back",
|
||||||
|
extra=logs.event("issues.claim.stuck", session_id=session.id),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _record_created(db: Session, user: User, session: DebugSession) -> None:
|
||||||
|
"""The trail for the one act that takes a user's log off this instance. Staged inside the send,
|
||||||
|
so the checkpoint that first writes `issue_url` commits BOTH — recorded later it could be lost
|
||||||
|
on its own (the process dies mid-send), and no retry could write it afterwards: the next send
|
||||||
|
sees the link and knows the issue is not new."""
|
||||||
|
audit.record(
|
||||||
|
db,
|
||||||
|
user.id,
|
||||||
|
AuditAction.ISSUE_CREATE,
|
||||||
|
target_type="debug_session",
|
||||||
|
target_id=session.id,
|
||||||
|
summary=session.issue_url or "",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PreviewIn(BaseModel):
|
||||||
|
text: str = Field(max_length=50_000)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{sid}/preview")
|
||||||
|
def preview_issue(
|
||||||
|
sid: str,
|
||||||
|
body: PreviewIn,
|
||||||
|
user: User = Depends(require_human),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
"""The body as the tracker will render it. Gitea sanitises its own output; the composer still
|
||||||
|
shows it in a sandboxed frame, never in the app's own document."""
|
||||||
|
owned_or_404(db, DebugSession, sid, user, what="debug_session")
|
||||||
|
if not _previews.allow(str(user.id)):
|
||||||
|
raise refused(429, "preview_rate_limited")
|
||||||
|
try:
|
||||||
|
return {"ok": True, "html": gitea.preview(db, body.text)}
|
||||||
|
except gitea.GiteaError as exc:
|
||||||
|
raise issue_refused(exc) from None
|
||||||
|
|
||||||
|
|
||||||
|
# --- screenshots ---------------------------------------------------------------------------
|
||||||
|
def _shot_view(shot: DebugScreenshot) -> dict:
|
||||||
|
return {"id": shot.id, "mime": shot.mime, "size": shot.size, "created_at": iso(shot.created_at)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{sid}/screenshots")
|
||||||
|
def list_screenshots(
|
||||||
|
sid: str, user: User = Depends(require_human), db: Session = Depends(get_db)
|
||||||
|
) -> dict:
|
||||||
|
session = owned_or_404(db, DebugSession, sid, user, what="debug_session")
|
||||||
|
return {"items": [_shot_view(s) for s in debugsession.screenshots(db, session)]}
|
||||||
|
|
||||||
|
|
||||||
|
def _upload_target(
|
||||||
|
sid: str, user: User = Depends(require_human), db: Session = Depends(get_db)
|
||||||
|
) -> DebugSession:
|
||||||
|
return owned_or_404(db, DebugSession, sid, user, what="debug_session")
|
||||||
|
|
||||||
|
|
||||||
|
async def _image_body(request: Request, db: Session = Depends(get_db)) -> bytes:
|
||||||
|
# The owner check above has already run, and it was a SELECT — so this session is holding a
|
||||||
|
# pooled connection `idle in transaction` and would hold it for the whole 10 MiB upload, from a
|
||||||
|
# client that may be on a phone. Hand it back first; the handler's own work re-takes one. The
|
||||||
|
# `session` object the handler holds is expired by this and reloads on its next attribute read,
|
||||||
|
# which is the correct moment to touch the database again.
|
||||||
|
release_read_connection(db)
|
||||||
|
return await capped_body(request, debugsession.SCREENSHOT_MAX_BYTES)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{sid}/screenshots")
|
||||||
|
def add_screenshot(
|
||||||
|
# The owner check FIRST: dependencies resolve in order, so a stranger's upload is refused
|
||||||
|
# before a byte of it is read.
|
||||||
|
session: DebugSession = Depends(_upload_target),
|
||||||
|
data: bytes = Depends(_image_body),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
try:
|
||||||
|
shot = debugsession.add_screenshot(db, session, data)
|
||||||
|
except debugsession.ScreenshotRefused as exc:
|
||||||
|
raise _screenshot_refused(exc.code) from None
|
||||||
|
db.commit()
|
||||||
|
return {"ok": True, "screenshot": _shot_view(shot)}
|
||||||
|
|
||||||
|
|
||||||
|
def _screenshot_refused(code: str) -> HTTPException:
|
||||||
|
# Literal reasons, one per case: the frontend's contract test reads them to require a sentence
|
||||||
|
# for each in both languages.
|
||||||
|
if code == "already_sent":
|
||||||
|
return refused(409, "screenshot_already_sent")
|
||||||
|
if code == "too_many":
|
||||||
|
return refused(422, "screenshot_too_many", limit=debugsession.SCREENSHOTS_MAX)
|
||||||
|
if code == "sending":
|
||||||
|
return refused(409, "issue_sending")
|
||||||
|
if code == "storage_full":
|
||||||
|
mb = debugsession.SCREENSHOT_USER_MAX_BYTES // (1024 * 1024)
|
||||||
|
return refused(422, "screenshot_storage_full", limit=mb)
|
||||||
|
if code == "empty":
|
||||||
|
return refused(422, "screenshot_empty")
|
||||||
|
return refused(422, "screenshot_not_an_image")
|
||||||
|
|
||||||
|
|
||||||
|
def _session_screenshot(db: Session, sid: str, shot_id: int, user: User) -> DebugScreenshot:
|
||||||
|
return _screenshot_of(
|
||||||
|
db, owned_or_404(db, DebugSession, sid, user, what="debug_session"), shot_id
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _screenshot_of(db: Session, session: DebugSession, shot_id: int) -> DebugScreenshot:
|
||||||
|
shot = db.get(DebugScreenshot, shot_id)
|
||||||
|
if shot is None or shot.session_id != session.id:
|
||||||
|
raise not_found("screenshot")
|
||||||
|
return shot
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{sid}/screenshots/{shot_id}")
|
||||||
|
def get_screenshot(
|
||||||
|
sid: str, shot_id: int, user: User = Depends(require_human), db: Session = Depends(get_db)
|
||||||
|
) -> Response:
|
||||||
|
shot = _session_screenshot(db, sid, shot_id, user)
|
||||||
|
# `nosniff`: the type was checked against the bytes at upload, and the browser must not guess.
|
||||||
|
return Response(
|
||||||
|
content=shot.data,
|
||||||
|
media_type=shot.mime,
|
||||||
|
headers={"Cache-Control": "private, max-age=3600", "X-Content-Type-Options": "nosniff"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{sid}/screenshots/{shot_id}")
|
||||||
|
def delete_screenshot(
|
||||||
|
sid: str, shot_id: int, user: User = Depends(require_human), db: Session = Depends(get_db)
|
||||||
|
) -> dict:
|
||||||
|
session = owned_or_404(db, DebugSession, sid, user, what="debug_session")
|
||||||
|
shot = _screenshot_of(db, session, shot_id)
|
||||||
|
try:
|
||||||
|
debugsession.remove_screenshot(db, session, shot)
|
||||||
|
except debugsession.ScreenshotRefused as exc:
|
||||||
|
raise _screenshot_refused(exc.code) from None
|
||||||
|
db.commit()
|
||||||
|
return {"ok": True, "deleted": shot_id}
|
||||||
@@ -13,7 +13,9 @@ Nothing from the body is trusted as-is:
|
|||||||
* everything is redacted AGAIN with the server's own rules — the client redacts at capture, but a
|
* everything is redacted AGAIN with the server's own rules — the client redacts at capture, but a
|
||||||
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;
|
Teljes may carry), and so is the whole batch once the level has lapsed — where an open debug
|
||||||
|
session the request names counts as consent to ITS level (E16), and only its own events keep
|
||||||
|
its tag;
|
||||||
* the timestamp is clamped to the consent window, so a row cannot dodge the retention job by
|
* 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.
|
||||||
"""
|
"""
|
||||||
@@ -27,11 +29,12 @@ from pydantic import BaseModel, Field, ValidationError
|
|||||||
from sqlalchemy import insert
|
from sqlalchemy import insert
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app import diagnostics, logs, logsink
|
from app import debugsession, diagnostics, logs, logsink
|
||||||
from app.auth import current_user
|
from app.auth import current_user
|
||||||
from app.db import get_db
|
from app.db import get_db, release_read_connection
|
||||||
from app.models import LogEvent, User
|
from app.models import DebugSession, LogEvent, User
|
||||||
from app.ratelimit import RateLimiter
|
from app.ratelimit import RateLimiter
|
||||||
|
from app.routes._common import capped_body
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/diagnostics", tags=["diagnostics"])
|
router = APIRouter(prefix="/api/diagnostics", tags=["diagnostics"])
|
||||||
|
|
||||||
@@ -60,6 +63,9 @@ 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
|
||||||
|
# names in `X-Debug-Session` and the auth dependency found it the caller's own and open.
|
||||||
|
debug_session_id: str | None = Field(default=None, pattern=r"^[0-9a-f]{16}$")
|
||||||
version: str | None = Field(default=None, max_length=64)
|
version: str | None = Field(default=None, max_length=64)
|
||||||
|
|
||||||
|
|
||||||
@@ -67,19 +73,7 @@ class ClientEventsIn(BaseModel):
|
|||||||
events: list[ClientEvent] = Field(max_length=EVENTS_MAX)
|
events: list[ClientEvent] = Field(max_length=EVENTS_MAX)
|
||||||
|
|
||||||
|
|
||||||
async def _capped_body(request: Request) -> bytes:
|
async def _parsed_events(request: Request, db: Session = Depends(get_db)) -> ClientEventsIn:
|
||||||
"""The request body, refused the moment it passes BODY_MAX_BYTES while still arriving. Counted,
|
|
||||||
not taken from Content-Length: a declared length can be absent (chunked) or wrong, and counting
|
|
||||||
stops reading at the same point a declared-length check would have."""
|
|
||||||
body = bytearray()
|
|
||||||
async for chunk in request.stream():
|
|
||||||
body += chunk
|
|
||||||
if len(body) > BODY_MAX_BYTES:
|
|
||||||
raise HTTPException(status_code=413, detail={"code": "too_large"})
|
|
||||||
return bytes(body)
|
|
||||||
|
|
||||||
|
|
||||||
async def _parsed_events(request: Request) -> ClientEventsIn:
|
|
||||||
"""The body as the model — parsed only after the size cap, which is why this is a dependency and
|
"""The body as the model — parsed only after the size cap, which is why this is a dependency and
|
||||||
not a plain body parameter (FastAPI reads a declared body in full before anything else runs).
|
not a plain body parameter (FastAPI reads a declared body in full before anything else runs).
|
||||||
|
|
||||||
@@ -89,14 +83,24 @@ async def _parsed_events(request: Request) -> ClientEventsIn:
|
|||||||
media_type = request.headers.get("content-type", "").split(";")[0].strip().lower()
|
media_type = request.headers.get("content-type", "").split(";")[0].strip().lower()
|
||||||
if media_type != "application/json" and not media_type.endswith("+json"):
|
if media_type != "application/json" and not media_type.endswith("+json"):
|
||||||
raise HTTPException(status_code=415, detail={"code": "json_required"})
|
raise HTTPException(status_code=415, detail={"code": "json_required"})
|
||||||
raw = await _capped_body(request)
|
# `current_user` resolved above on a SELECT, so this session is holding a pooled connection.
|
||||||
|
# Give it back before awaiting the body — the sink is a background beacon and its sender may be
|
||||||
|
# on a link far slower than the 512 KiB suggests. See `db.release_read_connection`.
|
||||||
|
release_read_connection(db)
|
||||||
|
raw = await capped_body(request, BODY_MAX_BYTES)
|
||||||
try:
|
try:
|
||||||
return ClientEventsIn.model_validate_json(raw)
|
return ClientEventsIn.model_validate_json(raw)
|
||||||
except ValidationError as exc:
|
except ValidationError as exc:
|
||||||
raise HTTPException(status_code=422, detail=json.loads(exc.json())) from exc
|
raise HTTPException(status_code=422, detail=json.loads(exc.json())) from exc
|
||||||
|
|
||||||
|
|
||||||
def _row(item: ClientEvent, user: User, now: datetime, window_start: datetime) -> dict[str, Any]:
|
def _row(
|
||||||
|
item: ClientEvent,
|
||||||
|
user: User,
|
||||||
|
now: datetime,
|
||||||
|
window_start: datetime,
|
||||||
|
session: DebugSession | None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
attrs = logs.redact(item.attrs) if item.attrs else None
|
attrs = logs.redact(item.attrs) if item.attrs else None
|
||||||
if attrs is not None:
|
if attrs is not None:
|
||||||
size = len(json.dumps(attrs, ensure_ascii=False, default=str).encode())
|
size = len(json.dumps(attrs, ensure_ascii=False, default=str).encode())
|
||||||
@@ -117,6 +121,9 @@ def _row(item: ClientEvent, user: User, now: datetime, window_start: datetime) -
|
|||||||
"attrs": attrs,
|
"attrs": attrs,
|
||||||
"request_id": item.request_id,
|
"request_id": item.request_id,
|
||||||
"user_id": user.id,
|
"user_id": user.id,
|
||||||
|
"debug_session_id": (
|
||||||
|
session.id if session and item.debug_session_id == session.id else None
|
||||||
|
),
|
||||||
"version": item.version,
|
"version": item.version,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -126,11 +133,15 @@ def _row(item: ClientEvent, user: User, now: datetime, window_start: datetime) -
|
|||||||
def ingest_events(
|
def ingest_events(
|
||||||
# The account first: dependencies resolve in order, so an anonymous caller is refused before a
|
# The account first: dependencies resolve in order, so an anonymous caller is refused before a
|
||||||
# byte of its body is read.
|
# byte of its body is read.
|
||||||
|
request: Request,
|
||||||
user: User = Depends(current_user),
|
user: User = Depends(current_user),
|
||||||
body: ClientEventsIn = Depends(_parsed_events),
|
body: ClientEventsIn = Depends(_parsed_events),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
level = diagnostics.effective_level(user)
|
# A debug session the request names raises the level for its own events (E16): starting one is
|
||||||
|
# the consent to that level for its duration, whatever the user's own level is.
|
||||||
|
session = debugsession.from_header(db, user, request.headers.get(debugsession.HEADER))
|
||||||
|
level, _ = debugsession.level_for(user, session)
|
||||||
if level == diagnostics.BASE:
|
if level == diagnostics.BASE:
|
||||||
# Lapsed or withdrawn: the client stops streaming when it sees this.
|
# Lapsed or withdrawn: the client stops streaming when it sees this.
|
||||||
raise HTTPException(status_code=409, detail={"code": "diagnostics_off"})
|
raise HTTPException(status_code=409, detail={"code": "diagnostics_off"})
|
||||||
@@ -140,7 +151,7 @@ def ingest_events(
|
|||||||
window_start = now - timedelta(hours=diagnostics.ELEVATION_HOURS)
|
window_start = now - timedelta(hours=diagnostics.ELEVATION_HOURS)
|
||||||
floor = logs.SEVERITY[level]
|
floor = logs.SEVERITY[level]
|
||||||
rows = [
|
rows = [
|
||||||
_row(item, user, now, window_start)
|
_row(item, user, now, window_start, session)
|
||||||
for item in body.events
|
for item in body.events
|
||||||
if logs.SEVERITY[item.level] >= floor
|
if logs.SEVERITY[item.level] >= floor
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -9,11 +9,13 @@ serves finished files (range-aware, with the user's custom display name), and sh
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import tempfile
|
import tempfile
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
from dataclasses import dataclass
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from urllib.parse import parse_qs, urlparse
|
from urllib.parse import parse_qs, unquote, urlparse
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
@@ -22,7 +24,7 @@ from sqlalchemy.orm import Session
|
|||||||
from app import audit, sysconfig
|
from app import audit, sysconfig
|
||||||
from app.auth import admin_user, require_human
|
from app.auth import admin_user, require_human
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.db import get_db
|
from app.db import get_db, release_read_connection
|
||||||
from app.downloads import credentials as dlcreds
|
from app.downloads import credentials as dlcreds
|
||||||
from app.downloads import edit as editmod
|
from app.downloads import edit as editmod
|
||||||
from app.downloads import links as linksmod
|
from app.downloads import links as linksmod
|
||||||
@@ -37,8 +39,9 @@ from app.models import (
|
|||||||
DownloadShare,
|
DownloadShare,
|
||||||
MediaAsset,
|
MediaAsset,
|
||||||
User,
|
User,
|
||||||
|
display_text,
|
||||||
)
|
)
|
||||||
from app.routes._common import get_or_404, not_found, owned_or_404
|
from app.routes._common import capped_stream_to, get_or_404, not_found, owned_or_404
|
||||||
from app.security import hash_password
|
from app.security import hash_password
|
||||||
from app.userscope import is_messageable_user, messageable_clauses
|
from app.userscope import is_messageable_user, messageable_clauses
|
||||||
from app.utils import iso
|
from app.utils import iso
|
||||||
@@ -398,7 +401,7 @@ def enqueue_download(
|
|||||||
status_code=422, detail={"code": "url_blocked", "reason": e.reason}
|
status_code=422, detail={"code": "url_blocked", "reason": e.reason}
|
||||||
) from e
|
) from e
|
||||||
spec, profile_id = _resolve_spec(db, user, body.profile_id, body.spec)
|
spec, profile_id = _resolve_spec(db, user, body.profile_id, body.spec)
|
||||||
display_name = (body.display_name or "").strip() or None
|
display_name = display_text(body.display_name)
|
||||||
try:
|
try:
|
||||||
job = service.enqueue(db, user.id, source_kind, source_ref, spec, profile_id, display_name)
|
job = service.enqueue(db, user.id, source_kind, source_ref, spec, profile_id, display_name)
|
||||||
except quota.QuotaExceeded as e:
|
except quota.QuotaExceeded as e:
|
||||||
@@ -406,61 +409,119 @@ def enqueue_download(
|
|||||||
return _serialize_job(db, job)
|
return _serialize_job(db, job)
|
||||||
|
|
||||||
|
|
||||||
# Read the upload in 1 MiB chunks so a large file never sits fully in memory, and the size cap is
|
# The upload's filename and display name travel as HEADERS, not as multipart parts. That is not a
|
||||||
# enforced on the ACTUAL bytes read (a client-set Content-Length can't be trusted to gate it).
|
# style choice: a DECLARED body parameter (`file: UploadFile = File(...)`) is read and spooled by
|
||||||
_UPLOAD_CHUNK = 1024 * 1024
|
# FastAPI *before the first dependency runs*, so `require_human` could not gate it and an anonymous
|
||||||
|
# caller could make the process write the whole per-file allowance to temp, per request (P1; SEC-20
|
||||||
|
# bounded the number but could not stop the writing). Reading the body ourselves, from a dependency
|
||||||
|
# declared AFTER the auth one, is the only shape that closes it — the same one
|
||||||
|
# `POST /api/debug-sessions/{sid}/screenshots` uses, and `test_body_limit.py` now enforces it for
|
||||||
|
# every route with a raised ceiling rather than leaving it to a comment like this one.
|
||||||
|
#
|
||||||
|
# Headers are latin-1 on the wire, so both are percent-encoded by the client and unquoted here; a
|
||||||
|
# Hungarian filename would otherwise be unsendable.
|
||||||
|
UPLOAD_FILENAME_HEADER = "x-upload-filename"
|
||||||
|
UPLOAD_DISPLAY_NAME_HEADER = "x-upload-display-name"
|
||||||
|
|
||||||
|
|
||||||
@router.post("/upload")
|
@dataclass(frozen=True)
|
||||||
def upload_download(
|
class SpooledUpload:
|
||||||
file: UploadFile = File(...),
|
"""What the body dependency hands the handler: the bytes already on disk, plus the two names
|
||||||
display_name: str | None = Form(None),
|
that came alongside them."""
|
||||||
|
|
||||||
|
path: Path
|
||||||
|
size: int
|
||||||
|
user_id: int
|
||||||
|
filename: str
|
||||||
|
display_name: str | None
|
||||||
|
|
||||||
|
|
||||||
|
def _header_text(request: Request, name: str) -> str:
|
||||||
|
"""One percent-encoded header, decoded. A header that is not valid UTF-8 once unquoted is the
|
||||||
|
caller's doing, not a server fault — it degrades to empty rather than 500ing, and the route's
|
||||||
|
own fallback names the file."""
|
||||||
|
raw = request.headers.get(name, "")
|
||||||
|
try:
|
||||||
|
return unquote(raw, errors="strict").strip()
|
||||||
|
except (UnicodeDecodeError, ValueError):
|
||||||
|
# silent-ok: a mis-encoded header is the caller's own malformed request, and this one is
|
||||||
|
# cosmetic — it names the file. Refusing the upload over it would fail a 500 MiB transfer on
|
||||||
|
# a label; logging it would hand any client a free log line per request.
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
async def spool_upload(
|
||||||
|
request: Request,
|
||||||
user: User = Depends(require_human),
|
user: User = Depends(require_human),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> AsyncIterator[SpooledUpload]:
|
||||||
"""Ingest a local file into the caller's library (E7 S5). Any file type is accepted; media
|
"""Stream the request body to a staging temp file, enforcing the per-file cap as it arrives.
|
||||||
(video/audio) gets the full function set, a generic file is download-only. The file is streamed
|
|
||||||
to a temp path under the download volume (enforcing the per-file cap as it goes), then handed to
|
|
||||||
the non-yt-dlp ingest which probes + places it and returns an already-`done` job.
|
|
||||||
|
|
||||||
A sync (threadpool) handler on purpose: the ingest does blocking work — an ffprobe of up to 30s
|
`user` is taken but unused: it is the whole point. Dependencies resolve in declaration order, so
|
||||||
plus sync DB writes — so it must NOT run on the event loop. `file.file` is the sync file object
|
naming `require_human` here makes the auth check a precondition of reading the body at all — a
|
||||||
Starlette spools the multipart body into."""
|
stranger is refused before a byte moves. The `yield` shape owns the temp file's life: ingest
|
||||||
|
MOVES it into place on success (the unlink is then a no-op), and on any other exit — cap hit,
|
||||||
|
empty, quota, probe or move error, a client that vanished mid-upload — it is removed."""
|
||||||
cap = sysconfig.get_int(db, "download_upload_max_bytes")
|
cap = sysconfig.get_int(db, "download_upload_max_bytes")
|
||||||
|
user_id = user.id
|
||||||
|
# Hand the pooled connection back BEFORE the transfer starts — see `db.release_read_connection`
|
||||||
|
# for why, and for the precondition it enforces. Everything this dependency still needs is
|
||||||
|
# already an int or a header in hand: touching an ORM attribute below would reload `user` and
|
||||||
|
# check a connection straight back out.
|
||||||
|
release_read_connection(db)
|
||||||
staging = Path(settings.download_root) / ".staging" / "uploads"
|
staging = Path(settings.download_root) / ".staging" / "uploads"
|
||||||
staging.mkdir(parents=True, exist_ok=True)
|
staging.mkdir(parents=True, exist_ok=True)
|
||||||
fd, tmp = tempfile.mkstemp(dir=str(staging))
|
fd, tmp = tempfile.mkstemp(dir=str(staging))
|
||||||
tmp_path = Path(tmp)
|
tmp_path = Path(tmp)
|
||||||
try:
|
try:
|
||||||
size = 0
|
|
||||||
with os.fdopen(fd, "wb") as out:
|
with os.fdopen(fd, "wb") as out:
|
||||||
while chunk := file.file.read(_UPLOAD_CHUNK):
|
size = await capped_stream_to(
|
||||||
size += len(chunk)
|
request,
|
||||||
if cap and size > cap:
|
out,
|
||||||
raise HTTPException(
|
cap,
|
||||||
status_code=422, detail={"code": "upload_too_large", "limit": cap}
|
HTTPException(status_code=422, detail={"code": "upload_too_large", "limit": cap}),
|
||||||
)
|
|
||||||
out.write(chunk)
|
|
||||||
if size == 0:
|
|
||||||
raise HTTPException(status_code=400, detail={"code": "upload_empty"})
|
|
||||||
try:
|
|
||||||
job = uploadmod.ingest_upload(
|
|
||||||
db,
|
|
||||||
user.id,
|
|
||||||
filename=file.filename or "upload",
|
|
||||||
src_path=tmp_path,
|
|
||||||
size=size,
|
|
||||||
display_name=(display_name or "").strip() or None,
|
|
||||||
)
|
)
|
||||||
except quota.QuotaExceeded as e:
|
yield SpooledUpload(
|
||||||
raise HTTPException(status_code=422, detail=_quota_detail(e))
|
path=tmp_path,
|
||||||
return _serialize_job(db, job)
|
size=size,
|
||||||
|
user_id=user_id,
|
||||||
|
filename=_header_text(request, UPLOAD_FILENAME_HEADER) or "upload",
|
||||||
|
display_name=display_text(_header_text(request, UPLOAD_DISPLAY_NAME_HEADER)),
|
||||||
|
)
|
||||||
finally:
|
finally:
|
||||||
# Ingest MOVES the temp file into place on success (unlink is then a no-op); on any early exit
|
|
||||||
# — cap hit, empty, quota, probe/move error — this removes the abandoned staging file.
|
|
||||||
tmp_path.unlink(missing_ok=True)
|
tmp_path.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/upload")
|
||||||
|
def upload_download(
|
||||||
|
upload: SpooledUpload = Depends(spool_upload),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
"""Ingest a local file into the caller's library (E7 S5). Any file type is accepted; media
|
||||||
|
(video/audio) gets the full function set, a generic file is download-only. The body IS the file
|
||||||
|
(see the header note above); `spool_upload` has already authenticated the caller and streamed it
|
||||||
|
to a temp path under the download volume, so what is left here is the non-yt-dlp ingest which
|
||||||
|
probes + places it and returns an already-`done` job.
|
||||||
|
|
||||||
|
A sync (threadpool) handler on purpose: the ingest does blocking work — an ffprobe of up to 30s
|
||||||
|
plus sync DB writes — so it must NOT run on the event loop. The streaming half is async because
|
||||||
|
reading the body is; keeping them in separate callables is what lets each run where it belongs."""
|
||||||
|
if upload.size == 0:
|
||||||
|
raise HTTPException(status_code=400, detail={"code": "upload_empty"})
|
||||||
|
try:
|
||||||
|
job = uploadmod.ingest_upload(
|
||||||
|
db,
|
||||||
|
upload.user_id,
|
||||||
|
filename=upload.filename,
|
||||||
|
src_path=upload.path,
|
||||||
|
size=upload.size,
|
||||||
|
display_name=upload.display_name,
|
||||||
|
)
|
||||||
|
except quota.QuotaExceeded as e:
|
||||||
|
raise HTTPException(status_code=422, detail=_quota_detail(e))
|
||||||
|
return _serialize_job(db, job)
|
||||||
|
|
||||||
|
|
||||||
class EnqueueEditIn(BaseModel):
|
class EnqueueEditIn(BaseModel):
|
||||||
source_job_id: int = 0
|
source_job_id: int = 0
|
||||||
edit_spec: dict
|
edit_spec: dict
|
||||||
@@ -480,7 +541,7 @@ def enqueue_edit(
|
|||||||
if source_job.status != "done" or source_job.asset_id is None:
|
if source_job.status != "done" or source_job.asset_id is None:
|
||||||
raise refused(409, "edit_unfinished")
|
raise refused(409, "edit_unfinished")
|
||||||
spec = body.edit_spec
|
spec = body.edit_spec
|
||||||
display_name = (body.display_name or "").strip() or None
|
display_name = display_text(body.display_name)
|
||||||
try:
|
try:
|
||||||
job = service.enqueue_edit(db, user.id, source_job, spec, display_name)
|
job = service.enqueue_edit(db, user.id, source_job, spec, display_name)
|
||||||
except quota.QuotaExceeded as e:
|
except quota.QuotaExceeded as e:
|
||||||
@@ -516,7 +577,20 @@ def list_downloads(
|
|||||||
|
|
||||||
@router.get("/usage")
|
@router.get("/usage")
|
||||||
def my_usage(user: User = Depends(require_human), db: Session = Depends(get_db)) -> dict:
|
def my_usage(user: User = Depends(require_human), db: Session = Depends(get_db)) -> dict:
|
||||||
return quota.usage(db, user.id)
|
"""The caller's download quota, plus the per-file upload cap.
|
||||||
|
|
||||||
|
The cap rides here rather than in `quota.usage` because it is not a quota value — it is a
|
||||||
|
`sysconfig` knob about one route — and this is the route layer, where composing the two is the
|
||||||
|
job. The upload dialog needs it to refuse an over-cap file BEFORE sending it: both server-side
|
||||||
|
caps answer too late to be pleasant (the edge ceiling refuses the request outright, see
|
||||||
|
`bodylimit.ROUTE_MAX_BODY_BYTES`), and making the user push 600 MB up the wire to be told the
|
||||||
|
limit is 500 is a bad answer even when it is a correct one. It is a convenience, not the
|
||||||
|
boundary: the caps stay where they are and still refuse a client that ignores this.
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
**quota.usage(db, user.id),
|
||||||
|
"upload_max_bytes": sysconfig.get_int(db, "download_upload_max_bytes"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# Priority when a source has several jobs (e.g. a done one plus a fresh queued one).
|
# Priority when a source has several jobs (e.g. a done one plus a fresh queued one).
|
||||||
@@ -611,9 +685,9 @@ def update_download_meta(
|
|||||||
job = owned_or_404(db, DownloadJob, job_id, user, what="download")
|
job = owned_or_404(db, DownloadJob, job_id, user, what="download")
|
||||||
sent = body.model_fields_set
|
sent = body.model_fields_set
|
||||||
if "display_name" in sent:
|
if "display_name" in sent:
|
||||||
job.display_name = ((body.display_name or "").strip())[:255] or None
|
job.display_name = display_text(body.display_name)
|
||||||
if "display_uploader" in sent:
|
if "display_uploader" in sent:
|
||||||
job.display_uploader = ((body.display_uploader or "").strip())[:255] or None
|
job.display_uploader = display_text(body.display_uploader)
|
||||||
if "display_uploader_url" in sent:
|
if "display_uploader_url" in sent:
|
||||||
job.display_uploader_url = _clean_url(body.display_uploader_url)
|
job.display_uploader_url = _clean_url(body.display_uploader_url)
|
||||||
if "extra_links" in sent:
|
if "extra_links" in sent:
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ from app.auth import (
|
|||||||
optional_current_user,
|
optional_current_user,
|
||||||
purge_user,
|
purge_user,
|
||||||
)
|
)
|
||||||
from app import diagnostics
|
from app import debugsession, diagnostics
|
||||||
|
from app.issues import gitea
|
||||||
from app import email as email_mod
|
from app import email as email_mod
|
||||||
from app import logs
|
from app import logs
|
||||||
from app import sysconfig
|
from app import sysconfig
|
||||||
@@ -158,9 +159,18 @@ def get_me(
|
|||||||
# 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 (E22 S5). 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.
|
||||||
|
"debug_session": _live_session(db, user),
|
||||||
|
# Where a bug report goes (E16 S4): the tracker's repository, or None — download only.
|
||||||
|
"issues": gitea.target(db),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _live_session(db: Session, user: User) -> dict | None:
|
||||||
|
session = debugsession.open_session(db, user)
|
||||||
|
return None if session is None else debugsession.view(db, session)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/account")
|
@router.delete("/account")
|
||||||
def delete_my_account(
|
def delete_my_account(
|
||||||
request: Request,
|
request: Request,
|
||||||
|
|||||||
@@ -4,9 +4,8 @@ mirror the user's own YouTube playlists in (`/sync-youtube`) and push/revert loc
|
|||||||
out (`/{id}/push-plan`, `/{id}/revert-youtube`), behind the optional write scope."""
|
out (`/{id}/push-plan`, `/{id}/revert-youtube`), behind the optional write scope."""
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel, Field
|
||||||
from sqlalchemy import func, select, and_
|
from sqlalchemy import func, select, and_
|
||||||
from sqlalchemy.exc import IntegrityError
|
|
||||||
from sqlalchemy.orm import Session, aliased
|
from sqlalchemy.orm import Session, aliased
|
||||||
|
|
||||||
from app import quota
|
from app import quota
|
||||||
@@ -15,14 +14,18 @@ from app.db import get_db
|
|||||||
from app.feedquery import feed_columns, serialize
|
from app.feedquery import feed_columns, serialize
|
||||||
from app.models import Channel, Playlist, PlaylistItem, User, Video, VideoState
|
from app.models import Channel, Playlist, PlaylistItem, User, Video, VideoState
|
||||||
from app.sync.playlists import (
|
from app.sync.playlists import (
|
||||||
|
PlaylistGone,
|
||||||
|
lock_playlists,
|
||||||
merge_playlist,
|
merge_playlist,
|
||||||
plan_merge,
|
plan_merge,
|
||||||
recompute_dirty,
|
recompute_dirty,
|
||||||
|
pull_user_playlists,
|
||||||
repull_playlist,
|
repull_playlist,
|
||||||
sync_user_playlists,
|
sync_claim,
|
||||||
|
user_sync_claim,
|
||||||
)
|
)
|
||||||
from app.ratelimit import RateLimiter
|
from app.ratelimit import RateLimiter
|
||||||
from app.routes._common import get_or_404, owned_or_404, youtube_refusal
|
from app.routes._common import get_or_404, not_found, owned_or_404, youtube_refusal
|
||||||
from app.youtube.client import YouTubeClient, YouTubeError
|
from app.youtube.client import YouTubeClient, YouTubeError
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/playlists", tags=["playlists"])
|
router = APIRouter(prefix="/api/playlists", tags=["playlists"])
|
||||||
@@ -79,7 +82,10 @@ def _next_playlist_position(db: Session, user_id: int) -> int:
|
|||||||
|
|
||||||
|
|
||||||
def _next_item_position(db: Session, playlist_id: int) -> int:
|
def _next_item_position(db: Session, playlist_id: int) -> int:
|
||||||
"""Append position for a new item at the end of a playlist."""
|
"""Append position for a new item at the end of a playlist. Locks the playlist row first: two
|
||||||
|
adds (two tabs, the rail and the page) otherwise both read the same max and land on one
|
||||||
|
position, and the list's order between them is then arbitrary."""
|
||||||
|
lock_playlists(db, playlist_id)
|
||||||
return (
|
return (
|
||||||
db.scalar(
|
db.scalar(
|
||||||
select(func.coalesce(func.max(PlaylistItem.position), -1)).where(
|
select(func.coalesce(func.max(PlaylistItem.position), -1)).where(
|
||||||
@@ -92,7 +98,10 @@ def _next_item_position(db: Session, playlist_id: int) -> int:
|
|||||||
|
|
||||||
def _add_item(db: Session, pl: Playlist, video_id: str, *, mark_dirty: bool) -> bool:
|
def _add_item(db: Session, pl: Playlist, video_id: str, *, mark_dirty: bool) -> bool:
|
||||||
"""Append a video to a playlist if not already present (idempotent). Returns whether it
|
"""Append a video to a playlist if not already present (idempotent). Returns whether it
|
||||||
was added. `mark_dirty` recomputes the YouTube-sync dirty flag (skip for Watch later)."""
|
was added. `mark_dirty` recomputes the YouTube-sync dirty flag (skip for Watch later).
|
||||||
|
Locked BEFORE the presence check: two adds of one video both saw "absent" and the second one's
|
||||||
|
insert hit uq_playlist_video as a 500."""
|
||||||
|
lock_playlists(db, pl.id)
|
||||||
exists = db.scalar(
|
exists = db.scalar(
|
||||||
select(PlaylistItem).where(
|
select(PlaylistItem).where(
|
||||||
PlaylistItem.playlist_id == pl.id, PlaylistItem.video_id == video_id
|
PlaylistItem.playlist_id == pl.id, PlaylistItem.video_id == video_id
|
||||||
@@ -113,6 +122,7 @@ def _add_item(db: Session, pl: Playlist, video_id: str, *, mark_dirty: bool) ->
|
|||||||
def _remove_item(db: Session, pl: Playlist, video_id: str, *, mark_dirty: bool) -> bool:
|
def _remove_item(db: Session, pl: Playlist, video_id: str, *, mark_dirty: bool) -> bool:
|
||||||
"""Remove a video from a playlist if present (idempotent). Returns whether it was removed.
|
"""Remove a video from a playlist if present (idempotent). Returns whether it was removed.
|
||||||
`mark_dirty` recomputes the YouTube-sync dirty flag (skip for Watch later)."""
|
`mark_dirty` recomputes the YouTube-sync dirty flag (skip for Watch later)."""
|
||||||
|
lock_playlists(db, pl.id)
|
||||||
item = db.scalar(
|
item = db.scalar(
|
||||||
select(PlaylistItem).where(
|
select(PlaylistItem).where(
|
||||||
PlaylistItem.playlist_id == pl.id, PlaylistItem.video_id == video_id
|
PlaylistItem.playlist_id == pl.id, PlaylistItem.video_id == video_id
|
||||||
@@ -321,13 +331,18 @@ def sync_youtube(
|
|||||||
Also runs automatically on the scheduler; this is the manual 'sync now'."""
|
Also runs automatically on the scheduler; this is the manual 'sync now'."""
|
||||||
if not has_read_scope(user):
|
if not has_read_scope(user):
|
||||||
raise _refused(403, "youtube_read_scope")
|
raise _refused(403, "youtube_read_scope")
|
||||||
_check_pull_cooldown(user)
|
with user_sync_claim(db, user.id) as free:
|
||||||
try:
|
if not free:
|
||||||
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PULL):
|
# Another run (the scheduler, another tab) is pulling right now: "0 synced" would lie.
|
||||||
return {"ok": True, **sync_user_playlists(db, user)}
|
raise _refused(409, "sync_all_busy")
|
||||||
except YouTubeError as exc:
|
# After the claim, like the re-pull's: a refused attempt must not spend one of the pulls.
|
||||||
db.rollback()
|
_check_pull_cooldown(user)
|
||||||
raise youtube_refusal(exc)
|
try:
|
||||||
|
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PULL):
|
||||||
|
return {"ok": True, **pull_user_playlists(db, user)}
|
||||||
|
except YouTubeError as exc:
|
||||||
|
db.rollback()
|
||||||
|
raise youtube_refusal(exc)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{playlist_id}/revert-youtube")
|
@router.post("/{playlist_id}/revert-youtube")
|
||||||
@@ -344,13 +359,20 @@ def revert_youtube(
|
|||||||
raise _refused(400, "not_on_youtube")
|
raise _refused(400, "not_on_youtube")
|
||||||
if not has_read_scope(user):
|
if not has_read_scope(user):
|
||||||
raise _refused(403, "youtube_read_scope")
|
raise _refused(403, "youtube_read_scope")
|
||||||
_check_pull_cooldown(user)
|
with sync_claim(db, pl.id) as free:
|
||||||
try:
|
if not free:
|
||||||
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PULL):
|
raise _refused(409, "sync_busy")
|
||||||
return {"ok": True, **repull_playlist(db, user, pl)}
|
# The cooldown is spent only by a pull that runs: checked after the claim, so clicking
|
||||||
except YouTubeError as exc:
|
# Revert while a push is in flight does not use up the three pulls per ten minutes.
|
||||||
db.rollback()
|
_check_pull_cooldown(user)
|
||||||
raise youtube_refusal(exc)
|
try:
|
||||||
|
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PULL):
|
||||||
|
return {"ok": True, **repull_playlist(db, user, pl)}
|
||||||
|
except PlaylistGone:
|
||||||
|
raise not_found("playlist")
|
||||||
|
except YouTubeError as exc:
|
||||||
|
db.rollback()
|
||||||
|
raise youtube_refusal(exc)
|
||||||
|
|
||||||
|
|
||||||
def _pushable(db: Session, user: User, playlist_id: int) -> Playlist:
|
def _pushable(db: Session, user: User, playlist_id: int) -> Playlist:
|
||||||
@@ -392,25 +414,32 @@ def push(
|
|||||||
neither side loses an item). Refuses if the estimated quota exceeds what's left today, so a
|
neither side loses an item). Refuses if the estimated quota exceeds what's left today, so a
|
||||||
big sync can't strand half-applied. Write scope required (require_write_scope)."""
|
big sync can't strand half-applied. Write scope required (require_write_scope)."""
|
||||||
pl = _pushable(db, user, playlist_id)
|
pl = _pushable(db, user, playlist_id)
|
||||||
try:
|
# One sync of a list at a time, across tabs, devices and the scheduler (`sync_claim`): the
|
||||||
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PUSH):
|
# client's own in-flight flag only covers the menus of one page.
|
||||||
# Read the live playlist ONCE (linked playlists only) and share it with both the
|
with sync_claim(db, pl.id) as free:
|
||||||
# plan and the merge, halving read-quota and closing the second read→write TOCTOU
|
if not free:
|
||||||
# window. A fresh export (no yt_playlist_id) reads nothing.
|
raise _refused(409, "sync_busy")
|
||||||
live = None
|
try:
|
||||||
if pl.yt_playlist_id:
|
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PUSH):
|
||||||
with YouTubeClient(db, user) as yt:
|
# Read the live playlist ONCE (linked playlists only) and share it with both the
|
||||||
live = (
|
# plan and the merge, halving read-quota and closing the second read→write TOCTOU
|
||||||
list(yt.iter_playlist_items_with_ids(pl.yt_playlist_id)),
|
# window. A fresh export (no yt_playlist_id) reads nothing.
|
||||||
yt.get_playlist_snippet(pl.yt_playlist_id),
|
live = None
|
||||||
)
|
if pl.yt_playlist_id:
|
||||||
plan = plan_merge(db, user, pl, live=live)
|
with YouTubeClient(db, user) as yt:
|
||||||
if plan["units_estimate"] > quota.remaining_today(db):
|
live = (
|
||||||
raise _refused(429, "quota_exceeded")
|
list(yt.iter_playlist_items_with_ids(pl.yt_playlist_id)),
|
||||||
return {"ok": True, **merge_playlist(db, user, pl, live=live)}
|
yt.get_playlist_snippet(pl.yt_playlist_id),
|
||||||
except YouTubeError as exc:
|
)
|
||||||
db.rollback()
|
plan = plan_merge(db, user, pl, live=live)
|
||||||
raise youtube_refusal(exc)
|
if plan["units_estimate"] > quota.remaining_today(db):
|
||||||
|
raise _refused(429, "quota_exceeded")
|
||||||
|
return {"ok": True, **merge_playlist(db, user, pl, live=live)}
|
||||||
|
except PlaylistGone:
|
||||||
|
raise not_found("playlist")
|
||||||
|
except YouTubeError as exc:
|
||||||
|
db.rollback()
|
||||||
|
raise youtube_refusal(exc)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{playlist_id}")
|
@router.get("/{playlist_id}")
|
||||||
@@ -427,7 +456,8 @@ def get_playlist(
|
|||||||
.join(Channel, Channel.id == Video.channel_id)
|
.join(Channel, Channel.id == Video.channel_id)
|
||||||
.outerjoin(state, and_(state.video_id == Video.id, state.user_id == user.id))
|
.outerjoin(state, and_(state.video_id == Video.id, state.user_id == user.id))
|
||||||
.where(PlaylistItem.playlist_id == pl.id)
|
.where(PlaylistItem.playlist_id == pl.id)
|
||||||
.order_by(PlaylistItem.position)
|
# `id` breaks a position tie the same way on every read (the renumbering add does too).
|
||||||
|
.order_by(PlaylistItem.position, PlaylistItem.id)
|
||||||
).all()
|
).all()
|
||||||
return {
|
return {
|
||||||
"id": pl.id,
|
"id": pl.id,
|
||||||
@@ -451,7 +481,8 @@ def rename_playlist(
|
|||||||
user: User = Depends(current_user),
|
user: User = Depends(current_user),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
pl = owned_or_404(db, Playlist, playlist_id, user, what="playlist")
|
# Locked: the new name moves `dirty`, which the scheduled mirror refresh reads under this lock.
|
||||||
|
pl = owned_or_404(db, Playlist, playlist_id, user, what="playlist", lock=True)
|
||||||
if "name" in body.model_fields_set:
|
if "name" in body.model_fields_set:
|
||||||
name = (body.name or "").strip()
|
name = (body.name or "").strip()
|
||||||
if not name:
|
if not name:
|
||||||
@@ -491,33 +522,65 @@ def delete_playlist(
|
|||||||
pl = owned_or_404(db, Playlist, playlist_id, user, what="playlist")
|
pl = owned_or_404(db, Playlist, playlist_id, user, what="playlist")
|
||||||
if pl.kind == "watch_later":
|
if pl.kind == "watch_later":
|
||||||
raise _refused(400, "watch_later_undeletable")
|
raise _refused(400, "watch_later_undeletable")
|
||||||
if on_youtube:
|
# Not in the middle of a sync of it (`sync_claim`): a push that read the list before the delete
|
||||||
if not pl.yt_playlist_id:
|
# and wrote YouTube after it planned from no items at all, so it emptied the YouTube playlist a
|
||||||
raise _refused(400, "not_on_youtube")
|
# local-only delete means to keep. The push checks for a vanished list too (`PlaylistGone`);
|
||||||
if not has_write_scope(user):
|
# this refuses the delete while a sync runs rather than race it.
|
||||||
raise _refused(403, "youtube_editing_off")
|
with sync_claim(db, pl.id) as free:
|
||||||
try:
|
if not free:
|
||||||
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_DELETE):
|
raise _refused(409, "sync_busy")
|
||||||
with YouTubeClient(db, user) as yt:
|
if on_youtube:
|
||||||
yt.delete_playlist(pl.yt_playlist_id)
|
if not pl.yt_playlist_id:
|
||||||
except YouTubeError as exc:
|
raise _refused(400, "not_on_youtube")
|
||||||
raise youtube_refusal(exc)
|
if not has_write_scope(user):
|
||||||
db.delete(pl) # items cascade
|
raise _refused(403, "youtube_editing_off")
|
||||||
db.commit()
|
try:
|
||||||
|
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_DELETE):
|
||||||
|
with YouTubeClient(db, user) as yt:
|
||||||
|
yt.delete_playlist(pl.yt_playlist_id)
|
||||||
|
except YouTubeError as exc:
|
||||||
|
raise youtube_refusal(exc)
|
||||||
|
# The playlist first, as every item write: the cascade deletes the items before the list row.
|
||||||
|
lock_playlists(db, pl.id)
|
||||||
|
db.delete(pl) # items cascade
|
||||||
|
db.commit()
|
||||||
return {"deleted": playlist_id}
|
return {"deleted": playlist_id}
|
||||||
|
|
||||||
|
|
||||||
|
class AddItemIn(VideoRefIn):
|
||||||
|
# Where the video lands among the list's items, in their stored order (0 = first). Omitted, it
|
||||||
|
# is appended. The list page's "Undo" of a removal puts the row back where it was with this —
|
||||||
|
# ONE request, because append-then-reorder leaves it at the end whenever the second call fails.
|
||||||
|
position: int | None = Field(default=None, ge=0)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{playlist_id}/items")
|
@router.post("/{playlist_id}/items")
|
||||||
def add_item(
|
def add_item(
|
||||||
playlist_id: int,
|
playlist_id: int,
|
||||||
body: VideoRefIn,
|
body: AddItemIn,
|
||||||
user: User = Depends(current_user),
|
user: User = Depends(current_user),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
pl = owned_or_404(db, Playlist, playlist_id, user, what="playlist")
|
pl = owned_or_404(db, Playlist, playlist_id, user, what="playlist")
|
||||||
video_id = body.video_id
|
video_id = body.video_id
|
||||||
get_or_404(db, Video, video_id, what="video")
|
get_or_404(db, Video, video_id, what="video")
|
||||||
_add_item(db, pl, video_id, mark_dirty=True)
|
added = _add_item(db, pl, video_id, mark_dirty=False)
|
||||||
|
if added and body.position is not None:
|
||||||
|
# Renumber the whole list with the new row at `position` (clamped to the end): positions
|
||||||
|
# can have gaps after removals, so "index N" is only meaningful over the ordered rows.
|
||||||
|
rows = list(
|
||||||
|
db.execute(
|
||||||
|
select(PlaylistItem)
|
||||||
|
.where(PlaylistItem.playlist_id == pl.id)
|
||||||
|
.order_by(PlaylistItem.position, PlaylistItem.id)
|
||||||
|
).scalars()
|
||||||
|
)
|
||||||
|
new = next(r for r in rows if r.video_id == video_id)
|
||||||
|
rows.remove(new)
|
||||||
|
rows.insert(min(body.position, len(rows)), new)
|
||||||
|
for pos, row in enumerate(rows):
|
||||||
|
row.position = pos
|
||||||
|
recompute_dirty(db, pl)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"ok": True, "playlist_id": pl.id, "video_id": video_id}
|
return {"ok": True, "playlist_id": pl.id, "video_id": video_id}
|
||||||
|
|
||||||
@@ -562,6 +625,8 @@ def move_item(
|
|||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400, detail={"code": "playlist_move", "reason": "same_playlist"}
|
status_code=400, detail={"code": "playlist_move", "reason": "same_playlist"}
|
||||||
)
|
)
|
||||||
|
# Both lists, in id order: a move A→B beside a move B→A would otherwise lock crosswise.
|
||||||
|
lock_playlists(db, src.id, dst.id)
|
||||||
item = db.scalar(
|
item = db.scalar(
|
||||||
select(PlaylistItem).where(
|
select(PlaylistItem).where(
|
||||||
PlaylistItem.playlist_id == src.id, PlaylistItem.video_id == video_id
|
PlaylistItem.playlist_id == src.id, PlaylistItem.video_id == video_id
|
||||||
@@ -581,20 +646,11 @@ def move_item(
|
|||||||
if already is None:
|
if already is None:
|
||||||
# Re-parent the existing row rather than delete+insert: `added_at` ("when I saved this")
|
# Re-parent the existing row rather than delete+insert: `added_at` ("when I saved this")
|
||||||
# survives the move, and there is no window where the video belongs to neither list. The
|
# survives the move, and there is no window where the video belongs to neither list. The
|
||||||
# check above is not a lock: a second tab's add or the scheduler's YouTube merge can insert
|
# check above holds: both lists are locked, and an insert into the target (another tab's
|
||||||
# the same (playlist, video) in between, and uq_playlist_video then refuses the re-parent.
|
# add, a YouTube merge) waits for the lock — its foreign key needs the target row.
|
||||||
# That is the already-present outcome arriving late, not a failure — answer it the same way
|
item.playlist_id = dst.id
|
||||||
# instead of a raw 500.
|
item.position = _next_item_position(db, dst.id)
|
||||||
try:
|
dst_changed = True
|
||||||
with db.begin_nested():
|
|
||||||
item.playlist_id = dst.id
|
|
||||||
item.position = _next_item_position(db, dst.id)
|
|
||||||
db.flush()
|
|
||||||
dst_changed = True
|
|
||||||
# silent-ok: the conflict IS the answer — the target already holds the video, which is the
|
|
||||||
# idempotent outcome; the savepoint put the row back in the source list, dropped below.
|
|
||||||
except IntegrityError:
|
|
||||||
pass
|
|
||||||
if not dst_changed:
|
if not dst_changed:
|
||||||
db.delete(item)
|
db.delete(item)
|
||||||
# The source always changed; the target only when the row landed in it. `_touch` on a target
|
# The source always changed; the target only when the row landed in it. `_touch` on a target
|
||||||
@@ -619,7 +675,8 @@ def reorder_items(
|
|||||||
user: User = Depends(current_user),
|
user: User = Depends(current_user),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
pl = owned_or_404(db, Playlist, playlist_id, user, what="playlist")
|
# Locked: a concurrent add between reading the items and writing the order would collide.
|
||||||
|
pl = owned_or_404(db, Playlist, playlist_id, user, what="playlist", lock=True)
|
||||||
order = body.video_ids
|
order = body.video_ids
|
||||||
items = {
|
items = {
|
||||||
it.video_id: it
|
it.video_id: it
|
||||||
|
|||||||
@@ -3,8 +3,10 @@
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
from pydantic import BaseModel
|
from typing import Annotated
|
||||||
from sqlalchemy import delete, func, select, update
|
|
||||||
|
from pydantic import BaseModel, Field, model_validator
|
||||||
|
from sqlalchemy import delete, func, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.auth import current_user
|
from app.auth import current_user
|
||||||
@@ -217,7 +219,8 @@ def playlist_add_item(
|
|||||||
user: User = Depends(current_user),
|
user: User = Depends(current_user),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
pl = owned_or_404(db, PlexPlaylist, pid, user, what="playlist")
|
# Locked (like every add / reorder here): two adds otherwise read the same max position.
|
||||||
|
pl = owned_or_404(db, PlexPlaylist, pid, user, what="playlist", lock=True)
|
||||||
it = by_rating_key(db, PlexItem, body.item_rating_key)
|
it = by_rating_key(db, PlexItem, body.item_rating_key)
|
||||||
if it is None:
|
if it is None:
|
||||||
raise not_found("item")
|
raise not_found("item")
|
||||||
@@ -249,7 +252,9 @@ def playlist_remove_item(
|
|||||||
user: User = Depends(current_user),
|
user: User = Depends(current_user),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
pl = owned_or_404(db, PlexPlaylist, pid, user, what="playlist")
|
# Locked like the adds and the reorder: the list row first, then its items — a remove that
|
||||||
|
# locked an item row before a reorder locked the list deadlocked against it.
|
||||||
|
pl = owned_or_404(db, PlexPlaylist, pid, user, what="playlist", lock=True)
|
||||||
it = by_rating_key(db, PlexItem, item_rating_key)
|
it = by_rating_key(db, PlexItem, item_rating_key)
|
||||||
if it is not None:
|
if it is not None:
|
||||||
db.execute(
|
db.execute(
|
||||||
@@ -285,16 +290,75 @@ def _items_by_rks(db: Session, rks: list) -> list[PlexItem]:
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
class PlexItemAddBulkIn(PlexItemKeysIn):
|
||||||
|
# Where each key goes back (E28 S3e2, 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
|
||||||
|
# at the end whenever the second call fails, and the order it sends would overwrite anything
|
||||||
|
# another tab changed in between.
|
||||||
|
positions: list[Annotated[int, Field(ge=0)]] | None = None
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def _one_position_per_key(self):
|
||||||
|
if self.positions is not None and len(self.positions) != len(self.item_rating_keys):
|
||||||
|
raise ValueError("positions must pair one-to-one with item_rating_keys")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
def _insert_at(db: Session, pid: int, placed: list[tuple[int, PlexItem]]) -> int:
|
||||||
|
"""Insert new rows at their indices (ascending, so each index is where the row stood when the
|
||||||
|
rows before it were back), renumber the whole list 0..n-1, and say how many were added. An item
|
||||||
|
already in the list does not move: the add stays idempotent."""
|
||||||
|
rows = list(
|
||||||
|
db.execute(
|
||||||
|
select(PlexPlaylistItem)
|
||||||
|
.where(PlexPlaylistItem.playlist_id == pid)
|
||||||
|
.order_by(PlexPlaylistItem.position, PlexPlaylistItem.id)
|
||||||
|
).scalars()
|
||||||
|
)
|
||||||
|
present = {r.item_id for r in rows}
|
||||||
|
added = 0
|
||||||
|
for at, it in sorted(placed, key=lambda p: p[0]):
|
||||||
|
if it.id in present:
|
||||||
|
continue
|
||||||
|
row = PlexPlaylistItem(playlist_id=pid, item_id=it.id, position=0)
|
||||||
|
db.add(row)
|
||||||
|
rows.insert(min(at, len(rows)), row)
|
||||||
|
present.add(it.id)
|
||||||
|
added += 1
|
||||||
|
for i, r in enumerate(rows):
|
||||||
|
r.position = i
|
||||||
|
return added
|
||||||
|
|
||||||
|
|
||||||
@router.post("/playlists/{pid}/items/bulk")
|
@router.post("/playlists/{pid}/items/bulk")
|
||||||
def playlist_add_bulk(
|
def playlist_add_bulk(
|
||||||
pid: int,
|
pid: int,
|
||||||
body: PlexItemKeysIn,
|
body: PlexItemAddBulkIn,
|
||||||
user: User = Depends(current_user),
|
user: User = Depends(current_user),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Append many items at once (a whole season/show). Already-present items are skipped; the input
|
"""Append many items at once (a whole season/show). Already-present items are skipped; the input
|
||||||
order is preserved for the newly-added ones. Returns how many were added + the new total."""
|
order is preserved for the newly-added ones. With `positions`, each key is put back at its index
|
||||||
pl = owned_or_404(db, PlexPlaylist, pid, user, what="playlist")
|
instead of appended. Returns how many were added + the new total."""
|
||||||
|
pl = owned_or_404(db, PlexPlaylist, pid, user, what="playlist", lock=True)
|
||||||
|
if body.positions is not None:
|
||||||
|
by_rk = {it.rating_key: it for it in _items_by_rks(db, body.item_rating_keys)}
|
||||||
|
placed = [
|
||||||
|
(at, by_rk[str(rk)])
|
||||||
|
for rk, at in zip(body.item_rating_keys, body.positions, strict=True)
|
||||||
|
if str(rk) in by_rk
|
||||||
|
]
|
||||||
|
added = _insert_at(db, pid, placed)
|
||||||
|
if added:
|
||||||
|
pl.updated_at = datetime.now(timezone.utc)
|
||||||
|
db.commit()
|
||||||
|
total = (
|
||||||
|
db.scalar(
|
||||||
|
select(func.count(PlexPlaylistItem.id)).where(PlexPlaylistItem.playlist_id == pid)
|
||||||
|
)
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
return {"ok": True, "added": added, "item_count": total}
|
||||||
items = _items_by_rks(db, body.item_rating_keys)
|
items = _items_by_rks(db, body.item_rating_keys)
|
||||||
existing = set(
|
existing = set(
|
||||||
db.execute(
|
db.execute(
|
||||||
@@ -337,7 +401,7 @@ def playlist_remove_bulk(
|
|||||||
) -> dict:
|
) -> dict:
|
||||||
"""Remove many items at once (a whole season/show, or a multi-select). Returns how many were
|
"""Remove many items at once (a whole season/show, or a multi-select). Returns how many were
|
||||||
removed + the new total."""
|
removed + the new total."""
|
||||||
pl = owned_or_404(db, PlexPlaylist, pid, user, what="playlist")
|
pl = owned_or_404(db, PlexPlaylist, pid, user, what="playlist", lock=True)
|
||||||
ids = [it.id for it in _items_by_rks(db, body.item_rating_keys)]
|
ids = [it.id for it in _items_by_rks(db, body.item_rating_keys)]
|
||||||
removed = 0
|
removed = 0
|
||||||
if ids:
|
if ids:
|
||||||
@@ -367,7 +431,7 @@ def reorder_playlist(
|
|||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Set the playlist order from an explicit list of item rating_keys (0-based positions)."""
|
"""Set the playlist order from an explicit list of item rating_keys (0-based positions)."""
|
||||||
pl = owned_or_404(db, PlexPlaylist, pid, user, what="playlist")
|
pl = owned_or_404(db, PlexPlaylist, pid, user, what="playlist", lock=True)
|
||||||
order = body.item_rating_keys
|
order = body.item_rating_keys
|
||||||
id_by_rk = {
|
id_by_rk = {
|
||||||
it.rating_key: it.id
|
it.rating_key: it.id
|
||||||
@@ -375,14 +439,27 @@ def reorder_playlist(
|
|||||||
select(PlexItem).where(PlexItem.rating_key.in_(order or [""]))
|
select(PlexItem).where(PlexItem.rating_key.in_(order or [""]))
|
||||||
).scalars()
|
).scalars()
|
||||||
}
|
}
|
||||||
for pos, rk in enumerate(order):
|
rows = {
|
||||||
iid = id_by_rk.get(rk)
|
r.item_id: r
|
||||||
if iid is not None:
|
for r in db.execute(
|
||||||
db.execute(
|
select(PlexPlaylistItem).where(PlexPlaylistItem.playlist_id == pid)
|
||||||
update(PlexPlaylistItem)
|
).scalars()
|
||||||
.where(PlexPlaylistItem.playlist_id == pid, PlexPlaylistItem.item_id == iid)
|
}
|
||||||
.values(position=pos)
|
pos = 0
|
||||||
)
|
seen: set[int] = set()
|
||||||
|
for rk in order:
|
||||||
|
row = rows.get(id_by_rk.get(rk, -1))
|
||||||
|
if row is not None and row.item_id not in seen:
|
||||||
|
row.position = pos
|
||||||
|
pos += 1
|
||||||
|
seen.add(row.item_id)
|
||||||
|
# Rows the payload omitted (added in another tab meanwhile) keep their relative order after
|
||||||
|
# the given ones, so no two rows share a position (the YouTube reorder does the same).
|
||||||
|
for row in sorted(
|
||||||
|
(r for iid, r in rows.items() if iid not in seen), key=lambda r: (r.position, r.id)
|
||||||
|
):
|
||||||
|
row.position = pos
|
||||||
|
pos += 1
|
||||||
pl.updated_at = datetime.now(timezone.utc)
|
pl.updated_at = datetime.now(timezone.utc)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ def _ingest_candidates(
|
|||||||
try:
|
try:
|
||||||
with quota.attribute(user.id, quota.QuotaAction.CHANNELS_DISCOVER):
|
with quota.attribute(user.id, quota.QuotaAction.CHANNELS_DISCOVER):
|
||||||
details = yt.get_channels(new_channel_ids)
|
details = yt.get_channels(new_channel_ids)
|
||||||
apply_channel_details(db, details)
|
apply_channel_details(db, details, new_channel_ids)
|
||||||
db.commit()
|
db.commit()
|
||||||
except YouTubeError:
|
except YouTubeError:
|
||||||
# The stubs remain and the details can be backfilled later — but until they are, those
|
# The stubs remain and the details can be backfilled later — but until they are, those
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from typing import Callable
|
|||||||
from apscheduler.schedulers.background import BackgroundScheduler
|
from apscheduler.schedulers.background import BackgroundScheduler
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
from app import audit, logs, logsink, progress, quota
|
from app import audit, debugsession, logs, logsink, progress, quota
|
||||||
from app.audit import AuditAction
|
from app.audit import AuditAction
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.db import SessionLocal
|
from app.db import SessionLocal
|
||||||
@@ -386,9 +386,13 @@ def _log_gc_job() -> None:
|
|||||||
def work(db):
|
def work(db):
|
||||||
from app import sysconfig
|
from app import sysconfig
|
||||||
|
|
||||||
reaped = logsink.gc(db, sysconfig.get_int(db, "log_retention_days"))
|
days = sysconfig.get_int(db, "log_retention_days")
|
||||||
|
reaped = logsink.gc(db, days)
|
||||||
|
# Debug sessions (E16) go with the rows they bundle; an open one past its cap is closed.
|
||||||
|
closed = debugsession.sweep(db)
|
||||||
|
sessions = debugsession.gc(db, days)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"reaped": reaped}
|
return {"reaped": reaped, "sessions_closed": closed, "sessions_reaped": sessions}
|
||||||
|
|
||||||
_job("log_gc", work)
|
_job("log_gc", work)
|
||||||
|
|
||||||
|
|||||||
@@ -8,13 +8,15 @@ ingested (with a stub channel) so the mirror is faithful."""
|
|||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable, Iterator
|
||||||
|
from contextlib import AbstractContextManager, contextmanager
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import TypeVar
|
from typing import TypeVar
|
||||||
|
|
||||||
from sqlalchemy import delete, select
|
from sqlalchemy import Engine, create_engine, delete, func, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy.pool import NullPool
|
||||||
|
|
||||||
from app import logs, progress, quota
|
from app import logs, progress, quota
|
||||||
from app.auth import has_read_scope
|
from app.auth import has_read_scope
|
||||||
@@ -27,6 +29,87 @@ log = logs.get_logger(__name__)
|
|||||||
# Cost of a single YouTube write op (playlists/playlistItems insert/update/delete).
|
# Cost of a single YouTube write op (playlists/playlistItems insert/update/delete).
|
||||||
WRITE_UNIT_COST = 50
|
WRITE_UNIT_COST = 50
|
||||||
|
|
||||||
|
# The advisory-lock namespaces ("PL" per playlist, "PU" per user), so a key cannot meet another
|
||||||
|
# feature's lock on the same number (Postgres keeps the two-int keys apart from the one-bigint ones).
|
||||||
|
_SYNC_LOCK_NS = 0x504C
|
||||||
|
_USER_SYNC_LOCK_NS = 0x5055
|
||||||
|
|
||||||
|
# The claims' own connections, outside the app pool: a claim is held for a whole push (seconds of
|
||||||
|
# YouTube calls) beside the request's pooled session and quota's, so a burst of pushes drawing a
|
||||||
|
# third pooled connection each ran the pool dry and stalled every other request for pool_timeout.
|
||||||
|
# NullPool opens one per claim and closes it after, so a lock can never ride back into a pool either.
|
||||||
|
_claim_engines: dict[str, Engine] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _claim_engine(db: Session) -> Engine:
|
||||||
|
url = db.get_bind().engine.url
|
||||||
|
key = url.render_as_string(hide_password=False)
|
||||||
|
eng = _claim_engines.get(key)
|
||||||
|
if eng is None:
|
||||||
|
eng = _claim_engines[key] = create_engine(url, poolclass=NullPool, hide_parameters=True)
|
||||||
|
return eng
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def _advisory_claim(db: Session, ns: int, key: int) -> Iterator[bool]:
|
||||||
|
"""Try a session-level advisory lock for the block on a connection of its own; yields whether it
|
||||||
|
was free. It survives the block's commits, and a crashed worker's lock dies with its connection
|
||||||
|
— no claim column that a crash leaves stuck."""
|
||||||
|
conn = _claim_engine(db).connect()
|
||||||
|
held = False
|
||||||
|
try:
|
||||||
|
held = bool(conn.scalar(select(func.pg_try_advisory_lock(ns, key))))
|
||||||
|
conn.commit()
|
||||||
|
yield held
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
if held:
|
||||||
|
conn.scalar(select(func.pg_advisory_unlock(ns, key)))
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def sync_claim(db: Session, playlist_id: int) -> AbstractContextManager[bool]:
|
||||||
|
"""Claim one playlist's YouTube sync (push, re-pull, the scheduled mirror refresh) for the block;
|
||||||
|
yields whether it was free. A push reads YouTube, then writes both sides for seconds and commits
|
||||||
|
step by step, so a row lock ends at its first commit and two tabs (or a push and the scheduler)
|
||||||
|
interleave into a doubled YouTube item or a baseline recorded over the other's writes."""
|
||||||
|
return _advisory_claim(db, _SYNC_LOCK_NS, playlist_id)
|
||||||
|
|
||||||
|
|
||||||
|
class PlaylistGone(Exception):
|
||||||
|
"""The list a sync was working on was deleted meanwhile (another tab, the mirror drop). Its
|
||||||
|
items went with it, so a plan made now reads "every item removed here" — and would delete them
|
||||||
|
all from the YouTube playlist the reader chose to keep. The route answers 404."""
|
||||||
|
|
||||||
|
|
||||||
|
def lock_playlists(db: Session, *playlist_ids: int) -> set[int]:
|
||||||
|
"""Row-lock playlists before touching their items, several in id order. Every item write takes
|
||||||
|
the PLAYLIST first — the route edits, a push, a re-pull, the scheduled mirror refresh and a
|
||||||
|
delete alike: a writer that locked an item row first and the playlist second (a push's item
|
||||||
|
replace beside a remove) deadlocks against one that went the other way, and the loser is a
|
||||||
|
500. The rows are re-read, so `dirty`, the name and the sync baseline are the ones a finished
|
||||||
|
writer committed. Held to the caller's next commit — so no commit may come between this and
|
||||||
|
the write it guards. Returns the ids it found: a list deleted meanwhile is not among them."""
|
||||||
|
rows = db.scalars(
|
||||||
|
select(Playlist)
|
||||||
|
.where(Playlist.id.in_(sorted(set(playlist_ids))))
|
||||||
|
.order_by(Playlist.id)
|
||||||
|
.with_for_update()
|
||||||
|
.execution_options(populate_existing=True)
|
||||||
|
).all() # consumed: rows fetched are what refreshes the loaded objects
|
||||||
|
return {pl.id for pl in rows}
|
||||||
|
|
||||||
|
|
||||||
|
def _lock_or_gone(db: Session, pl: Playlist) -> None:
|
||||||
|
"""`lock_playlists` for a sync that goes on to write YouTube or the items: a list deleted since
|
||||||
|
it was read raises `PlaylistGone` (rolled back) instead of being planned as an empty one."""
|
||||||
|
pl_id = pl.id # read first: the rollback expires `pl`, and its row is gone
|
||||||
|
if pl_id not in lock_playlists(db, pl_id):
|
||||||
|
db.rollback()
|
||||||
|
raise PlaylistGone(pl_id)
|
||||||
|
|
||||||
|
|
||||||
def fingerprint(name: str, ids: list[str]) -> str:
|
def fingerprint(name: str, ids: list[str]) -> str:
|
||||||
"""A stable checksum of a playlist's name + ordered video ids. Stored as the synced
|
"""A stable checksum of a playlist's name + ordered video ids. Stored as the synced
|
||||||
@@ -91,11 +174,18 @@ def parse_unavailable(pl: Playlist) -> set[str]:
|
|||||||
return _load_baseline(pl)[1]
|
return _load_baseline(pl)[1]
|
||||||
|
|
||||||
|
|
||||||
def _set_baseline(pl: Playlist, ids: list[str], unavailable: list[str] | None = None) -> None:
|
def _set_baseline(
|
||||||
|
pl: Playlist,
|
||||||
|
ids: list[str],
|
||||||
|
unavailable: list[str] | None = None,
|
||||||
|
*,
|
||||||
|
name: str | None = None,
|
||||||
|
) -> None:
|
||||||
"""Record `ids` as both the hash (drives `dirty`, over the local ids only) and the raw ordered
|
"""Record `ids` as both the hash (drives `dirty`, over the local ids only) and the raw ordered
|
||||||
set (drives merge). `unavailable` = un-ingestable-but-live YouTube ids to carry forward so the
|
set (drives merge). `unavailable` = un-ingestable-but-live YouTube ids to carry forward so the
|
||||||
next merge skips them; stored only when non-empty (keeps the common case a plain list)."""
|
next merge skips them; stored only when non-empty (keeps the common case a plain list). `name`:
|
||||||
pl.synced_fingerprint = fingerprint(pl.name, ids)
|
the title YouTube now has, when that may differ from the list's own (see the push)."""
|
||||||
|
pl.synced_fingerprint = fingerprint(pl.name if name is None else name, ids)
|
||||||
if unavailable:
|
if unavailable:
|
||||||
pl.synced_video_ids = json.dumps({"ids": ids, "unavailable": list(unavailable)})
|
pl.synced_video_ids = json.dumps({"ids": ids, "unavailable": list(unavailable)})
|
||||||
else:
|
else:
|
||||||
@@ -103,6 +193,25 @@ def _set_baseline(pl: Playlist, ids: list[str], unavailable: list[str] | None =
|
|||||||
pl.dirty = False
|
pl.dirty = False
|
||||||
|
|
||||||
|
|
||||||
|
def _set_baseline_under_lock(
|
||||||
|
db: Session,
|
||||||
|
pl: Playlist,
|
||||||
|
ids: list[str],
|
||||||
|
unavailable: list[str] | None = None,
|
||||||
|
*,
|
||||||
|
name: str,
|
||||||
|
) -> None:
|
||||||
|
"""A push's closing baseline: `ids` and `name` are what YouTube now holds. The push spent
|
||||||
|
seconds on YouTube writes since it replaced the local items, so an edit may have landed
|
||||||
|
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
|
||||||
|
baseline taken over THAT name matched it, so the list read clean with YouTube's title still the
|
||||||
|
old one (E28 review round 4)."""
|
||||||
|
_lock_or_gone(db, pl)
|
||||||
|
_set_baseline(pl, ids, unavailable, name=name)
|
||||||
|
recompute_dirty(db, pl)
|
||||||
|
|
||||||
|
|
||||||
def recompute_dirty(db: Session, pl: Playlist) -> None:
|
def recompute_dirty(db: Session, pl: Playlist) -> None:
|
||||||
"""Set `dirty` by comparing the current state to the synced baseline. Unlinked lists
|
"""Set `dirty` by comparing the current state to the synced baseline. Unlinked lists
|
||||||
(and Watch later) are never dirty; a missing baseline counts as dirty (unknown YT state)."""
|
(and Watch later) are never dirty; a missing baseline counts as dirty (unknown YT state)."""
|
||||||
@@ -159,15 +268,13 @@ def _ensure_videos(db: Session, yt: YouTubeClient, video_ids: list[str]) -> None
|
|||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
def _replace_items_from_youtube(
|
def _replace_items_from_youtube(db: Session, pl: Playlist, ids: list[str]) -> list[str]:
|
||||||
db: Session, yt: YouTubeClient, pl: Playlist, ids: list[str]
|
|
||||||
) -> list[str]:
|
|
||||||
"""Mirror a YouTube playlist's ordered video ids into `pl`'s local items (authoritative
|
"""Mirror a YouTube playlist's ordered video ids into `pl`'s local items (authoritative
|
||||||
replace): ensure the videos exist in the catalog, keep only those present in their YT
|
replace): keep only those present in the catalog in their YT order (de-duplicated), swap the
|
||||||
order (de-duplicated), swap the items out, and reset the synced fingerprint + dirty flag.
|
items out, and reset the synced fingerprint + dirty flag. The caller has ingested `ids`
|
||||||
`pl.name` must already be set to the desired name. The caller commits. Returns the final
|
(`_ensure_videos`, which commits) and THEN taken `lock_playlists`, set `pl.name`, and commits
|
||||||
ordered ids."""
|
after: an ingest in here re-fetched every id YouTube will not return (a private or deleted
|
||||||
_ensure_videos(db, yt, ids)
|
video) and its commit let go of the lock before the replace. Returns the final ordered ids."""
|
||||||
present = (
|
present = (
|
||||||
set(db.execute(select(Video.id).where(Video.id.in_(ids))).scalars().all()) if ids else set()
|
set(db.execute(select(Video.id).where(Video.id.in_(ids))).scalars().all()) if ids else set()
|
||||||
)
|
)
|
||||||
@@ -196,6 +303,21 @@ def sync_user_playlists(db: Session, user: User) -> dict:
|
|||||||
the user's local playlists and the built-in Watch later untouched."""
|
the user's local playlists and the built-in Watch later untouched."""
|
||||||
if not has_read_scope(user):
|
if not has_read_scope(user):
|
||||||
return {"synced": 0, "reason": "no read scope"}
|
return {"synced": 0, "reason": "no read scope"}
|
||||||
|
with user_sync_claim(db, user.id) as free:
|
||||||
|
if not free:
|
||||||
|
return {"synced": 0, "reason": "busy"}
|
||||||
|
return pull_user_playlists(db, user)
|
||||||
|
|
||||||
|
|
||||||
|
def user_sync_claim(db: Session, user_id: int) -> AbstractContextManager[bool]:
|
||||||
|
"""Claim a user's bulk mirror pull for the block; yields whether it was free. Two runs at once
|
||||||
|
(the scheduler beside a manual "sync now", two tabs) both saw a YouTube list unmirrored and
|
||||||
|
each created its mirror — a duplicate row."""
|
||||||
|
return _advisory_claim(db, _USER_SYNC_LOCK_NS, user_id)
|
||||||
|
|
||||||
|
|
||||||
|
def pull_user_playlists(db: Session, user: User) -> dict:
|
||||||
|
"""The mirror pull itself; the caller holds `user_sync_claim`."""
|
||||||
synced = 0
|
synced = 0
|
||||||
with YouTubeClient(db, user) as yt:
|
with YouTubeClient(db, user) as yt:
|
||||||
yt_playlists = list(yt.iter_my_playlists())
|
yt_playlists = list(yt.iter_my_playlists())
|
||||||
@@ -219,11 +341,15 @@ def sync_user_playlists(db: Session, user: User) -> dict:
|
|||||||
.scalars()
|
.scalars()
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
# Drop mirrors whose YouTube playlist no longer exists.
|
# Drop mirrors whose YouTube playlist no longer exists — locked first, as every item write.
|
||||||
for ytid, pl in existing.items():
|
gone = [pl for ytid, pl in existing.items() if ytid not in yt_ids]
|
||||||
if ytid not in yt_ids:
|
if gone:
|
||||||
db.delete(pl)
|
lock_playlists(db, *(pl.id for pl in gone))
|
||||||
db.flush()
|
for pl in gone:
|
||||||
|
db.delete(pl)
|
||||||
|
# Committed now: a run where no list below commits (all dirty, or all claimed) would
|
||||||
|
# otherwise drop the deletions with the session.
|
||||||
|
db.commit()
|
||||||
|
|
||||||
for idx, p in enumerate(yt_playlists):
|
for idx, p in enumerate(yt_playlists):
|
||||||
ytid = p.get("id")
|
ytid = p.get("id")
|
||||||
@@ -245,12 +371,27 @@ def sync_user_playlists(db: Session, user: User) -> dict:
|
|||||||
)
|
)
|
||||||
db.add(pl)
|
db.add(pl)
|
||||||
db.flush()
|
db.flush()
|
||||||
else:
|
with sync_claim(db, pl.id) as free:
|
||||||
|
if not free:
|
||||||
|
# The user is pushing or re-pulling this very list: it writes the same rows.
|
||||||
|
# The next scheduled run refreshes it.
|
||||||
|
continue
|
||||||
|
ids = list(yt.iter_my_playlist_video_ids(ytid))
|
||||||
|
# Ingest first — it commits — so the row lock below is held from the dirty check
|
||||||
|
# to the commit that replaces the items.
|
||||||
|
_ensure_videos(db, yt, ids)
|
||||||
|
# `dirty` read above is a snapshot from before the YouTube calls (sessions keep
|
||||||
|
# loaded rows past a commit): a remove or reorder made meanwhile must not be
|
||||||
|
# replaced by YouTube's order. Item writes lock the playlist row, so this waits for
|
||||||
|
# one in flight and sees what it committed.
|
||||||
|
lock_playlists(db, pl.id)
|
||||||
|
if pl.dirty:
|
||||||
|
db.rollback()
|
||||||
|
continue
|
||||||
pl.name = p.get("title") or pl.name
|
pl.name = p.get("title") or pl.name
|
||||||
# Mirror is authoritative: replace the items to match YouTube's order.
|
# Mirror is authoritative: replace the items to match YouTube's order.
|
||||||
ids = list(yt.iter_my_playlist_video_ids(ytid))
|
_replace_items_from_youtube(db, pl, ids)
|
||||||
_replace_items_from_youtube(db, yt, pl, ids)
|
db.commit()
|
||||||
db.commit()
|
|
||||||
synced += 1
|
synced += 1
|
||||||
return {"synced": synced}
|
return {"synced": synced}
|
||||||
|
|
||||||
@@ -488,7 +629,8 @@ def merge_playlist(db: Session, user: User, pl: Playlist, *, live=None) -> dict:
|
|||||||
breaker = _Breaker(playlist_id=pl.id)
|
breaker = _Breaker(playlist_id=pl.id)
|
||||||
with YouTubeClient(db, user) as yt:
|
with YouTubeClient(db, user) as yt:
|
||||||
if not pl.yt_playlist_id:
|
if not pl.yt_playlist_id:
|
||||||
pl.yt_playlist_id = yt.create_playlist(pl.name)
|
pushed_name = pl.name
|
||||||
|
pl.yt_playlist_id = yt.create_playlist(pushed_name)
|
||||||
db.commit()
|
db.commit()
|
||||||
created = 1
|
created = 1
|
||||||
# Brand-new playlist: just append every local video in order.
|
# Brand-new playlist: just append every local video in order.
|
||||||
@@ -501,7 +643,7 @@ def merge_playlist(db: Session, user: User, pl: Playlist, *, live=None) -> dict:
|
|||||||
if not isinstance(added, _Failed):
|
if not isinstance(added, _Failed):
|
||||||
inserted += 1
|
inserted += 1
|
||||||
if not failures:
|
if not failures:
|
||||||
_set_baseline(pl, local)
|
_set_baseline_under_lock(db, pl, local, name=pushed_name)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {
|
return {
|
||||||
"created": created,
|
"created": created,
|
||||||
@@ -542,6 +684,14 @@ def merge_playlist(db: Session, user: User, pl: Playlist, *, live=None) -> dict:
|
|||||||
# merged order authoritatively. Do this first so local reflects the merge even if a later
|
# merged order authoritatively. Do this first so local reflects the merge even if a later
|
||||||
# YouTube write trips the breaker.
|
# YouTube write trips the breaker.
|
||||||
_ensure_videos(db, yt, mp.add_to_local)
|
_ensure_videos(db, yt, mp.add_to_local)
|
||||||
|
# `local` was read before the YouTube calls above: an add or remove committed meanwhile
|
||||||
|
# (another tab) would be undone by the replace below. Lock the list — the ingest's commit is
|
||||||
|
# behind us — and plan again from what is there now. Nothing new needs ingesting: a video
|
||||||
|
# the re-read adds is already a local row. A list deleted meanwhile stops here, before a plan
|
||||||
|
# over its now-empty items removes them all from YouTube.
|
||||||
|
_lock_or_gone(db, pl)
|
||||||
|
local = _local_video_ids(db, pl)
|
||||||
|
mp = merge_plan(parse_baseline(pl), local, yt_ids, effective_unavailable)
|
||||||
# Only the ids that actually exist in the catalog can become local rows (a private/deleted
|
# Only the ids that actually exist in the catalog can become local rows (a private/deleted
|
||||||
# YouTube video won't ingest); keep the merged order, dropping any that didn't materialize.
|
# YouTube video won't ingest); keep the merged order, dropping any that didn't materialize.
|
||||||
present = (
|
present = (
|
||||||
@@ -555,10 +705,12 @@ def merge_playlist(db: Session, user: User, pl: Playlist, *, live=None) -> dict:
|
|||||||
_set_local_items(db, pl, final_local)
|
_set_local_items(db, pl, final_local)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
# --- YouTube side (metered, breaker-guarded).
|
# --- YouTube side (metered, breaker-guarded). The name as of the lock above: the closing
|
||||||
if snippet is not None and (snippet.get("title") or "") != pl.name:
|
# baseline records THIS one, whatever a rename made during the writes below says.
|
||||||
|
pushed_name = pl.name
|
||||||
|
if snippet is not None and (snippet.get("title") or "") != pushed_name:
|
||||||
try:
|
try:
|
||||||
yt.update_playlist_title(pl.yt_playlist_id, pl.name)
|
yt.update_playlist_title(pl.yt_playlist_id, pushed_name)
|
||||||
except YouTubeError as exc:
|
except YouTubeError as exc:
|
||||||
# All the caller passes on is the word "title", which says the rename did not
|
# All the caller passes on is the word "title", which says the rename did not
|
||||||
# happen but not that YouTube refused it, or why.
|
# happen but not that YouTube refused it, or why.
|
||||||
@@ -626,7 +778,7 @@ def merge_playlist(db: Session, user: User, pl: Playlist, *, live=None) -> dict:
|
|||||||
(effective_unavailable | {v for v in mp.add_to_local if v not in present})
|
(effective_unavailable | {v for v in mp.add_to_local if v not in present})
|
||||||
& yt_id_set
|
& yt_id_set
|
||||||
)
|
)
|
||||||
_set_baseline(pl, final_local, unavailable)
|
_set_baseline_under_lock(db, pl, final_local, unavailable, name=pushed_name)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {
|
return {
|
||||||
"created": created,
|
"created": created,
|
||||||
@@ -652,9 +804,11 @@ def repull_playlist(db: Session, user: User, pl: Playlist) -> dict:
|
|||||||
snippet = yt.get_playlist_snippet(pl.yt_playlist_id)
|
snippet = yt.get_playlist_snippet(pl.yt_playlist_id)
|
||||||
if snippet is None:
|
if snippet is None:
|
||||||
raise YouTubeError("Playlist no longer exists on YouTube")
|
raise YouTubeError("Playlist no longer exists on YouTube")
|
||||||
pl.name = snippet.get("title") or pl.name
|
|
||||||
ids = list(yt.iter_my_playlist_video_ids(pl.yt_playlist_id))
|
ids = list(yt.iter_my_playlist_video_ids(pl.yt_playlist_id))
|
||||||
ordered = _replace_items_from_youtube(db, yt, pl, ids)
|
_ensure_videos(db, yt, ids)
|
||||||
|
_lock_or_gone(db, pl)
|
||||||
|
pl.name = snippet.get("title") or pl.name
|
||||||
|
ordered = _replace_items_from_youtube(db, pl, ids)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"items": len(ordered)}
|
return {"items": len(ordered)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
"""Import the authenticated user's YouTube subscriptions and channel metadata."""
|
"""Import the authenticated user's YouTube subscriptions and channel metadata."""
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
from collections.abc import Iterable
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import or_, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app import logs
|
from app import logs
|
||||||
@@ -47,8 +48,37 @@ def _external_links(branding: dict) -> list | None:
|
|||||||
return out or None
|
return out or None
|
||||||
|
|
||||||
|
|
||||||
def apply_channel_details(db: Session, items: list[dict]) -> None:
|
# How old a channel's metadata may get before it is fetched again. Banners, avatars, descriptions and
|
||||||
|
# counts change on YouTube, and an old banner URL stops resolving at all — without a refresh a channel
|
||||||
|
# kept whatever it had the day it was first seen. A week keeps counts roughly current at almost no
|
||||||
|
# cost: `channels.list` is 1 quota unit per 50 channels, and only the stale ones are asked for.
|
||||||
|
DETAILS_MAX_AGE = timedelta(days=7)
|
||||||
|
|
||||||
|
|
||||||
|
def details_stale(channel: Channel, now: datetime) -> bool:
|
||||||
|
"""Whether `channel`'s metadata is missing or older than DETAILS_MAX_AGE (see `details_stale_clause`)."""
|
||||||
|
synced = channel.details_synced_at
|
||||||
|
return synced is None or synced < now - DETAILS_MAX_AGE
|
||||||
|
|
||||||
|
|
||||||
|
def details_stale_clause(now: datetime):
|
||||||
|
"""`details_stale` as a WHERE clause, so the batch import and a single channel agree on "stale"."""
|
||||||
|
return or_(
|
||||||
|
Channel.details_synced_at.is_(None),
|
||||||
|
Channel.details_synced_at < now - DETAILS_MAX_AGE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def apply_channel_details(db: Session, items: list[dict], asked: Iterable[str]) -> None:
|
||||||
|
"""Write `channels.list`'s answer onto the rows. `asked` is every id the call asked for: one
|
||||||
|
YouTube did not return (terminated, deleted) is stamped as synced too — otherwise it stays
|
||||||
|
stale forever and every view / import asks for it again, spending quota on a known miss."""
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
|
returned = {item["id"] for item in items}
|
||||||
|
for cid in set(asked) - returned:
|
||||||
|
missing = db.get(Channel, cid)
|
||||||
|
if missing is not None:
|
||||||
|
missing.details_synced_at = now
|
||||||
for item in items:
|
for item in items:
|
||||||
channel = db.get(Channel, item["id"])
|
channel = db.get(Channel, item["id"])
|
||||||
if channel is None:
|
if channel is None:
|
||||||
@@ -92,7 +122,7 @@ def should_prune(fetched_count: int, existing_count: int) -> bool:
|
|||||||
def import_subscriptions(db: Session, user: User) -> dict:
|
def import_subscriptions(db: Session, user: User) -> dict:
|
||||||
"""Pull all subscriptions for `user`, upsert channels + subscription links, prune
|
"""Pull all subscriptions for `user`, upsert channels + subscription links, prune
|
||||||
channels the user has unsubscribed from on YouTube, and fetch channel details for
|
channels the user has unsubscribed from on YouTube, and fetch channel details for
|
||||||
any channel we don't have details for yet."""
|
any channel whose details are missing or stale (`DETAILS_MAX_AGE`)."""
|
||||||
fetched: dict[str, dict] = {}
|
fetched: dict[str, dict] = {}
|
||||||
new_channels = 0
|
new_channels = 0
|
||||||
|
|
||||||
@@ -145,12 +175,12 @@ def import_subscriptions(db: Session, user: User) -> dict:
|
|||||||
removed += 1
|
removed += 1
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
# Fetch details for channels that don't have them yet.
|
# Fetch details for channels that don't have them yet, or had them too long ago.
|
||||||
need_details = (
|
need_details = (
|
||||||
db.execute(
|
db.execute(
|
||||||
select(Channel).where(
|
select(Channel).where(
|
||||||
Channel.id.in_(fetched.keys()),
|
Channel.id.in_(fetched.keys()),
|
||||||
Channel.details_synced_at.is_(None),
|
details_stale_clause(datetime.now(timezone.utc)),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.scalars()
|
.scalars()
|
||||||
@@ -158,8 +188,9 @@ def import_subscriptions(db: Session, user: User) -> dict:
|
|||||||
)
|
)
|
||||||
detailed = 0
|
detailed = 0
|
||||||
if need_details:
|
if need_details:
|
||||||
items = yt.get_channels([c.id for c in need_details])
|
ids = [c.id for c in need_details]
|
||||||
apply_channel_details(db, items)
|
items = yt.get_channels(ids)
|
||||||
|
apply_channel_details(db, items, ids)
|
||||||
detailed = len(items)
|
detailed = len(items)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
|||||||
@@ -137,10 +137,29 @@ SPECS: tuple[ConfigSpec, ...] = (
|
|||||||
min=1,
|
min=1,
|
||||||
max=100_000,
|
max=100_000,
|
||||||
),
|
),
|
||||||
# min=1, NOT 0: the per-file cap is enforced as `if cap and size > cap`, so a stored 0 would read
|
# min=1, NOT 0: a stored 0 used to read as "no per-file cap" (the cap was enforced as
|
||||||
# as falsy and SILENTLY disable the limit (an unbounded streamed write to the volume). Floor it at
|
# `if cap and size > cap`, so a falsy value disabled it). Since P1 the route passes this number
|
||||||
# 1 so the cap is always active; the overall footprint is separately bounded by the byte quota.
|
# straight to `capped_stream_to`, where 0 would refuse every upload instead — the opposite
|
||||||
ConfigSpec("download_upload_max_bytes", "int", "downloads", "download_upload_max_bytes", min=1),
|
# failure, and the reason `config.download_upload_max_bytes` carries its own `ge=1`. Either way
|
||||||
|
# a stored 0 is wrong, so it is floored at 1; the overall footprint is separately bounded by the
|
||||||
|
# byte quota.
|
||||||
|
# `max` is not cosmetic: `bodylimit.ROUTE_MAX_BODY_BYTES` refuses an upload body over exactly
|
||||||
|
# this number at the edge, before the route's own check ever sees it. An admin allowed to raise
|
||||||
|
# the knob past the edge would get 413s from a layer the admin page never mentions, so the two
|
||||||
|
# are pinned together here and `tests/test_body_limit.py` holds them that way. Below the edge is
|
||||||
|
# the useful direction and the one the knob exists for: a lowered knob is what makes the route's
|
||||||
|
# own 422 — the one carrying this figure — the answer the user gets. `_coerce` also
|
||||||
|
# REJECTS a stored value outside these bounds on read (falling back to the env default, with a
|
||||||
|
# warning) — neither bound has always been what it is now, so a legal-when-written override can
|
||||||
|
# be sitting in the database already.
|
||||||
|
ConfigSpec(
|
||||||
|
"download_upload_max_bytes",
|
||||||
|
"int",
|
||||||
|
"downloads",
|
||||||
|
"download_upload_max_bytes",
|
||||||
|
min=1,
|
||||||
|
max=settings.download_upload_max_bytes,
|
||||||
|
),
|
||||||
ConfigSpec(
|
ConfigSpec(
|
||||||
"download_retention_days", "int", "downloads", "download_retention_days", min=0, max=3_650
|
"download_retention_days", "int", "downloads", "download_retention_days", min=0, max=3_650
|
||||||
),
|
),
|
||||||
@@ -192,6 +211,10 @@ SPECS: tuple[ConfigSpec, ...] = (
|
|||||||
# --- TMDB enrichment (E5 S2; optional) — the API key is the only secret ---
|
# --- TMDB enrichment (E5 S2; 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 ---
|
||||||
|
ConfigSpec("issues_gitea_url", "str", "issues", "issues_gitea_url"),
|
||||||
|
ConfigSpec("issues_gitea_repo", "str", "issues", "issues_gitea_repo"),
|
||||||
|
ConfigSpec("issues_gitea_token", "str", "issues", "issues_gitea_token", secret=True),
|
||||||
# (tmdb_enrich_interval_min is a scheduler job, tuned from the Scheduler dashboard; tmdb_enrich_batch
|
# (tmdb_enrich_interval_min is a scheduler job, tuned from the Scheduler dashboard; tmdb_enrich_batch
|
||||||
# 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.)
|
||||||
)
|
)
|
||||||
@@ -232,9 +255,29 @@ def _choice(s: ConfigSpec, raw: object) -> str:
|
|||||||
return option
|
return option
|
||||||
|
|
||||||
|
|
||||||
|
class OutOfRange(ValueError):
|
||||||
|
"""A stored int override that no longer satisfies its spec's bounds."""
|
||||||
|
|
||||||
|
|
||||||
def _coerce(s: ConfigSpec, raw: str):
|
def _coerce(s: ConfigSpec, raw: str):
|
||||||
if s.type == "int":
|
if s.type == "int":
|
||||||
return int(raw)
|
# 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
|
||||||
|
# the edge; its `min` went 0 → 1 during E7) 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
|
||||||
|
# acting on a number another layer refuses, with the admin page showing a value outside the
|
||||||
|
# range it renders.
|
||||||
|
#
|
||||||
|
# It raises rather than CLAMPING, and that distinction cost a round: clamping is a silent
|
||||||
|
# rewrite, and silently rewriting is exactly what this module refuses to do elsewhere. The
|
||||||
|
# case that settles it is a stored 0 for download_upload_max_bytes — legal when `min` was 0,
|
||||||
|
# and meaning "no per-file cap" to the route that reads it. Clamped to `min=1` it becomes a
|
||||||
|
# ONE-BYTE cap and uploads stop working with nothing in the log. Raising drops it into the
|
||||||
|
# same fallback the uncoercible path uses: the env default, and one warning.
|
||||||
|
v = int(raw)
|
||||||
|
if (s.min is not None and v < s.min) or (s.max is not None and v > s.max):
|
||||||
|
raise OutOfRange(f"{v} is outside [{s.min}, {s.max}]")
|
||||||
|
return v
|
||||||
if s.type == "bool":
|
if s.type == "bool":
|
||||||
return raw == "true"
|
return raw == "true"
|
||||||
if s.type == "choice":
|
if s.type == "choice":
|
||||||
@@ -287,14 +330,19 @@ def get(db: Session, key: str):
|
|||||||
return _coerce(s, raw)
|
return _coerce(s, raw)
|
||||||
except (TypeError, ValueError) as exc:
|
except (TypeError, ValueError) as exc:
|
||||||
# The admin set this in the UI and it is quietly not in force: the app runs on the env
|
# The admin set this in the UI and it is quietly not in force: the app runs on the env
|
||||||
# default while the settings page goes on showing the value that was saved.
|
# default while the settings page goes on showing the value that was saved. An OutOfRange is
|
||||||
|
# the same situation arrived at from the other direction — the value was fine when it was
|
||||||
|
# written and the spec moved under it — so it gets the same treatment and its own code.
|
||||||
|
event = (
|
||||||
|
"sysconfig.override_out_of_range"
|
||||||
|
if isinstance(exc, OutOfRange)
|
||||||
|
else "sysconfig.override_uncoercible"
|
||||||
|
)
|
||||||
if (s.key, raw) not in _coerce_warned:
|
if (s.key, raw) not in _coerce_warned:
|
||||||
_coerce_warned.add((s.key, raw))
|
_coerce_warned.add((s.key, raw))
|
||||||
log.warning(
|
log.warning(
|
||||||
"a stored setting could not be coerced; the default is used instead",
|
"a stored setting is not usable; the default is used instead",
|
||||||
extra=logs.event(
|
extra=logs.event(event, key=s.key, reason=type(exc).__name__),
|
||||||
"sysconfig.override_uncoercible", key=s.key, reason=type(exc).__name__
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
return _default(s)
|
return _default(s)
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ from app.downloads.jobdb import (
|
|||||||
_staging_dir,
|
_staging_dir,
|
||||||
)
|
)
|
||||||
from app.downloads.status import STOPPED_BY_ROUTE, JobStatus
|
from app.downloads.status import STOPPED_BY_ROUTE, JobStatus
|
||||||
from app.models import DownloadJob, MediaAsset
|
from app.models import DISPLAY_TEXT_MAX, DownloadJob, MediaAsset
|
||||||
|
|
||||||
log = logs.get_logger(__name__)
|
log = logs.get_logger(__name__)
|
||||||
_stop = threading.Event()
|
_stop = threading.Event()
|
||||||
@@ -562,7 +562,7 @@ def _finish_from_cache(job_id: int, asset_id: int) -> None:
|
|||||||
fields = {"status": JobStatus.DONE, "progress": 100, "phase": None}
|
fields = {"status": JobStatus.DONE, "progress": 100, "phase": None}
|
||||||
if not job.display_name and asset.title:
|
if not job.display_name and asset.title:
|
||||||
# display_name is VARCHAR(255); a title can be longer (e.g. a recipe in the title).
|
# display_name is VARCHAR(255); a title can be longer (e.g. a recipe in the title).
|
||||||
fields["display_name"] = asset.title[:255]
|
fields["display_name"] = asset.title[:DISPLAY_TEXT_MAX]
|
||||||
asset.last_access_at = datetime.now(timezone.utc)
|
asset.last_access_at = datetime.now(timezone.utc)
|
||||||
# Atomic finalize: only marks the job done while it is still running, so a concurrent
|
# Atomic finalize: only marks the job done while it is still running, so a concurrent
|
||||||
# cancel/pause is never resurrected (see service.finalize_if_running).
|
# cancel/pause is never resurrected (see service.finalize_if_running).
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
// The backend type-check lane (E27 S3) — 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.
|
||||||
|
//
|
||||||
|
// basedpyright, not mypy: measured on this tree with the dependencies actually installed, both land
|
||||||
|
// ~190-200 errors with the same distribution, so the choice was made on the things that differ —
|
||||||
|
// basedpyright is ~2x faster, needs no MYPYPATH to resolve our `app.*` imports, and has a FIRST-CLASS
|
||||||
|
// baseline (`--writebaseline`), which is the whole ratchet. mypy would have needed a third-party tool
|
||||||
|
// for that.
|
||||||
|
//
|
||||||
|
// It runs in the pytest test image, never on the host: `requirements.lock` cannot be installed on
|
||||||
|
// Windows at all (uvloop refuses to build there), so a host run would type-check against absent
|
||||||
|
// third-party packages and report nonsense. The version is pinned in requirements-dev.txt, which the
|
||||||
|
// image installs — so the lane's version IS the pin, with no host-vs-pin check to drift.
|
||||||
|
//
|
||||||
|
// `pythonVersion` is not free-standing: `/.python-version` is the single source of truth and
|
||||||
|
// `tests/test_python_engine_contract.py` fails this gate if this file, the two Dockerfiles, the CI
|
||||||
|
// container or ruff's target-version disagree with it.
|
||||||
|
//
|
||||||
|
// `include` covers `alembic` for the same reason the ruff lane was widened to it: migrations run on
|
||||||
|
// every container start, so one that raises there crash-loops prod, and "it is generated code" is
|
||||||
|
// not an argument when the generated code is what boots the system. `tests/` is deliberately OUT —
|
||||||
|
// a test's job is to fail loudly when it is wrong, which is the opposite of the silent runtime
|
||||||
|
// failure this lane exists to catch, and annotating fixtures to a checker's satisfaction is churn
|
||||||
|
// the ratchet exists to avoid.
|
||||||
|
{
|
||||||
|
"include": ["app", "alembic"],
|
||||||
|
"extraPaths": ["."],
|
||||||
|
"typeCheckingMode": "basic",
|
||||||
|
"pythonVersion": "3.12",
|
||||||
|
"pythonPlatform": "Linux"
|
||||||
|
}
|
||||||
@@ -4,6 +4,10 @@
|
|||||||
# 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
|
||||||
|
# checker whose version floats silently changes what "clean" means, and the ratchet baseline it
|
||||||
|
# writes is keyed to the diagnostics THIS version produces.
|
||||||
|
basedpyright==1.40.1
|
||||||
pytest==8.4.2
|
pytest==8.4.2
|
||||||
ruff==0.15.21
|
ruff==0.15.21
|
||||||
httpx2==2.13.0
|
httpx2==2.13.0
|
||||||
|
|||||||
@@ -47,7 +47,6 @@ pydantic-settings==2.15.0
|
|||||||
pydantic==2.13.4
|
pydantic==2.13.4
|
||||||
pydantic_core==2.46.4
|
pydantic_core==2.46.4
|
||||||
python-dotenv==1.2.3
|
python-dotenv==1.2.3
|
||||||
python-multipart==0.0.32
|
|
||||||
requests==2.34.2
|
requests==2.34.2
|
||||||
rich==15.0.0
|
rich==15.0.0
|
||||||
starlette==1.6.0
|
starlette==1.6.0
|
||||||
|
|||||||
@@ -1,7 +1,4 @@
|
|||||||
fastapi>=0.115,<1.0
|
fastapi>=0.115,<1.0
|
||||||
# Multipart form parsing — required by Starlette/FastAPI to accept file uploads (UploadFile/Form).
|
|
||||||
# The download center's local-file Upload (E7) is the only multipart endpoint in the app.
|
|
||||||
python-multipart>=0.0.9,<1.0
|
|
||||||
uvicorn[standard]>=0.30,<1.0
|
uvicorn[standard]>=0.30,<1.0
|
||||||
sqlalchemy>=2.0,<2.1
|
sqlalchemy>=2.0,<2.1
|
||||||
psycopg[binary]>=3.2,<4.0
|
psycopg[binary]>=3.2,<4.0
|
||||||
|
|||||||
@@ -7,6 +7,10 @@
|
|||||||
# the repo wrap at the same width, and it's the width `ruff format` enforces (gated via
|
# the repo wrap at the same width, and it's the width `ruff format` enforces (gated via
|
||||||
# `ruff format --check` alongside `ruff check`).
|
# `ruff format --check` alongside `ruff check`).
|
||||||
line-length = 100
|
line-length = 100
|
||||||
|
# The interpreter this code actually runs on, so ruff judges syntax and upgrade hints against the
|
||||||
|
# right one. Left unset it falls back to a default that has nothing to do with us. `/.python-version`
|
||||||
|
# is the single source of truth; tests/test_python_engine_contract.py holds this to it.
|
||||||
|
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 E22.5 makes of it:
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ tests that COMMIT (the whole point of this lane — transaction-ownership behavi
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from alembic import command
|
from alembic import command
|
||||||
@@ -111,8 +112,11 @@ def _request_dep(db, unfinished: list[str]):
|
|||||||
request that ends normally with a write it did not commit — flushed, or still only staged — is
|
request that ends normally with a write it did not commit — flushed, or still only staged — is
|
||||||
recorded, and the test fails on it at teardown. Only the ROOT transaction's end clears it: a
|
recorded, and the test fails on it at teardown. Only the ROOT transaction's end clears it: a
|
||||||
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 exempt: its writes are meant to
|
test's business, not the handler's. A request that raises is rolled back, as production's
|
||||||
roll back."""
|
`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
|
||||||
|
on the tracker before a failed upload was kept only by an explicit commit, and nothing saw it
|
||||||
|
missing)."""
|
||||||
|
|
||||||
def dep(conn: HTTPConnection):
|
def dep(conn: HTTPConnection):
|
||||||
db.flush()
|
db.flush()
|
||||||
@@ -133,6 +137,9 @@ def _request_dep(db, unfinished: list[str]):
|
|||||||
staged = db.new or db.deleted or any(db.is_modified(o) for o in db.dirty)
|
staged = db.new or db.deleted or any(db.is_modified(o) for o in db.dirty)
|
||||||
if state["dirty"] or staged:
|
if state["dirty"] or staged:
|
||||||
unfinished.append(f"{conn.scope.get('method', 'WS')} {conn.url.path}")
|
unfinished.append(f"{conn.scope.get('method', 'WS')} {conn.url.path}")
|
||||||
|
except Exception:
|
||||||
|
db.rollback()
|
||||||
|
raise
|
||||||
finally:
|
finally:
|
||||||
for name, fn in hooks:
|
for name, fn in hooks:
|
||||||
event.remove(db, name, fn)
|
event.remove(db, name, fn)
|
||||||
@@ -242,6 +249,27 @@ def as_user(db, override_dep, _configured):
|
|||||||
return _as
|
return _as
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def upload_headers():
|
||||||
|
"""Build the headers `POST /api/downloads/upload` takes since P1.
|
||||||
|
|
||||||
|
Shared, because two test modules drive that route and a second copy of this is exactly the drift
|
||||||
|
that lets the wire shape change in one place only. The body IS the file now; the names ride as
|
||||||
|
percent-encoded headers (`quote` mirrors the client's `encodeURIComponent`, since headers are
|
||||||
|
latin-1 and a Hungarian filename would not survive raw)."""
|
||||||
|
|
||||||
|
def _headers(filename: str, display_name: str | None = None) -> dict[str, str]:
|
||||||
|
headers = {
|
||||||
|
"content-type": "application/octet-stream",
|
||||||
|
"x-upload-filename": quote(filename),
|
||||||
|
}
|
||||||
|
if display_name is not None:
|
||||||
|
headers["x-upload-display-name"] = quote(display_name)
|
||||||
|
return headers
|
||||||
|
|
||||||
|
return _headers
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def _reset_rate_limiters():
|
def _reset_rate_limiters():
|
||||||
"""Every RateLimiter is a module-level singleton keyed by client IP, and TestClient always
|
"""Every RateLimiter is a module-level singleton keyed by client IP, and TestClient always
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ def test_the_single_playlist_repull_shares_the_pull_cooldown(db, as_user, make_u
|
|||||||
db.commit()
|
db.commit()
|
||||||
monkeypatch.setattr(plroutes, "has_read_scope", lambda u: True)
|
monkeypatch.setattr(plroutes, "has_read_scope", lambda u: True)
|
||||||
monkeypatch.setattr(plroutes, "repull_playlist", lambda d, u, p: {"ok": True})
|
monkeypatch.setattr(plroutes, "repull_playlist", lambda d, u, p: {"ok": True})
|
||||||
monkeypatch.setattr(plroutes, "sync_user_playlists", lambda d, u: {"ok": True})
|
monkeypatch.setattr(plroutes, "pull_user_playlists", lambda d, u: {"ok": True})
|
||||||
|
|
||||||
c = as_user(user)
|
c = as_user(user)
|
||||||
codes = [c.post(f"/api/playlists/{pl.id}/revert-youtube").status_code for _ in range(4)]
|
codes = [c.post(f"/api/playlists/{pl.id}/revert-youtube").status_code for _ in range(4)]
|
||||||
|
|||||||
@@ -0,0 +1,705 @@
|
|||||||
|
"""SEC-20 — the global request-body cap (`app/bodylimit.py`).
|
||||||
|
|
||||||
|
Two halves, deliberately kept apart:
|
||||||
|
|
||||||
|
* the MIDDLEWARE tests drive a toy ASGI app, so they pin the mechanism (declared length, counted
|
||||||
|
bytes, the exemption match) with no router, no auth and no database in the way;
|
||||||
|
* the APP tests drive the real application, so they pin that the middleware is actually wired in,
|
||||||
|
sits ahead of the auth dependencies, and refuses in the same shape `capped_body` does.
|
||||||
|
|
||||||
|
The pair that matters most is "an oversized body is refused" together with "a normal body still
|
||||||
|
gets through": the first alone would pass just as well if the cap refused *everything*.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import ast
|
||||||
|
import json
|
||||||
|
import pathlib
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from starlette.requests import Request
|
||||||
|
|
||||||
|
from app import bodylimit, debugsession, sysconfig
|
||||||
|
from app.config import settings
|
||||||
|
from app.models import SystemConfig
|
||||||
|
from app.bodylimit import DEFAULT_MAX_BODY_BYTES, BodyLimitMiddleware
|
||||||
|
from app.routes import diagnostics as diagnostics_routes
|
||||||
|
|
||||||
|
SMALL = b"x" * 16
|
||||||
|
OVER = b"x" * (DEFAULT_MAX_BODY_BYTES + 1)
|
||||||
|
|
||||||
|
|
||||||
|
async def _reading_app(scope, receive, send):
|
||||||
|
"""A toy app that reads the whole body the way a route does, then reports its length.
|
||||||
|
|
||||||
|
It uses a real Starlette `Request`, so the faked `http.disconnect` reaches it as the real
|
||||||
|
ClientDisconnect a real handler would see — the middleware's exception path is exercised, not
|
||||||
|
stubbed around.
|
||||||
|
"""
|
||||||
|
body = await Request(scope, receive).body()
|
||||||
|
payload = json.dumps({"read": len(body)}).encode()
|
||||||
|
await send(
|
||||||
|
{
|
||||||
|
"type": "http.response.start",
|
||||||
|
"status": 200,
|
||||||
|
"headers": [(b"content-type", b"application/json")],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
await send({"type": "http.response.body", "body": payload})
|
||||||
|
|
||||||
|
|
||||||
|
def _client(**kwargs) -> TestClient:
|
||||||
|
return TestClient(BodyLimitMiddleware(_reading_app, **kwargs))
|
||||||
|
|
||||||
|
|
||||||
|
def _chunks(total: int, size: int = 64 * 1024):
|
||||||
|
"""A body with NO Content-Length: httpx sends an iterator chunked, which is exactly the shape a
|
||||||
|
declared-length check cannot see."""
|
||||||
|
sent = 0
|
||||||
|
while sent < total:
|
||||||
|
n = min(size, total - sent)
|
||||||
|
yield b"x" * n
|
||||||
|
sent += n
|
||||||
|
|
||||||
|
|
||||||
|
# --- the mechanism ------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_declared_length_over_the_cap_is_refused_without_reading_the_body():
|
||||||
|
r = _client().post("/anything", content=OVER)
|
||||||
|
assert r.status_code == 413
|
||||||
|
assert r.content == bodylimit.TOO_LARGE_BODY
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_body_under_the_cap_is_passed_through_untouched():
|
||||||
|
# The mutation guard for the test above: if the cap refused everything, this would fail.
|
||||||
|
r = _client().post("/anything", content=SMALL)
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json() == {"read": len(SMALL)}
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_chunked_body_is_refused_on_the_COUNTED_bytes():
|
||||||
|
# No Content-Length at all, so the only thing that can stop this is counting as it arrives.
|
||||||
|
r = _client().post("/anything", content=_chunks(DEFAULT_MAX_BODY_BYTES + 64 * 1024))
|
||||||
|
assert r.status_code == 413
|
||||||
|
assert r.content == bodylimit.TOO_LARGE_BODY
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_chunked_body_under_the_cap_still_arrives_whole():
|
||||||
|
size = DEFAULT_MAX_BODY_BYTES - 1
|
||||||
|
r = _client().post("/anything", content=_chunks(size))
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json() == {"read": size}
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_understated_content_length_does_not_buy_a_pass():
|
||||||
|
# The declared-length check is the cheap half; a client that lies must still be counted out.
|
||||||
|
r = _client().post("/anything", content=OVER, headers={"content-length": "10"})
|
||||||
|
assert r.status_code == 413
|
||||||
|
|
||||||
|
|
||||||
|
def test_exactly_at_the_cap_is_allowed():
|
||||||
|
size = DEFAULT_MAX_BODY_BYTES
|
||||||
|
r = _client(limit=size).post("/anything", content=b"x" * size)
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json() == {"read": size}
|
||||||
|
|
||||||
|
|
||||||
|
# --- the per-route ceilings ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
BIG = {("POST", "/api/things/{tid}/blob"): DEFAULT_MAX_BODY_BYTES * 4}
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_route_with_a_raised_ceiling_may_send_a_body_over_the_global_cap():
|
||||||
|
r = _client(routes=BIG).post("/api/things/abc/blob", content=OVER)
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json() == {"read": len(OVER)}
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_raised_ceiling_is_still_a_ceiling():
|
||||||
|
"""The difference between this and an exemption, and the reason the list is a mapping: a route
|
||||||
|
that needs more bytes does not thereby get unlimited bytes. Left uncounted, the upload route
|
||||||
|
would let an ANONYMOUS caller stream without bound — FastAPI parses a declared body before the
|
||||||
|
first dependency, so `require_human` has not run yet when the bytes land."""
|
||||||
|
over = b"x" * (BIG[("POST", "/api/things/{tid}/blob")] + 1)
|
||||||
|
r = _client(routes=BIG).post("/api/things/abc/blob", content=over)
|
||||||
|
assert r.status_code == 413
|
||||||
|
assert r.content == bodylimit.TOO_LARGE_BODY
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_raised_ceiling_counts_a_chunked_body_too():
|
||||||
|
cap = BIG[("POST", "/api/things/{tid}/blob")]
|
||||||
|
r = _client(routes=BIG).post("/api/things/abc/blob", content=_chunks(cap + 64 * 1024))
|
||||||
|
assert r.status_code == 413
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_raised_ceiling_is_scoped_to_its_method_and_its_path():
|
||||||
|
client = _client(routes=BIG)
|
||||||
|
# Same path, different method.
|
||||||
|
assert client.put("/api/things/abc/blob", content=OVER).status_code == 413
|
||||||
|
# A neighbour that merely starts the same way.
|
||||||
|
assert client.post("/api/things/abc/blob/extra", content=OVER).status_code == 413
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_path_parameter_cannot_swallow_a_slash():
|
||||||
|
# `{tid}` matching greedily across `/` would hand the raised ceiling to routes without one.
|
||||||
|
mw = BodyLimitMiddleware(_reading_app, routes={("POST", "/a/{tid}/b"): 99})
|
||||||
|
assert mw.limit_for("POST", "/a/one/b") == 99
|
||||||
|
assert mw.limit_for("POST", "/a/one/two/b") == DEFAULT_MAX_BODY_BYTES
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_request_gets_a_finite_ceiling():
|
||||||
|
"""There is no pass-through path. A route that is not listed gets the default; one that is
|
||||||
|
listed gets its own; nothing is simply not counted."""
|
||||||
|
mw = BodyLimitMiddleware(_reading_app)
|
||||||
|
assert mw.limit_for("GET", "/api/anything") == DEFAULT_MAX_BODY_BYTES
|
||||||
|
for (method, template), cap in bodylimit.ROUTE_MAX_BODY_BYTES.items():
|
||||||
|
assert cap > DEFAULT_MAX_BODY_BYTES # the admission rule: raised, never lowered
|
||||||
|
assert mw.limit_for(method, template.replace("{sid}", "abc")) == cap
|
||||||
|
|
||||||
|
|
||||||
|
def _declared_routes(root) -> set[tuple[str, str]]:
|
||||||
|
"""Every (method, path template) the app serves.
|
||||||
|
|
||||||
|
It has to RECURSE: since FastAPI 0.141 / Starlette 1.6 an `include_router` leaves an opaque
|
||||||
|
`_IncludedRouter` in `app.routes` and keeps the real routes under its `original_router`, instead
|
||||||
|
of flattening them into the parent as older versions did. Reading `app.routes` alone finds 35
|
||||||
|
entries and none of ours; walking the tree finds all 252.
|
||||||
|
"""
|
||||||
|
found: set[tuple[str, str]] = set()
|
||||||
|
stack = [root]
|
||||||
|
while stack:
|
||||||
|
node = stack.pop()
|
||||||
|
for route in getattr(node, "routes", ()) or ():
|
||||||
|
inner = getattr(route, "original_router", None)
|
||||||
|
if inner is not None:
|
||||||
|
stack.append(inner)
|
||||||
|
continue
|
||||||
|
for method in getattr(route, "methods", None) or ():
|
||||||
|
found.add((method, route.path))
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(("method", "template"), sorted(bodylimit.ROUTE_MAX_BODY_BYTES))
|
||||||
|
def test_every_listed_template_still_resolves_to_a_real_route(method, template):
|
||||||
|
"""The drift guard. The list names route templates that live in another file; a renamed route
|
||||||
|
must fail the gate here, not lose its allowance silently in production."""
|
||||||
|
from app.main import app
|
||||||
|
|
||||||
|
assert (method, template) in _declared_routes(app)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_default_ceiling_clears_the_largest_body_a_default_route_takes():
|
||||||
|
"""The headroom the module's docblock claims, held as a RATIO rather than as a figure in prose.
|
||||||
|
The client log sink caps itself at 512 KiB — below the default, which is why it is not listed:
|
||||||
|
the global ceiling never fires on it and raising it could only widen what it accepts.
|
||||||
|
|
||||||
|
`<=` alone would not hold the claim. The docblock calls the 2x margin load-bearing precisely
|
||||||
|
because a ceiling that refuses a legitimate request is a worse bug than the one this module
|
||||||
|
fixes, and a margin of 1x is no margin at all.
|
||||||
|
"""
|
||||||
|
assert diagnostics_routes.BODY_MAX_BYTES * 2 <= DEFAULT_MAX_BODY_BYTES
|
||||||
|
assert not any(
|
||||||
|
path.startswith("/api/diagnostics") for _, path in bodylimit.ROUTE_MAX_BODY_BYTES
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Every body-reading call site in the routers, as (module, function, limit expression). A route that
|
||||||
|
# reads its own body declares the size it MEANS to accept; if that is above the global default and
|
||||||
|
# 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.
|
||||||
|
#
|
||||||
|
# BOTH helpers, not just `capped_body`: P1 added `capped_stream_to` as its sibling and the registry
|
||||||
|
# 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
|
||||||
|
# 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>`
|
||||||
|
# rather than vanishing (round 3).
|
||||||
|
#
|
||||||
|
# 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`.
|
||||||
|
CAPPED_BODY_SITES = {
|
||||||
|
("diagnostics.py", "_parsed_events", "capped_body", "BODY_MAX_BYTES"),
|
||||||
|
("debug_sessions.py", "_image_body", "capped_body", "debugsession.SCREENSHOT_MAX_BYTES"),
|
||||||
|
("downloads.py", "spool_upload", "capped_stream_to", "cap"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _body_readers() -> set[str]:
|
||||||
|
"""The names of the body-reading helpers `_common.py` exports — asked of the module rather than
|
||||||
|
listed here, so adding a fourth one cannot quietly leave the registry below behind."""
|
||||||
|
common = pathlib.Path(__file__).resolve().parents[1] / "app" / "routes" / "_common.py"
|
||||||
|
tree = ast.parse(common.read_text("utf-8"), filename=str(common))
|
||||||
|
return {
|
||||||
|
node.name
|
||||||
|
for node in tree.body
|
||||||
|
if isinstance(node, (ast.AsyncFunctionDef, ast.FunctionDef))
|
||||||
|
and node.name.startswith("capped_")
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_registry_covers_every_body_reading_helper():
|
||||||
|
"""The mutation guard for the tests below: if a helper were renamed or added, the site list
|
||||||
|
would still match itself while the new one went uncovered."""
|
||||||
|
assert _body_readers() == {"capped_body", "capped_stream_to"}, (
|
||||||
|
"routes/_common.py grew or renamed a body-reading helper — add its call sites to "
|
||||||
|
"CAPPED_BODY_SITES and this expectation."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# The ways a route can read its own body WITHOUT one of `_common.py`'s helpers. Every `Request`
|
||||||
|
# parameter under `app/routes/` is named `request`, so the base name is an exact filter and not a
|
||||||
|
# guess — the only other `.json()` in the package is `exc.json()` on a ValidationError.
|
||||||
|
#
|
||||||
|
# Without this set the three guards below know only the `capped_*` naming convention, which means
|
||||||
|
# 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
|
||||||
|
# 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).
|
||||||
|
RAW_BODY_READS = {"body", "stream", "form", "json"}
|
||||||
|
|
||||||
|
|
||||||
|
def _call_name(node: ast.Call) -> str | None:
|
||||||
|
if isinstance(node.func, ast.Attribute):
|
||||||
|
return node.func.attr
|
||||||
|
return getattr(node.func, "id", None)
|
||||||
|
|
||||||
|
|
||||||
|
def _body_read_calls(fn: ast.AST, readers: set[str]) -> list[tuple[ast.Call, str]]:
|
||||||
|
"""Every call inside `fn` that reads the request body, as (node, name) in source order."""
|
||||||
|
out: list[tuple[ast.Call, str]] = []
|
||||||
|
for node in ast.walk(fn):
|
||||||
|
if not isinstance(node, ast.Call):
|
||||||
|
continue
|
||||||
|
if (
|
||||||
|
isinstance(node.func, ast.Attribute)
|
||||||
|
and isinstance(node.func.value, ast.Name)
|
||||||
|
and node.func.value.id == "request"
|
||||||
|
and node.func.attr in RAW_BODY_READS
|
||||||
|
):
|
||||||
|
out.append((node, f"request.{node.func.attr}"))
|
||||||
|
elif _call_name(node) in readers:
|
||||||
|
out.append((node, _call_name(node) or ""))
|
||||||
|
return sorted(out, key=lambda pair: (pair[0].lineno, pair[0].col_offset))
|
||||||
|
|
||||||
|
|
||||||
|
def _body_reading_functions() -> dict[tuple[str, str], dict]:
|
||||||
|
"""Every function under `app/routes/` that reads a request body itself, keyed by
|
||||||
|
(module, function).
|
||||||
|
|
||||||
|
Parsed, not grepped. A regex over the call text has to guess how the arguments are spelled, and
|
||||||
|
the shapes it guesses wrong — a bare `4 * 1024 * 1024`, a parameter not named `request` — are
|
||||||
|
the natural ways the NEXT site gets written, so a miss reads as "no new sites" rather than as a
|
||||||
|
failure. `ast` sees every call whatever it looks like.
|
||||||
|
|
||||||
|
Keyed by the ENCLOSING FUNCTION, not just the module: a set keyed by module and limit
|
||||||
|
expression collapses two call sites that spell their limit the same way, which is exactly what
|
||||||
|
a second route copied from the first would do.
|
||||||
|
|
||||||
|
The module half of the key is the path RELATIVE to `app/routes/`, not the basename: the scan
|
||||||
|
recurses, and `admin.py`, `discover.py` and `playlists.py` each exist both at the top level and
|
||||||
|
under `plex/`. Keying by basename would let the second of a colliding pair overwrite the first
|
||||||
|
and take its `releases` flag out of the comparison with it — the same entry-collapse this
|
||||||
|
registry was widened to close.
|
||||||
|
|
||||||
|
`_common.py` is skipped: it is where the helpers live, so its own `request.stream()` loops are
|
||||||
|
the implementation of the rule rather than a violation of it.
|
||||||
|
"""
|
||||||
|
readers = _body_readers()
|
||||||
|
routes_dir = pathlib.Path(__file__).resolve().parents[1] / "app" / "routes"
|
||||||
|
found: dict[tuple[str, str], dict] = {}
|
||||||
|
for py in sorted(routes_dir.rglob("*.py")):
|
||||||
|
if py.name == "_common.py":
|
||||||
|
continue
|
||||||
|
module = py.relative_to(routes_dir).as_posix()
|
||||||
|
tree = ast.parse(py.read_text("utf-8"), filename=str(py))
|
||||||
|
for fn in ast.walk(tree):
|
||||||
|
if not isinstance(fn, (ast.AsyncFunctionDef, ast.FunctionDef)):
|
||||||
|
continue
|
||||||
|
calls = _body_read_calls(fn, readers)
|
||||||
|
if not calls:
|
||||||
|
continue
|
||||||
|
names = [name for _, name in calls]
|
||||||
|
limits = []
|
||||||
|
for node, name in zip([n for n, _ in calls], names):
|
||||||
|
if name.startswith("request."):
|
||||||
|
limits.append("<no ceiling declared>")
|
||||||
|
continue
|
||||||
|
limit = next((kw.value for kw in node.keywords if kw.arg == "limit"), None)
|
||||||
|
if limit is None:
|
||||||
|
pos = 2 if name == "capped_stream_to" else 1
|
||||||
|
limit = node.args[pos] if len(node.args) > pos else None
|
||||||
|
limits.append(ast.unparse(limit) if limit is not None else "<none>")
|
||||||
|
# POSITION, not presence. `any(...)` over the function accepted a release that ran
|
||||||
|
# AFTER the await it exists to precede — the natural spelling for someone reading the
|
||||||
|
# name as "release it when you're done" — while pinning a connection for the whole
|
||||||
|
# transfer. The first body read is what the release has to beat.
|
||||||
|
releases = [
|
||||||
|
node
|
||||||
|
for node in ast.walk(fn)
|
||||||
|
if isinstance(node, ast.Call) and _call_name(node) == "release_read_connection"
|
||||||
|
]
|
||||||
|
first_read = calls[0][0].lineno
|
||||||
|
found[(module, fn.name)] = {
|
||||||
|
"helpers": names,
|
||||||
|
"limits": limits,
|
||||||
|
"releases": any(node.lineno < first_read for node in releases),
|
||||||
|
"releases_late": bool(releases)
|
||||||
|
and not any(node.lineno < first_read for node in releases),
|
||||||
|
}
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_body_reading_site_is_known_and_fits_under_its_ceiling():
|
||||||
|
found = {
|
||||||
|
(module, fn, site["helpers"][i], site["limits"][i])
|
||||||
|
for (module, fn), site in _body_reading_functions().items()
|
||||||
|
for i in range(len(site["helpers"]))
|
||||||
|
}
|
||||||
|
assert found == CAPPED_BODY_SITES, (
|
||||||
|
f"a body-reading call site appeared, moved or changed shape: {found ^ CAPPED_BODY_SITES}. "
|
||||||
|
"Each one declares a body size; check it against bodylimit.DEFAULT_MAX_BODY_BYTES and, if "
|
||||||
|
"it is larger, add its route to bodylimit.ROUTE_MAX_BODY_BYTES — then update this list."
|
||||||
|
)
|
||||||
|
# And the known ones really do satisfy the rule.
|
||||||
|
assert diagnostics_routes.BODY_MAX_BYTES <= DEFAULT_MAX_BODY_BYTES
|
||||||
|
assert debugsession.SCREENSHOT_MAX_BYTES > DEFAULT_MAX_BODY_BYTES
|
||||||
|
assert ("POST", "/api/debug-sessions/{sid}/screenshots") in bodylimit.ROUTE_MAX_BODY_BYTES
|
||||||
|
# The upload site's limit is the sysconfig knob, which is bounded BY the edge ceiling rather
|
||||||
|
# than being a constant to compare against — that pairing is its own test below.
|
||||||
|
assert ("POST", "/api/downloads/upload") in bodylimit.ROUTE_MAX_BODY_BYTES
|
||||||
|
|
||||||
|
|
||||||
|
def test_release_read_connection_ends_the_transaction_it_was_called_for(db):
|
||||||
|
"""The helper itself: a SELECT has autobegun, and afterwards it has not."""
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.db import release_read_connection
|
||||||
|
|
||||||
|
db.execute(select(SystemConfig).limit(1))
|
||||||
|
assert db.in_transaction(), "nothing autobegan, so this test would pass vacuously"
|
||||||
|
release_read_connection(db)
|
||||||
|
assert not db.in_transaction()
|
||||||
|
|
||||||
|
|
||||||
|
def test_release_read_connection_refuses_to_discard_pending_work(db):
|
||||||
|
"""The precondition, ENFORCED rather than described.
|
||||||
|
|
||||||
|
The session belongs to the whole request, not to the dependency calling this — so if something
|
||||||
|
ahead of it ever stages a write, ending the transaction would throw that write away with
|
||||||
|
nothing in the log. `service.enqueue` already learned that a whole-session rollback takes the
|
||||||
|
caller's pending work with it, which is why it uses a SAVEPOINT. A comment cannot stop the next
|
||||||
|
dependency from being added in the wrong place; this can.
|
||||||
|
"""
|
||||||
|
from app.db import release_read_connection
|
||||||
|
|
||||||
|
db.add(SystemConfig(key="probe_pending_write", value="1"))
|
||||||
|
with pytest.raises(RuntimeError, match="pending work"):
|
||||||
|
release_read_connection(db)
|
||||||
|
db.rollback() # the staged row is this test's own
|
||||||
|
|
||||||
|
|
||||||
|
def test_release_read_connection_refuses_to_discard_a_FLUSHED_write(db):
|
||||||
|
"""The half the unit-of-work check cannot see.
|
||||||
|
|
||||||
|
`db.new`/`dirty`/`deleted` empty out the moment a helper flushes, and CLAUDE.md's canonical
|
||||||
|
split is "the handler commits; helpers flush" — so the single most likely future violator is
|
||||||
|
the one shape the obvious check is blind to. Measured before the fix: all three sets empty, the
|
||||||
|
guard silent, the row gone after the rollback.
|
||||||
|
"""
|
||||||
|
from app.db import release_read_connection
|
||||||
|
|
||||||
|
db.add(SystemConfig(key="probe_flushed_write", value="1"))
|
||||||
|
db.flush()
|
||||||
|
assert not (db.new or db.dirty or db.deleted), (
|
||||||
|
"the flush did not clear the unit-of-work, so this test would pass for the wrong reason"
|
||||||
|
)
|
||||||
|
with pytest.raises(RuntimeError, match="already WRITTEN"):
|
||||||
|
release_read_connection(db)
|
||||||
|
db.rollback()
|
||||||
|
|
||||||
|
|
||||||
|
def test_release_read_connection_refuses_to_discard_a_core_dml_write(db):
|
||||||
|
"""The other half: `db.execute(update(...))` never enters the unit-of-work at all, and
|
||||||
|
`downloads/service.py` writes that way on purpose (a conditional `UPDATE … WHERE status=…` is
|
||||||
|
the honest-states pattern)."""
|
||||||
|
from sqlalchemy import insert
|
||||||
|
|
||||||
|
from app.db import release_read_connection
|
||||||
|
|
||||||
|
db.execute(insert(SystemConfig).values(key="probe_core_dml", value="1"))
|
||||||
|
assert not (db.new or db.dirty or db.deleted), (
|
||||||
|
"Core DML entered the unit-of-work, so this test would pass for the wrong reason"
|
||||||
|
)
|
||||||
|
with pytest.raises(RuntimeError, match="already WRITTEN"):
|
||||||
|
release_read_connection(db)
|
||||||
|
db.rollback()
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_body_reading_site_hands_its_pooled_connection_back_first():
|
||||||
|
"""Reading the body yourself is what makes a request SLOW inside its own database session.
|
||||||
|
|
||||||
|
The auth (or ownership) dependency that must resolve first is a SELECT, so SQLAlchemy has
|
||||||
|
autobegun and the session is holding one of the pool's connections; awaiting the body then pins
|
||||||
|
it for as long as the client keeps sending — one connection per in-flight upload, `idle in
|
||||||
|
transaction`, with nothing to time it out. `db.release_read_connection` ends that transaction,
|
||||||
|
and every one of these sites needs it.
|
||||||
|
|
||||||
|
Held generically because the three sites arrived one at a time and only the newest was fixed:
|
||||||
|
a fourth body-reading route gets the same requirement automatically.
|
||||||
|
|
||||||
|
BEFORE the read, not merely somewhere in the function. A release that runs after the await
|
||||||
|
satisfies the letter of the rule and none of its purpose — the connection was held for exactly
|
||||||
|
the span the rule exists to protect — so `releases_late` is reported as its own, louder case.
|
||||||
|
"""
|
||||||
|
sites = _body_reading_functions()
|
||||||
|
late = [f"{module}:{fn}" for (module, fn), site in sites.items() if site["releases_late"]]
|
||||||
|
missing = [
|
||||||
|
f"{module}:{fn}"
|
||||||
|
for (module, fn), site in sites.items()
|
||||||
|
if not site["releases"] and not site["releases_late"]
|
||||||
|
]
|
||||||
|
assert not late, (
|
||||||
|
"a route calls db.release_read_connection AFTER it reads the body, which holds the pooled "
|
||||||
|
f"connection for exactly the transfer it was supposed to free: {late}. Move the call above "
|
||||||
|
"the await."
|
||||||
|
)
|
||||||
|
assert not missing, (
|
||||||
|
"a route reads its own request body while still inside its read transaction, pinning a "
|
||||||
|
f"pooled connection for the whole transfer: {missing}. Call "
|
||||||
|
"db.release_read_connection(db) before the await — see routes/downloads.py:spool_upload."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_upload_ceiling_and_the_admin_knob_cannot_drift_apart():
|
||||||
|
"""The upload route's in-handler cap is a `sysconfig` value an admin can change; the edge is a
|
||||||
|
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.
|
||||||
|
|
||||||
|
Since P1 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,
|
||||||
|
exactly. A gap reappearing means someone re-wrapped the upload in an envelope without saying so.
|
||||||
|
|
||||||
|
Equality also means the EDGE is what answers on a default install, where no override has lowered
|
||||||
|
the knob: the declared-length check refuses before a byte is read. That is not a gap to close
|
||||||
|
with headroom (measured: headroom only moves the boundary by its own size, and the route's drain
|
||||||
|
pushes the count back over it) — it is a refusal the client has to be able to read, which is why
|
||||||
|
`errors.tooLarge` exists and why the dialog checks the file against `upload_max_bytes` first.
|
||||||
|
"""
|
||||||
|
spec = next(s for s in sysconfig.SPECS if s.key == "download_upload_max_bytes")
|
||||||
|
edge = bodylimit.ROUTE_MAX_BODY_BYTES[("POST", "/api/downloads/upload")]
|
||||||
|
assert spec.max == edge
|
||||||
|
|
||||||
|
|
||||||
|
def _route_objects(root):
|
||||||
|
"""`_declared_routes`' sibling, keeping the route OBJECT so its FastAPI dependant can be read."""
|
||||||
|
found: dict[tuple[str, str], object] = {}
|
||||||
|
stack = [root]
|
||||||
|
while stack:
|
||||||
|
node = stack.pop()
|
||||||
|
for route in getattr(node, "routes", ()) or ():
|
||||||
|
inner = getattr(route, "original_router", None)
|
||||||
|
if inner is not None:
|
||||||
|
stack.append(inner)
|
||||||
|
continue
|
||||||
|
for method in getattr(route, "methods", None) or ():
|
||||||
|
found[(method, route.path)] = route
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
@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):
|
||||||
|
"""P1'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.
|
||||||
|
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
|
||||||
|
thing between an anonymous caller and the whole allowance, `require_human` never gets a say, and
|
||||||
|
the route's in-handler cap measures bytes that have already landed. That is exactly what P1
|
||||||
|
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.
|
||||||
|
|
||||||
|
Asked of FastAPI itself rather than parsed or grepped: the router already knows which parameters
|
||||||
|
it will read the body for, and a shape a regex or an AST walk guesses wrong is precisely how the
|
||||||
|
next route slips through.
|
||||||
|
|
||||||
|
`route.body_field`, NOT `route.dependant.body_params`, and the difference is the whole test.
|
||||||
|
`body_params` holds only the parameters of the route's OWN callable; `body_field` is built from
|
||||||
|
`_get_flat_body_params`, which recurses into sub-dependencies — and that is also what FastAPI
|
||||||
|
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
|
||||||
|
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
|
||||||
|
`yield` dependency — is exactly the shape that would have slipped through."""
|
||||||
|
from app.main import app
|
||||||
|
|
||||||
|
route = _route_objects(app).get((method, template))
|
||||||
|
assert route is not None, f"{method} {template} is not a route any more"
|
||||||
|
assert route.body_field is None, (
|
||||||
|
f"{method} {template} declares a body ({route.body_field.name}) on the route or in one of "
|
||||||
|
"its dependencies, so FastAPI reads it before the first dependency runs and the raised "
|
||||||
|
"ceiling is all that stands between an anonymous caller and the whole allowance. Read the "
|
||||||
|
"body yourself, from a dependency declared AFTER the auth one — see "
|
||||||
|
"routes/downloads.py:spool_upload."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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 —
|
||||||
|
the upload knob had no `max` before SEC-20 and its `min` was 0 before E7 — so a value that was
|
||||||
|
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 fallback is the env default and a warning, NOT a clamp. A clamp is a silent rewrite, and on
|
||||||
|
the min side it would be a wrong one: a stored 0 meant "no per-file cap" to the route, and
|
||||||
|
clamped to 1 it becomes a one-byte cap that kills uploads with nothing in the log.
|
||||||
|
"""
|
||||||
|
spec = next(s for s in sysconfig.SPECS if s.key == "download_upload_max_bytes")
|
||||||
|
assert spec.max is not None and spec.min is not None
|
||||||
|
default = settings.download_upload_max_bytes
|
||||||
|
|
||||||
|
row = SystemConfig(key="download_upload_max_bytes", value=str(spec.max * 4))
|
||||||
|
db.add(row)
|
||||||
|
db.commit()
|
||||||
|
assert sysconfig.get_int(db, "download_upload_max_bytes") == default
|
||||||
|
|
||||||
|
row.value = "0" # the legacy "no per-file cap", now below min
|
||||||
|
db.commit()
|
||||||
|
assert sysconfig.get_int(db, "download_upload_max_bytes") == default
|
||||||
|
|
||||||
|
# An override that IS in range must still be honoured — otherwise a bounds predicate written
|
||||||
|
# backwards would make every override inert and the two assertions above could not tell.
|
||||||
|
# Deliberately not `spec.max`: that equals the env default for this key, so the assertion would
|
||||||
|
# be satisfied by the fallback it is meant to rule out.
|
||||||
|
row.value = str(spec.max - 1)
|
||||||
|
db.commit()
|
||||||
|
assert spec.max - 1 != default
|
||||||
|
assert sysconfig.get_int(db, "download_upload_max_bytes") == spec.max - 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_cap_sits_inside_the_response_decorators_and_outside_the_setup_gate():
|
||||||
|
"""The registration order, held in code rather than in the comment beside it. `add_middleware`
|
||||||
|
inverts the reading order (last registered = OUTERMOST), which is exactly the kind of thing a
|
||||||
|
prose description gets backwards — it already did once.
|
||||||
|
|
||||||
|
The layers are named, not counted by class: all four `@app.middleware("http")` decorators —
|
||||||
|
including the setup gate, which must be INSIDE — register as the same `BaseHTTPMiddleware`, so a
|
||||||
|
class-shaped assertion passes for every arrangement anyone would plausibly get wrong.
|
||||||
|
|
||||||
|
Outside the cap: only response decorators, so the 413 is dressed like any other answer. Inside
|
||||||
|
it: everything that does work on the caller's behalf — the setup gate opens a DB session per
|
||||||
|
request while an instance is unconfigured, which is the anonymous pre-auth work SEC-20 exists to
|
||||||
|
remove.
|
||||||
|
"""
|
||||||
|
from app.main import app
|
||||||
|
|
||||||
|
def layer(m) -> str:
|
||||||
|
dispatch = m.kwargs.get("dispatch") if m.kwargs else None
|
||||||
|
return dispatch.__name__ if dispatch is not None else getattr(m.cls, "__name__", str(m.cls))
|
||||||
|
|
||||||
|
names = [layer(m) for m in app.user_middleware] # outer → inner
|
||||||
|
i = names.index("BodyLimitMiddleware")
|
||||||
|
assert set(names[:i]) <= {
|
||||||
|
"log_requests",
|
||||||
|
"security_headers",
|
||||||
|
"static_cache_headers",
|
||||||
|
"CORSMiddleware",
|
||||||
|
}, f"something that is not a response decorator sits outside the cap: {names[:i]}"
|
||||||
|
assert "log_requests" in names[:i], "the access log must stay outside, so a 413 is logged"
|
||||||
|
assert "setup_gate" in names[i + 1 :], "the setup gate must not run for an oversized body"
|
||||||
|
assert "SessionMiddleware" in names[i + 1 :]
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_screenshot_ceiling_matches_the_cap_the_route_itself_enforces():
|
||||||
|
assert debugsession.SCREENSHOT_MAX_BYTES > DEFAULT_MAX_BODY_BYTES
|
||||||
|
assert (
|
||||||
|
bodylimit.ROUTE_MAX_BODY_BYTES[("POST", "/api/debug-sessions/{sid}/screenshots")]
|
||||||
|
== debugsession.SCREENSHOT_MAX_BYTES
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --- the real application -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_global_refusal_carries_the_baseline_security_headers():
|
||||||
|
"""The 413 is a response like any other, so it has to be dressed like one. The middleware sits
|
||||||
|
INSIDE the security-header and CORS layers for exactly this reason: a refusal a browser drops
|
||||||
|
for a missing header is indistinguishable from a network failure, and the user would be shown a
|
||||||
|
generic error instead of the translated `too_large`.
|
||||||
|
|
||||||
|
A bare TestClient on purpose, not the `client` fixture: this is the only test that exercises the
|
||||||
|
middleware ORDER, and that fixture is DB-gated — so on a host without Postgres the one assertion
|
||||||
|
holding the move would have skipped while the file still reported green. The refusal never
|
||||||
|
reaches a database; it is answered above the router.
|
||||||
|
"""
|
||||||
|
from app.main import app
|
||||||
|
|
||||||
|
r = TestClient(app).post(
|
||||||
|
"/auth/password-login", content=OVER, headers={"content-type": "text/plain"}
|
||||||
|
)
|
||||||
|
assert r.status_code == 413
|
||||||
|
assert r.headers.get("x-content-type-options") == "nosniff"
|
||||||
|
assert "referrer-policy" in r.headers
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_login_route_is_refused_before_a_single_dependency_runs(client, monkeypatch):
|
||||||
|
"""`/auth/password-login` is the reason SEC-20 exists: it is anonymous, it declares a body, and
|
||||||
|
FastAPI reads that body in full before the first dependency. `_login_limiter.allow` is the very
|
||||||
|
first statement of the handler, so it is the sharpest available proof that nothing ran."""
|
||||||
|
from app.routes import auth as auth_routes
|
||||||
|
|
||||||
|
reached = False
|
||||||
|
|
||||||
|
def _record(*args, **kwargs):
|
||||||
|
nonlocal reached
|
||||||
|
reached = True
|
||||||
|
return True
|
||||||
|
|
||||||
|
monkeypatch.setattr(auth_routes._login_limiter, "allow", _record)
|
||||||
|
|
||||||
|
r = client.post("/auth/password-login", content=OVER, headers={"content-type": "text/plain"})
|
||||||
|
assert r.status_code == 413
|
||||||
|
assert not reached, "the handler ran — the cap is inside the router instead of ahead of it"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_normal_login_body_still_reaches_the_handler(client, monkeypatch):
|
||||||
|
"""The mutation check for the test above. If the cap refused every body, the 413 test would
|
||||||
|
pass while the app was broken; this one would not."""
|
||||||
|
from app.routes import auth as auth_routes
|
||||||
|
|
||||||
|
reached = False
|
||||||
|
|
||||||
|
def _record(*args, **kwargs):
|
||||||
|
nonlocal reached
|
||||||
|
reached = True
|
||||||
|
return True
|
||||||
|
|
||||||
|
monkeypatch.setattr(auth_routes._login_limiter, "allow", _record)
|
||||||
|
|
||||||
|
r = client.post("/auth/password-login", json={"email": "nobody@example.com", "password": "x"})
|
||||||
|
assert r.status_code == 401
|
||||||
|
assert reached
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_global_refusal_is_byte_identical_to_the_per_route_one(db, as_user, make_user):
|
||||||
|
"""Two shapes for one fact is how the `cancelled`/`canceled` bug happened. The log sink caps
|
||||||
|
itself with `capped_body`; its 413 and the middleware's must be the same bytes.
|
||||||
|
|
||||||
|
Signed in on purpose: `current_user` resolves before the body dependency, so an anonymous call
|
||||||
|
would be answered 401 and never reach the per-route cap this compares against. The body is over
|
||||||
|
the SINK's 512 KiB but under the global 1 MiB, so it is `capped_body` that refuses it.
|
||||||
|
"""
|
||||||
|
user = make_user()
|
||||||
|
db.commit()
|
||||||
|
over_the_sink = b"x" * (diagnostics_routes.BODY_MAX_BYTES + 1)
|
||||||
|
assert len(over_the_sink) < DEFAULT_MAX_BODY_BYTES
|
||||||
|
r = as_user(user).post(
|
||||||
|
"/api/diagnostics/events",
|
||||||
|
content=over_the_sink,
|
||||||
|
headers={"content-type": "application/json"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 413
|
||||||
|
# RAW BYTES, not parsed JSON: the two used to differ by the two spaces `json.dumps` puts in by
|
||||||
|
# default and `JSONResponse` does not, and a structural comparison could not see it — which is
|
||||||
|
# the only kind of drift the docblock's promise was ever at risk from.
|
||||||
|
assert r.content == bodylimit.TOO_LARGE_BODY
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
"""A channel's metadata is fetched again once it is stale, not only the first time it is seen.
|
||||||
|
|
||||||
|
Banners, avatars, descriptions and counts change on YouTube, and an old banner URL stops resolving:
|
||||||
|
a channel that kept the metadata of the day it was first imported showed a dead banner forever. The
|
||||||
|
subscription import and the channel page both refresh what is older than `DETAILS_MAX_AGE`, and
|
||||||
|
leave fresh metadata alone (each refresh spends quota). DB lane.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.models import Channel, Subscription
|
||||||
|
from app.routes import channels
|
||||||
|
from app.sync import subscriptions
|
||||||
|
from app.sync.subscriptions import DETAILS_MAX_AGE, import_subscriptions
|
||||||
|
|
||||||
|
NOW = datetime.now(timezone.utc)
|
||||||
|
FRESH = NOW - timedelta(days=1)
|
||||||
|
STALE = NOW - DETAILS_MAX_AGE - timedelta(hours=1)
|
||||||
|
|
||||||
|
|
||||||
|
def _item(cid: str) -> dict:
|
||||||
|
return {
|
||||||
|
"id": cid,
|
||||||
|
"snippet": {"title": f"{cid} renamed", "publishedAt": "2010-01-01T00:00:00Z"},
|
||||||
|
"brandingSettings": {"image": {"bannerExternalUrl": f"https://yt3.example/{cid}-new"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _youtube(asked: list[list[str]], subscribed: list[str], gone: tuple[str, ...] = ()):
|
||||||
|
class _Client:
|
||||||
|
def __init__(self, db, user):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *exc):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def iter_subscriptions(self):
|
||||||
|
for cid in subscribed:
|
||||||
|
yield {"channel_id": cid, "title": cid, "yt_subscription_id": f"sub-{cid}"}
|
||||||
|
|
||||||
|
def get_channels(self, ids):
|
||||||
|
asked.append(sorted(ids))
|
||||||
|
# A terminated / deleted channel is simply absent from `channels.list`'s answer.
|
||||||
|
return [_item(cid) for cid in ids if cid not in gone]
|
||||||
|
|
||||||
|
return _Client
|
||||||
|
|
||||||
|
|
||||||
|
def _channel(db, cid: str, synced: datetime | None) -> None:
|
||||||
|
db.add(
|
||||||
|
Channel(
|
||||||
|
id=cid,
|
||||||
|
title=cid,
|
||||||
|
banner_url=f"https://yt3.example/{cid}-old",
|
||||||
|
published_at=datetime(2010, 1, 1, tzinfo=timezone.utc),
|
||||||
|
details_synced_at=synced,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_import_refreshes_stale_and_missing_details_and_leaves_fresh_ones(
|
||||||
|
db, make_user, monkeypatch
|
||||||
|
):
|
||||||
|
user = make_user()
|
||||||
|
_channel(db, "UCfresh", FRESH)
|
||||||
|
_channel(db, "UCstale", STALE)
|
||||||
|
_channel(db, "UCnever", None)
|
||||||
|
db.flush()
|
||||||
|
for cid in ("UCfresh", "UCstale", "UCnever"):
|
||||||
|
db.add(Subscription(user_id=user.id, channel_id=cid))
|
||||||
|
db.commit()
|
||||||
|
asked: list[list[str]] = []
|
||||||
|
monkeypatch.setattr(
|
||||||
|
subscriptions, "YouTubeClient", _youtube(asked, ["UCfresh", "UCstale", "UCnever"])
|
||||||
|
)
|
||||||
|
|
||||||
|
result = import_subscriptions(db, user)
|
||||||
|
|
||||||
|
assert asked == [["UCnever", "UCstale"]]
|
||||||
|
assert result["channels_detailed"] == 2
|
||||||
|
stale = db.get(Channel, "UCstale")
|
||||||
|
assert stale is not None
|
||||||
|
assert stale.banner_url == "https://yt3.example/UCstale-new"
|
||||||
|
assert stale.details_synced_at is not None and stale.details_synced_at > STALE
|
||||||
|
fresh = db.get(Channel, "UCfresh")
|
||||||
|
assert fresh is not None and fresh.banner_url == "https://yt3.example/UCfresh-old"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(("synced", "refreshed"), [(STALE, True), (FRESH, False)])
|
||||||
|
def test_the_channel_page_refreshes_a_stale_channel_only(
|
||||||
|
db, make_user, monkeypatch, synced, refreshed
|
||||||
|
):
|
||||||
|
user = make_user()
|
||||||
|
_channel(db, "UCpage", synced)
|
||||||
|
db.commit()
|
||||||
|
asked: list[list[str]] = []
|
||||||
|
monkeypatch.setattr(channels, "YouTubeClient", _youtube(asked, []))
|
||||||
|
|
||||||
|
body = channels.channel_detail("UCpage", user=user, db=db)
|
||||||
|
|
||||||
|
assert asked == ([["UCpage"]] if refreshed else [])
|
||||||
|
suffix = "new" if refreshed else "old"
|
||||||
|
assert body["banner_url"] == f"https://yt3.example/UCpage-{suffix}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_channel_youtube_no_longer_returns_is_not_asked_for_on_every_view(
|
||||||
|
db, make_user, monkeypatch
|
||||||
|
):
|
||||||
|
"""A terminated channel is missing from `channels.list`'s answer, so nothing was stamped and the
|
||||||
|
channel page asked for it again (1 quota unit) on every single view. The miss is stamped like a
|
||||||
|
fetch, so the next ask waits for `DETAILS_MAX_AGE` like any other channel."""
|
||||||
|
user = make_user()
|
||||||
|
db.add(Channel(id="UCgone", title="UCgone"))
|
||||||
|
db.commit()
|
||||||
|
asked: list[list[str]] = []
|
||||||
|
monkeypatch.setattr(channels, "YouTubeClient", _youtube(asked, [], gone=("UCgone",)))
|
||||||
|
|
||||||
|
channels.channel_detail("UCgone", user=user, db=db)
|
||||||
|
channels.channel_detail("UCgone", user=user, db=db)
|
||||||
|
|
||||||
|
assert asked == [["UCgone"]]
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_import_stamps_a_channel_youtube_no_longer_returns(db, make_user, monkeypatch):
|
||||||
|
user = make_user()
|
||||||
|
_channel(db, "UCgone", STALE)
|
||||||
|
db.flush()
|
||||||
|
db.add(Subscription(user_id=user.id, channel_id="UCgone"))
|
||||||
|
db.commit()
|
||||||
|
asked: list[list[str]] = []
|
||||||
|
monkeypatch.setattr(
|
||||||
|
subscriptions, "YouTubeClient", _youtube(asked, ["UCgone"], gone=("UCgone",))
|
||||||
|
)
|
||||||
|
|
||||||
|
import_subscriptions(db, user)
|
||||||
|
import_subscriptions(db, user)
|
||||||
|
|
||||||
|
assert asked == [["UCgone"]]
|
||||||
@@ -68,6 +68,7 @@ def test_raising_the_level_sets_the_servers_expiry_whatever_the_client_sends(db,
|
|||||||
"level": "INFO",
|
"level": "INFO",
|
||||||
"until": None,
|
"until": None,
|
||||||
"retention_days": 7,
|
"retention_days": 7,
|
||||||
|
"session_minutes": 60,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -82,6 +83,7 @@ def test_a_lapsed_level_reads_as_the_base_in_me(db, client, make_user):
|
|||||||
"level": "INFO",
|
"level": "INFO",
|
||||||
"until": None,
|
"until": None,
|
||||||
"retention_days": 7,
|
"retention_days": 7,
|
||||||
|
"session_minutes": 60,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -58,3 +58,27 @@ def test_the_commit_check_counts_only_the_root_transaction(db):
|
|||||||
next(request)
|
next(request)
|
||||||
assert db.info["unfinished"] == ["POST /probe"]
|
assert db.info["unfinished"] == ["POST /probe"]
|
||||||
db.info["unfinished"].clear() # recorded as it should be — not this test's failure
|
db.info["unfinished"].clear() # recorded as it should be — not this test's failure
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_request_that_raises_leaves_nothing_it_did_not_commit(db):
|
||||||
|
"""Production closes a failing request's session, which rolls back whatever it flushed and did
|
||||||
|
not commit; the shared test session must do the same, or a later read in the test sees a write
|
||||||
|
production would have lost (E16: the tracker's issue link, kept only by an explicit commit in
|
||||||
|
the handler's failure path). Driven directly, as a request would drive the dependency."""
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import select
|
||||||
|
from starlette.requests import HTTPConnection
|
||||||
|
|
||||||
|
from app.models import User
|
||||||
|
|
||||||
|
conn = HTTPConnection(
|
||||||
|
{"type": "http", "method": "POST", "path": "/probe", "headers": [], "query_string": b""}
|
||||||
|
)
|
||||||
|
request = db.info["request"](conn)
|
||||||
|
session = next(request)
|
||||||
|
session.add(User(email="rolled-back-probe@example.com"))
|
||||||
|
session.flush()
|
||||||
|
with pytest.raises(RuntimeError):
|
||||||
|
request.throw(RuntimeError("the handler failed"))
|
||||||
|
assert db.scalar(select(User).where(User.email == "rolled-back-probe@example.com")) is None
|
||||||
|
assert db.info["unfinished"] == [] # a failing request is rolled back, not reported
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
"""E28 S3e2 — adding a video AT a position: the list page's "Undo" of a removal.
|
||||||
|
|
||||||
|
The row has to come back where it was in ONE request. The two-call shape (append, then send the
|
||||||
|
whole order) leaves it at the end of the list whenever the second call fails, and the reorder would
|
||||||
|
also overwrite any change another tab made in between. Positions can have gaps after removals, so
|
||||||
|
the position is an index over the ORDERED rows, and the list is renumbered around it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from app.models import Channel, Playlist, PlaylistItem, Video
|
||||||
|
|
||||||
|
|
||||||
|
def _seed(db, user, ids, *, positions=None, **kw):
|
||||||
|
db.add(Channel(id="UCtest", title="Test"))
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
for v in [*ids, "vX"]:
|
||||||
|
db.add(Video(id=v, channel_id="UCtest", published_at=now))
|
||||||
|
pl = Playlist(user_id=user.id, name="L", **kw)
|
||||||
|
db.add(pl)
|
||||||
|
db.flush()
|
||||||
|
for i, vid in enumerate(ids):
|
||||||
|
db.add(PlaylistItem(playlist_id=pl.id, video_id=vid, position=(positions or range(99))[i]))
|
||||||
|
db.commit()
|
||||||
|
return pl
|
||||||
|
|
||||||
|
|
||||||
|
def _ids(db, pl_id):
|
||||||
|
db.expire_all()
|
||||||
|
return [
|
||||||
|
r.video_id
|
||||||
|
for r in db.execute(
|
||||||
|
PlaylistItem.__table__.select()
|
||||||
|
.where(PlaylistItem.playlist_id == pl_id)
|
||||||
|
.order_by(PlaylistItem.position)
|
||||||
|
).all()
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _add(client, pl_id, body):
|
||||||
|
return client.post(f"/api/playlists/{pl_id}/items", json=body)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_position_puts_the_video_back_in_its_place(db, make_user, as_user):
|
||||||
|
user = make_user()
|
||||||
|
pl = _seed(db, user, ["a", "b", "c"])
|
||||||
|
r = _add(as_user(user), pl.id, {"video_id": "vX", "position": 1})
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert _ids(db, pl.id) == ["a", "vX", "b", "c"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_position_counts_rows_not_stored_numbers(db, make_user, as_user):
|
||||||
|
# A removal leaves a gap (0, 5, 9): index 2 is before the THIRD row, not "position 2".
|
||||||
|
user = make_user()
|
||||||
|
pl = _seed(db, user, ["a", "b", "c"], positions=[0, 5, 9])
|
||||||
|
_add(as_user(user), pl.id, {"video_id": "vX", "position": 2})
|
||||||
|
assert _ids(db, pl.id) == ["a", "b", "vX", "c"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_position_past_the_end_appends(db, make_user, as_user):
|
||||||
|
user = make_user()
|
||||||
|
pl = _seed(db, user, ["a", "b"])
|
||||||
|
_add(as_user(user), pl.id, {"video_id": "vX", "position": 40})
|
||||||
|
assert _ids(db, pl.id) == ["a", "b", "vX"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_position_still_appends(db, make_user, as_user):
|
||||||
|
user = make_user()
|
||||||
|
pl = _seed(db, user, ["a", "b"])
|
||||||
|
_add(as_user(user), pl.id, {"video_id": "vX"})
|
||||||
|
assert _ids(db, pl.id) == ["a", "b", "vX"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_video_already_in_the_list_does_not_move(db, make_user, as_user):
|
||||||
|
# The add is idempotent; a position must not turn it into a reorder.
|
||||||
|
user = make_user()
|
||||||
|
pl = _seed(db, user, ["a", "b", "c"])
|
||||||
|
_add(as_user(user), pl.id, {"video_id": "c", "position": 0})
|
||||||
|
assert _ids(db, pl.id) == ["a", "b", "c"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_negative_position_is_refused(db, make_user, as_user):
|
||||||
|
user = make_user()
|
||||||
|
pl = _seed(db, user, ["a"])
|
||||||
|
r = _add(as_user(user), pl.id, {"video_id": "vX", "position": -1})
|
||||||
|
assert r.status_code == 422
|
||||||
|
assert _ids(db, pl.id) == ["a"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_putting_a_removed_video_back_leaves_a_linked_list_clean(db, make_user, as_user):
|
||||||
|
# Remove + undo is no change at all: a YouTube-linked list must not stay "unsynced" after it.
|
||||||
|
user = make_user()
|
||||||
|
pl = _seed(db, user, ["a", "b", "c"], yt_playlist_id="PLx")
|
||||||
|
client = as_user(user)
|
||||||
|
from app.sync.playlists import current_fingerprint
|
||||||
|
|
||||||
|
pl.synced_fingerprint = current_fingerprint(db, pl)
|
||||||
|
pl.dirty = False
|
||||||
|
db.commit()
|
||||||
|
client.delete(f"/api/playlists/{pl.id}/items/b")
|
||||||
|
db.expire_all()
|
||||||
|
assert db.get(Playlist, pl.id).dirty is True
|
||||||
|
_add(client, pl.id, {"video_id": "b", "position": 1})
|
||||||
|
db.expire_all()
|
||||||
|
assert db.get(Playlist, pl.id).dirty is False
|
||||||
@@ -13,6 +13,9 @@ Two properties are load-bearing and neither is visible from the happy path alone
|
|||||||
|
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy.exc import OperationalError
|
||||||
|
|
||||||
from app.models import Channel, Playlist, PlaylistItem, Video
|
from app.models import Channel, Playlist, PlaylistItem, Video
|
||||||
|
|
||||||
|
|
||||||
@@ -269,12 +272,12 @@ def test_a_target_that_already_had_it_is_not_recently_changed(db, make_user, as_
|
|||||||
assert db.get(Playlist, dst.id).updated_at == old
|
assert db.get(Playlist, dst.id).updated_at == old
|
||||||
|
|
||||||
|
|
||||||
def test_a_concurrent_add_to_the_target_lands_as_the_already_present_move(
|
def test_no_add_can_land_in_the_target_while_a_move_holds_it(db, make_user):
|
||||||
db, make_user, as_user, monkeypatch
|
"""The already-in-target check is a read; it holds because the move locks both lists first and
|
||||||
):
|
an insert into the target — another tab's add, a YouTube merge — needs the target row for its
|
||||||
"""The already-in-target check is a read, not a lock: another tab's add (or the scheduler's
|
foreign key, so it waits for the move's commit instead of hitting uq_playlist_video under it."""
|
||||||
YouTube merge) can commit the same video to the target in between, and the re-parent then
|
from sqlalchemy import text
|
||||||
hits uq_playlist_video. That is the idempotent outcome arriving late — not a raw 500."""
|
|
||||||
from app.db import SessionLocal
|
from app.db import SessionLocal
|
||||||
from app.routes import playlists as plroutes
|
from app.routes import playlists as plroutes
|
||||||
|
|
||||||
@@ -282,16 +285,10 @@ def test_a_concurrent_add_to_the_target_lands_as_the_already_present_move(
|
|||||||
_seed_videos(db, ["v1"])
|
_seed_videos(db, ["v1"])
|
||||||
src = _playlist(db, user, "Source", ["v1"])
|
src = _playlist(db, user, "Source", ["v1"])
|
||||||
dst = _playlist(db, user, "Target", [])
|
dst = _playlist(db, user, "Target", [])
|
||||||
real = plroutes._next_item_position
|
plroutes.lock_playlists(db, src.id, dst.id) # what the move holds until its commit
|
||||||
|
with SessionLocal() as other:
|
||||||
def add_first(session, playlist_id):
|
other.execute(text("SET LOCAL lock_timeout = '300ms'"))
|
||||||
with SessionLocal() as other: # the other tab, committing between check and re-parent
|
other.add(PlaylistItem(playlist_id=dst.id, video_id="v1", position=0))
|
||||||
other.add(PlaylistItem(playlist_id=dst.id, video_id="v1", position=0))
|
with pytest.raises(OperationalError, match="lock timeout"):
|
||||||
other.commit()
|
other.flush()
|
||||||
return real(session, playlist_id)
|
db.rollback()
|
||||||
|
|
||||||
monkeypatch.setattr(plroutes, "_next_item_position", add_first)
|
|
||||||
assert _move(as_user(user), src.id, "v1", dst.id).status_code == 200
|
|
||||||
db.expire_all()
|
|
||||||
assert _ids(db, src.id) == []
|
|
||||||
assert _ids(db, dst.id) == ["v1"]
|
|
||||||
|
|||||||
@@ -0,0 +1,262 @@
|
|||||||
|
"""E28 review — every request that reads a playlist's item positions and writes new ones locks the
|
||||||
|
playlist row first.
|
||||||
|
|
||||||
|
Without it two adds (two tabs; the rail and the page) both read the same `max(position)` and land
|
||||||
|
on one position, and a reorder racing an add collides with it — the list's order between the two
|
||||||
|
rows is then whatever the planner returns. A true two-connection race is not reproducible on
|
||||||
|
demand, so the test holds the mechanism: the parent row is taken `FOR UPDATE` by each such route.
|
||||||
|
A Plex reorder that omits a row (added in another tab meanwhile) must not collide with it either.
|
||||||
|
|
||||||
|
Round 2: the lock also comes FIRST — before the request reads or writes a single item row. A remove
|
||||||
|
that deleted an item and then touched the playlist beside a reorder that went the other way was a
|
||||||
|
deadlock (a 500 for one of them), and an add that checked "already there?" before the lock let two
|
||||||
|
adds of one video both insert (a 500 on the unique key).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import event
|
||||||
|
|
||||||
|
from app.models import (
|
||||||
|
Channel,
|
||||||
|
Playlist,
|
||||||
|
PlaylistItem,
|
||||||
|
PlexItem,
|
||||||
|
PlexLibrary,
|
||||||
|
PlexPlaylist,
|
||||||
|
PlexPlaylistItem,
|
||||||
|
Video,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def locks(db):
|
||||||
|
"""The tables each request took a row lock on (`SELECT … FROM <t> … FOR UPDATE`)."""
|
||||||
|
seen: list[str] = []
|
||||||
|
|
||||||
|
def _record(conn, cursor, statement, *_):
|
||||||
|
if "FOR UPDATE" in statement:
|
||||||
|
seen.extend(re.findall(r"FROM\s+(\w+)", statement)[:1])
|
||||||
|
|
||||||
|
engine = db.get_bind()
|
||||||
|
event.listen(engine, "before_cursor_execute", _record)
|
||||||
|
yield seen
|
||||||
|
event.remove(engine, "before_cursor_execute", _record)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def statements(db):
|
||||||
|
"""Every statement a request ran, in order."""
|
||||||
|
seen: list[str] = []
|
||||||
|
|
||||||
|
def _record(conn, cursor, statement, *_):
|
||||||
|
seen.append(" ".join(statement.split()))
|
||||||
|
|
||||||
|
engine = db.get_bind()
|
||||||
|
event.listen(engine, "before_cursor_execute", _record)
|
||||||
|
yield seen
|
||||||
|
event.remove(engine, "before_cursor_execute", _record)
|
||||||
|
|
||||||
|
|
||||||
|
def _locked_before_items(seen: list[str], parent: str, items: str) -> str | None:
|
||||||
|
"""The first statement touching `items` must come after a `FOR UPDATE` of `parent`; returns
|
||||||
|
that lock statement (None when it came too late or never)."""
|
||||||
|
lock = None
|
||||||
|
for st in seen:
|
||||||
|
if lock is None and "FOR UPDATE" in st and re.search(rf"FROM {parent}\b", st):
|
||||||
|
lock = st
|
||||||
|
if re.search(rf"\b{items}\b", st):
|
||||||
|
return lock
|
||||||
|
return lock
|
||||||
|
|
||||||
|
|
||||||
|
def _yt(db, user):
|
||||||
|
db.add(Channel(id="UCtest", title="Test"))
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
for v in ("v1", "v2", "v3"):
|
||||||
|
db.add(Video(id=v, channel_id="UCtest", published_at=now))
|
||||||
|
pl = Playlist(user_id=user.id, name="L")
|
||||||
|
other = Playlist(user_id=user.id, name="M")
|
||||||
|
db.add_all([pl, other])
|
||||||
|
db.flush()
|
||||||
|
db.add(PlaylistItem(playlist_id=pl.id, video_id="v1", position=0))
|
||||||
|
db.commit()
|
||||||
|
return pl.id, other.id
|
||||||
|
|
||||||
|
|
||||||
|
def _plex(db, user):
|
||||||
|
lib = PlexLibrary(plex_key="p1", title="Movies", kind="movie", enabled=True)
|
||||||
|
db.add(lib)
|
||||||
|
db.flush()
|
||||||
|
items = [PlexItem(rating_key=rk, library_id=lib.id, kind="movie", title=rk) for rk in "abcd"]
|
||||||
|
db.add_all(items)
|
||||||
|
pl = PlexPlaylist(user_id=user.id, title="L")
|
||||||
|
db.add(pl)
|
||||||
|
db.flush()
|
||||||
|
for i, it in enumerate(items[:3]):
|
||||||
|
db.add(PlexPlaylistItem(playlist_id=pl.id, item_id=it.id, position=i))
|
||||||
|
db.commit()
|
||||||
|
return pl.id
|
||||||
|
|
||||||
|
|
||||||
|
YT = Playlist.__table__.name
|
||||||
|
PLEX = PlexPlaylist.__table__.name
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"call",
|
||||||
|
[
|
||||||
|
lambda c, pid, other: c.post(f"/api/playlists/{pid}/items", json={"video_id": "v2"}),
|
||||||
|
lambda c, pid, other: c.post(
|
||||||
|
f"/api/playlists/{pid}/items", json={"video_id": "v2", "position": 0}
|
||||||
|
),
|
||||||
|
lambda c, pid, other: c.post("/api/playlists/watch-later", json={"video_id": "v2"}),
|
||||||
|
lambda c, pid, other: c.post(
|
||||||
|
f"/api/playlists/{pid}/items/v1/move", json={"target_playlist_id": other}
|
||||||
|
),
|
||||||
|
lambda c, pid, other: c.put(f"/api/playlists/{pid}/order", json={"video_ids": ["v1"]}),
|
||||||
|
],
|
||||||
|
ids=["add", "add-at", "watch-later", "move", "reorder"],
|
||||||
|
)
|
||||||
|
def test_a_youtube_playlist_write_locks_the_playlist(db, make_user, as_user, locks, call):
|
||||||
|
user = make_user()
|
||||||
|
pid, other = _yt(db, user)
|
||||||
|
r = call(as_user(user), pid, other)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert YT in locks
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"call",
|
||||||
|
[
|
||||||
|
lambda c, pid: c.post(f"/api/plex/playlists/{pid}/items", json={"item_rating_key": "d"}),
|
||||||
|
lambda c, pid: c.post(
|
||||||
|
f"/api/plex/playlists/{pid}/items/bulk", json={"item_rating_keys": ["d"]}
|
||||||
|
),
|
||||||
|
lambda c, pid: c.post(
|
||||||
|
f"/api/plex/playlists/{pid}/items/bulk",
|
||||||
|
json={"item_rating_keys": ["d"], "positions": [0]},
|
||||||
|
),
|
||||||
|
lambda c, pid: c.put(
|
||||||
|
f"/api/plex/playlists/{pid}/order", json={"item_rating_keys": ["c", "b", "a"]}
|
||||||
|
),
|
||||||
|
],
|
||||||
|
ids=["add", "bulk", "bulk-at", "reorder"],
|
||||||
|
)
|
||||||
|
def test_a_plex_playlist_write_locks_the_playlist(db, make_user, as_user, locks, call):
|
||||||
|
user = make_user()
|
||||||
|
pid = _plex(db, user)
|
||||||
|
r = call(as_user(user), pid)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert PLEX in locks
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_plex_reorder_that_omits_a_row_does_not_collide_with_it(db, make_user, as_user):
|
||||||
|
"""The row "a" was added in another tab after this page loaded its order ["c", "b"]: it keeps
|
||||||
|
its place after the given rows instead of sharing position 0 with "c"."""
|
||||||
|
user = make_user()
|
||||||
|
pid = _plex(db, user)
|
||||||
|
r = as_user(user).put(f"/api/plex/playlists/{pid}/order", json={"item_rating_keys": ["c", "b"]})
|
||||||
|
assert r.status_code == 200
|
||||||
|
db.expire_all()
|
||||||
|
rows = db.query(PlexPlaylistItem).filter_by(playlist_id=pid).all()
|
||||||
|
by_id = {it.id: it.rating_key for it in db.query(PlexItem).all()}
|
||||||
|
got = sorted((r.position, by_id[r.item_id]) for r in rows)
|
||||||
|
assert got == [(0, "c"), (1, "b"), (2, "a")]
|
||||||
|
|
||||||
|
|
||||||
|
YT_ITEMS = PlaylistItem.__table__.name
|
||||||
|
PLEX_ITEMS = PlexPlaylistItem.__table__.name
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"call",
|
||||||
|
[
|
||||||
|
lambda c, pid, other: c.post(f"/api/playlists/{pid}/items", json={"video_id": "v1"}),
|
||||||
|
lambda c, pid, other: c.post(
|
||||||
|
f"/api/playlists/{pid}/items", json={"video_id": "v2", "position": 0}
|
||||||
|
),
|
||||||
|
lambda c, pid, other: c.delete(f"/api/playlists/{pid}/items/v1"),
|
||||||
|
lambda c, pid, other: c.post(
|
||||||
|
f"/api/playlists/{pid}/items/v1/move", json={"target_playlist_id": other}
|
||||||
|
),
|
||||||
|
lambda c, pid, other: c.put(f"/api/playlists/{pid}/order", json={"video_ids": ["v1"]}),
|
||||||
|
# the ORM cascade deletes the items before the list row
|
||||||
|
lambda c, pid, other: c.delete(f"/api/playlists/{pid}"),
|
||||||
|
],
|
||||||
|
ids=["add-present", "add-at", "remove", "move", "reorder", "delete"],
|
||||||
|
)
|
||||||
|
def test_a_youtube_item_write_locks_the_playlist_before_any_item(
|
||||||
|
db, make_user, as_user, statements, call
|
||||||
|
):
|
||||||
|
user = make_user()
|
||||||
|
pid, other = _yt(db, user)
|
||||||
|
c = as_user(user)
|
||||||
|
statements.clear()
|
||||||
|
r = call(c, pid, other)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert _locked_before_items(statements, YT, YT_ITEMS), statements
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_move_locks_both_lists_in_one_ordered_statement(db, make_user, as_user, statements):
|
||||||
|
"""Two moves A→B and B→A each locking "its" list first would lock crosswise."""
|
||||||
|
user = make_user()
|
||||||
|
pid, other = _yt(db, user)
|
||||||
|
statements.clear()
|
||||||
|
r = as_user(user).post(
|
||||||
|
f"/api/playlists/{pid}/items/v1/move", json={"target_playlist_id": other}
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
lock = _locked_before_items(statements, YT, YT_ITEMS)
|
||||||
|
assert lock and "ORDER BY" in lock, lock
|
||||||
|
# BOTH lists in that one statement: one bound id per list in its IN (…) (a single-id lock
|
||||||
|
# renders the same IN and ORDER BY)
|
||||||
|
listed = re.search(r" IN \((.*?)\) ORDER BY", lock)
|
||||||
|
assert listed and len(re.findall(r"%\(\w+\)s", listed.group(1))) == 2, lock
|
||||||
|
|
||||||
|
|
||||||
|
def test_watch_later_add_and_remove_lock_it_before_any_item(db, make_user, as_user, statements):
|
||||||
|
user = make_user()
|
||||||
|
_yt(db, user)
|
||||||
|
c = as_user(user)
|
||||||
|
assert c.post("/api/playlists/watch-later", json={"video_id": "v2"}).status_code == 200
|
||||||
|
statements.clear()
|
||||||
|
assert c.post("/api/playlists/watch-later", json={"video_id": "v2"}).status_code == 200
|
||||||
|
assert _locked_before_items(statements, YT, YT_ITEMS), statements
|
||||||
|
statements.clear()
|
||||||
|
assert c.delete("/api/playlists/watch-later/v2").status_code == 200
|
||||||
|
assert _locked_before_items(statements, YT, YT_ITEMS), statements
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_rename_locks_the_playlist(db, make_user, as_user, locks):
|
||||||
|
"""A rename moves `dirty`, which the scheduled mirror refresh re-reads under this lock."""
|
||||||
|
user = make_user()
|
||||||
|
pid, _ = _yt(db, user)
|
||||||
|
r = as_user(user).patch(f"/api/playlists/{pid}", json={"name": "New"})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert YT in locks
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"call",
|
||||||
|
[
|
||||||
|
lambda c, pid: c.delete(f"/api/plex/playlists/{pid}/items/a"),
|
||||||
|
lambda c, pid: c.post(
|
||||||
|
f"/api/plex/playlists/{pid}/items/remove-bulk", json={"item_rating_keys": ["a", "b"]}
|
||||||
|
),
|
||||||
|
lambda c, pid: c.post(f"/api/plex/playlists/{pid}/items", json={"item_rating_key": "a"}),
|
||||||
|
],
|
||||||
|
ids=["remove", "remove-bulk", "add-present"],
|
||||||
|
)
|
||||||
|
def test_a_plex_item_write_locks_the_playlist_before_any_item(
|
||||||
|
db, make_user, as_user, statements, call
|
||||||
|
):
|
||||||
|
user = make_user()
|
||||||
|
pid = _plex(db, user)
|
||||||
|
c = as_user(user)
|
||||||
|
statements.clear()
|
||||||
|
r = call(c, pid)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert _locked_before_items(statements, PLEX, PLEX_ITEMS), statements
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
"""E28 review C1 — one YouTube sync of a playlist at a time, whoever starts it.
|
||||||
|
|
||||||
|
A push reads YouTube and then writes both sides for seconds, committing step by step, so the
|
||||||
|
client's in-flight flag (one page's menus) and a row lock (gone at the first commit) both leave two
|
||||||
|
tabs, or a push and the scheduled mirror refresh, free to interleave. The claim is an advisory lock
|
||||||
|
on a connection of its own; the test holds it from outside, the way a second request would.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
from app.auth import require_write_scope
|
||||||
|
from app.db import SessionLocal
|
||||||
|
from app.models import Playlist
|
||||||
|
from app.routes import playlists as plroutes
|
||||||
|
from app.sync import playlists as plsync
|
||||||
|
|
||||||
|
|
||||||
|
def _linked(db, user, *, source="local"):
|
||||||
|
pl = Playlist(user_id=user.id, name="L", source=source, yt_playlist_id="PL123")
|
||||||
|
db.add(pl)
|
||||||
|
db.commit()
|
||||||
|
return pl
|
||||||
|
|
||||||
|
|
||||||
|
class FakeYT:
|
||||||
|
"""YouTube as far as these routes read it: two playlists, none with items."""
|
||||||
|
|
||||||
|
def __init__(self, *_):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *_):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def iter_my_playlists(self):
|
||||||
|
return [{"id": "PL123", "title": "Renamed"}, {"id": "PL999", "title": "New"}]
|
||||||
|
|
||||||
|
def iter_my_playlist_video_ids(self, _ytid):
|
||||||
|
return []
|
||||||
|
|
||||||
|
def iter_playlist_items_with_ids(self, _ytid):
|
||||||
|
return []
|
||||||
|
|
||||||
|
def get_playlist_snippet(self, _ytid):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_push_and_revert_refuse_while_the_list_is_claimed(
|
||||||
|
db, as_user, make_user, override_dep, monkeypatch
|
||||||
|
):
|
||||||
|
user = make_user()
|
||||||
|
pl = _linked(db, user)
|
||||||
|
override_dep(require_write_scope, lambda: user)
|
||||||
|
monkeypatch.setattr(plroutes, "has_read_scope", lambda u: True)
|
||||||
|
ran: list[str] = []
|
||||||
|
monkeypatch.setattr(plroutes, "YouTubeClient", FakeYT)
|
||||||
|
monkeypatch.setattr(plroutes, "plan_merge", lambda d, u, p, live: {"units_estimate": 0})
|
||||||
|
monkeypatch.setattr(plroutes, "merge_playlist", lambda d, u, p, live: ran.append("push") or {})
|
||||||
|
monkeypatch.setattr(plroutes, "repull_playlist", lambda d, u, p: ran.append("revert") or {})
|
||||||
|
c = as_user(user)
|
||||||
|
|
||||||
|
with plsync.sync_claim(db, pl.id) as free:
|
||||||
|
assert free
|
||||||
|
for path in ("push", "revert-youtube"):
|
||||||
|
r = c.post(f"/api/playlists/{pl.id}/{path}")
|
||||||
|
assert r.status_code == 409
|
||||||
|
assert r.json()["detail"] == {"code": "playlist", "reason": "sync_busy"}
|
||||||
|
# a delete too: a push mid-way would plan the deleted list as emptied (E28 round 4)
|
||||||
|
r = c.delete(f"/api/playlists/{pl.id}")
|
||||||
|
assert r.status_code == 409
|
||||||
|
assert r.json()["detail"] == {"code": "playlist", "reason": "sync_busy"}
|
||||||
|
assert ran == []
|
||||||
|
assert db.get(Playlist, pl.id) is not None
|
||||||
|
|
||||||
|
# Released on the way out: the same list syncs again, and a claim is per list.
|
||||||
|
assert c.post(f"/api/playlists/{pl.id}/push").status_code == 200
|
||||||
|
assert c.post(f"/api/playlists/{pl.id}/revert-youtube").status_code == 200
|
||||||
|
assert ran == ["push", "revert"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_claim_is_released_when_its_block_raises(db, make_user):
|
||||||
|
pl = _linked(db, make_user())
|
||||||
|
try:
|
||||||
|
with plsync.sync_claim(db, pl.id) as free:
|
||||||
|
assert free
|
||||||
|
raise RuntimeError("boom")
|
||||||
|
except RuntimeError:
|
||||||
|
pass
|
||||||
|
# Asked of pg_locks, not by claiming again: a lock leaked on a pooled connection would be granted
|
||||||
|
# again to that same connection (advisory locks are re-entrant per session).
|
||||||
|
held = db.scalar(
|
||||||
|
text("SELECT count(*) FROM pg_locks WHERE locktype = 'advisory' AND objid = :id"),
|
||||||
|
{"id": pl.id},
|
||||||
|
)
|
||||||
|
assert held == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_claim_draws_no_connection_from_the_app_pool(db, make_user):
|
||||||
|
"""A claim is held for a whole push beside the request's pooled session; drawn from the pool
|
||||||
|
too, a burst of pushes ran it dry and stalled every other request for pool_timeout."""
|
||||||
|
from app.db import engine
|
||||||
|
|
||||||
|
pl = _linked(db, make_user())
|
||||||
|
before = engine.pool.checkedout()
|
||||||
|
with plsync.sync_claim(db, pl.id) as free:
|
||||||
|
assert free
|
||||||
|
assert engine.pool.checkedout() == before
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_scheduled_refresh_skips_a_mirror_being_synced(db, make_user, monkeypatch):
|
||||||
|
user = make_user()
|
||||||
|
busy = _linked(db, user, source="youtube")
|
||||||
|
replaced: list[int] = []
|
||||||
|
|
||||||
|
monkeypatch.setattr(plsync, "has_read_scope", lambda u: True)
|
||||||
|
monkeypatch.setattr(plsync, "YouTubeClient", FakeYT)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
plsync, "_replace_items_from_youtube", lambda d, p, ids: replaced.append(p.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
with plsync.sync_claim(db, busy.id):
|
||||||
|
out = plsync.sync_user_playlists(db, user)
|
||||||
|
|
||||||
|
assert out == {"synced": 1}
|
||||||
|
assert busy.id not in replaced and len(replaced) == 1
|
||||||
|
db.refresh(busy)
|
||||||
|
assert busy.name == "L"
|
||||||
|
|
||||||
|
|
||||||
|
def test_refused_reverts_do_not_spend_the_pull_cooldown(
|
||||||
|
db, as_user, make_user, override_dep, monkeypatch
|
||||||
|
):
|
||||||
|
"""Round 2: the cooldown (three pulls per ten minutes) is checked after the claim, so clicking
|
||||||
|
Revert while a push runs is refused as busy without using up a pull."""
|
||||||
|
user = make_user()
|
||||||
|
pl = _linked(db, user)
|
||||||
|
monkeypatch.setattr(plroutes, "has_read_scope", lambda u: True)
|
||||||
|
monkeypatch.setattr(plroutes, "repull_playlist", lambda d, u, p: {})
|
||||||
|
c = as_user(user)
|
||||||
|
with plsync.sync_claim(db, pl.id):
|
||||||
|
for _ in range(4):
|
||||||
|
assert c.post(f"/api/playlists/{pl.id}/revert-youtube").status_code == 409
|
||||||
|
assert c.post(f"/api/playlists/{pl.id}/revert-youtube").status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_bulk_pull_runs_once_per_user_at_a_time(db, as_user, make_user, monkeypatch):
|
||||||
|
"""Two bulk pulls at once both saw a YouTube list unmirrored and each created its mirror."""
|
||||||
|
user = make_user()
|
||||||
|
monkeypatch.setattr(plsync, "has_read_scope", lambda u: True)
|
||||||
|
monkeypatch.setattr(plroutes, "has_read_scope", lambda u: True)
|
||||||
|
monkeypatch.setattr(plsync, "YouTubeClient", FakeYT)
|
||||||
|
monkeypatch.setattr(plsync, "_replace_items_from_youtube", lambda d, p, ids: None)
|
||||||
|
c = as_user(user)
|
||||||
|
with plsync.user_sync_claim(db, user.id):
|
||||||
|
assert plsync.sync_user_playlists(db, user) == {"synced": 0, "reason": "busy"}
|
||||||
|
for _ in range(4):
|
||||||
|
r = c.post("/api/playlists/sync-youtube")
|
||||||
|
assert r.status_code == 409
|
||||||
|
assert r.json()["detail"] == {"code": "playlist", "reason": "sync_all_busy"}
|
||||||
|
assert c.post("/api/playlists/sync-youtube").json() == {"ok": True, "synced": 2}
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_gone_mirror_is_deleted_even_when_no_list_refreshes(db, make_user, monkeypatch):
|
||||||
|
"""Every remaining mirror has unpushed edits, so nothing below the deletion commits."""
|
||||||
|
user = make_user()
|
||||||
|
gone = Playlist(user_id=user.id, name="Gone", source="youtube", yt_playlist_id="PLgone")
|
||||||
|
kept = Playlist(user_id=user.id, name="K", source="youtube", yt_playlist_id="PL123", dirty=True)
|
||||||
|
other = Playlist(
|
||||||
|
user_id=user.id, name="O", source="youtube", yt_playlist_id="PL999", dirty=True
|
||||||
|
)
|
||||||
|
db.add_all([gone, kept, other])
|
||||||
|
db.commit()
|
||||||
|
gone_id = gone.id
|
||||||
|
monkeypatch.setattr(plsync, "has_read_scope", lambda u: True)
|
||||||
|
monkeypatch.setattr(plsync, "YouTubeClient", FakeYT)
|
||||||
|
|
||||||
|
assert plsync.sync_user_playlists(db, user) == {"synced": 0}
|
||||||
|
db.rollback() # what the request's session close does to an uncommitted deletion
|
||||||
|
assert db.get(Playlist, gone_id) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_mirror_edited_during_the_pull_is_not_replaced(db, make_user, monkeypatch):
|
||||||
|
"""The loop's `dirty` is a snapshot from before the YouTube calls; a remove committed by another
|
||||||
|
request meanwhile must win over YouTube's order."""
|
||||||
|
user = make_user()
|
||||||
|
pl = _linked(db, user, source="youtube")
|
||||||
|
replaced: list[int] = []
|
||||||
|
|
||||||
|
class EditedMeanwhile(FakeYT):
|
||||||
|
def iter_my_playlists(self):
|
||||||
|
return [{"id": "PL123", "title": "Renamed"}]
|
||||||
|
|
||||||
|
def iter_my_playlist_video_ids(self, _ytid):
|
||||||
|
with SessionLocal() as other:
|
||||||
|
other.get(Playlist, pl.id).dirty = True
|
||||||
|
other.commit()
|
||||||
|
return []
|
||||||
|
|
||||||
|
monkeypatch.setattr(plsync, "has_read_scope", lambda u: True)
|
||||||
|
monkeypatch.setattr(plsync, "YouTubeClient", EditedMeanwhile)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
plsync, "_replace_items_from_youtube", lambda d, p, ids: replaced.append(p.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert plsync.sync_user_playlists(db, user) == {"synced": 0}
|
||||||
|
assert replaced == []
|
||||||
|
db.expire_all()
|
||||||
|
assert db.get(Playlist, pl.id).name == "L"
|
||||||
@@ -0,0 +1,292 @@
|
|||||||
|
"""E28 review round 3 — the YouTube sync paths take the playlist row BEFORE they touch an item, and
|
||||||
|
hold it to the commit that writes them.
|
||||||
|
|
||||||
|
Round 2 made every route edit lock the playlist first. The sync paths were left item-first (a push's
|
||||||
|
and a re-pull's item replace, a delete's cascade), so a push beside a remove could deadlock (a 500),
|
||||||
|
and the scheduled mirror refresh re-ingested inside its lock — its commit let go of the lock before
|
||||||
|
the replace, so a remove made then was undone and the list read as synced. A push also replaced the
|
||||||
|
items from a snapshot read before its YouTube calls, and marked the list clean over an edit made
|
||||||
|
during its YouTube writes. The "other tab" here is a second session that commits while the push is
|
||||||
|
talking to YouTube — when the push holds no lock, as a real one does not.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import delete, event, text
|
||||||
|
|
||||||
|
from app.db import SessionLocal
|
||||||
|
from app.models import Channel, Playlist, PlaylistItem, Video
|
||||||
|
from app.sync import playlists as pl_mod
|
||||||
|
|
||||||
|
ITEMS = PlaylistItem.__table__.name
|
||||||
|
LISTS = Playlist.__table__.name
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def trace(db):
|
||||||
|
"""Every statement and every COMMIT on the test engine, in order."""
|
||||||
|
seen: list[str] = []
|
||||||
|
|
||||||
|
def _stmt(conn, cursor, statement, *_):
|
||||||
|
seen.append(" ".join(statement.split()))
|
||||||
|
|
||||||
|
def _commit(conn):
|
||||||
|
seen.append("COMMIT")
|
||||||
|
|
||||||
|
engine = db.get_bind()
|
||||||
|
event.listen(engine, "before_cursor_execute", _stmt)
|
||||||
|
event.listen(engine, "commit", _commit)
|
||||||
|
yield seen
|
||||||
|
event.remove(engine, "before_cursor_execute", _stmt)
|
||||||
|
event.remove(engine, "commit", _commit)
|
||||||
|
|
||||||
|
|
||||||
|
def _held_to_the_item_write(seen: list[str], verb: str) -> bool:
|
||||||
|
"""The first `<verb> … playlist_items` comes after a `FOR UPDATE` of playlists, with no COMMIT
|
||||||
|
in between (a commit ends the lock)."""
|
||||||
|
locked = False
|
||||||
|
for st in seen:
|
||||||
|
if "FOR UPDATE" in st and re.search(rf"FROM {LISTS}\b", st):
|
||||||
|
locked = True
|
||||||
|
elif st == "COMMIT":
|
||||||
|
locked = False
|
||||||
|
elif st.startswith(verb) and re.search(rf"\b{ITEMS}\b", st):
|
||||||
|
return locked
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class FakeYT:
|
||||||
|
"""YouTube for the sync paths: one playlist, its items, and ids the Data API will not return."""
|
||||||
|
|
||||||
|
def __init__(self, items, *, unavailable=(), title="Mix", on_get=None, on_add=None):
|
||||||
|
self.items = [(f"it{i}", v) for i, v in enumerate(items)]
|
||||||
|
self.unavailable = set(unavailable)
|
||||||
|
self.title = title
|
||||||
|
self.get_calls = 0
|
||||||
|
self._on_get = on_get
|
||||||
|
self._on_add = on_add
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *_):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def iter_my_playlists(self):
|
||||||
|
return [{"id": "PLyt", "title": self.title}]
|
||||||
|
|
||||||
|
def iter_my_playlist_video_ids(self, _pid):
|
||||||
|
return [v for _, v in self.items]
|
||||||
|
|
||||||
|
def iter_playlist_items_with_ids(self, _pid):
|
||||||
|
return [{"item_id": i, "video_id": v} for i, v in self.items]
|
||||||
|
|
||||||
|
def get_playlist_snippet(self, _pid):
|
||||||
|
return {"title": self.title}
|
||||||
|
|
||||||
|
def get_videos(self, ids):
|
||||||
|
self.get_calls += 1
|
||||||
|
if self._on_get:
|
||||||
|
self._on_get()
|
||||||
|
return [
|
||||||
|
{"id": v, "snippet": {"channelId": "UCtest", "publishedAt": "2020-01-01T00:00:00Z"}}
|
||||||
|
for v in ids
|
||||||
|
if v not in self.unavailable
|
||||||
|
]
|
||||||
|
|
||||||
|
def create_playlist(self, title, *_, **__):
|
||||||
|
return "PLyt"
|
||||||
|
|
||||||
|
def update_playlist_title(self, _pid, title):
|
||||||
|
self.title = title
|
||||||
|
|
||||||
|
def add_playlist_item(self, _pid, vid, position=None):
|
||||||
|
if self._on_add:
|
||||||
|
self._on_add()
|
||||||
|
iid = f"it{len(self.items) + 100}"
|
||||||
|
self.items.append((iid, vid))
|
||||||
|
return iid
|
||||||
|
|
||||||
|
def delete_playlist_item(self, item_id):
|
||||||
|
self.items = [r for r in self.items if r[0] != item_id]
|
||||||
|
|
||||||
|
def move_playlist_item(self, item_id, _pid, _vid, position):
|
||||||
|
row = next(r for r in self.items if r[0] == item_id)
|
||||||
|
self.items.remove(row)
|
||||||
|
self.items.insert(position, row)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def fake_yt(monkeypatch):
|
||||||
|
def install(fake):
|
||||||
|
monkeypatch.setattr(pl_mod, "YouTubeClient", lambda *_: fake)
|
||||||
|
return fake
|
||||||
|
|
||||||
|
return install
|
||||||
|
|
||||||
|
|
||||||
|
def _seed(db, ids):
|
||||||
|
db.add(Channel(id="UCtest", title="Test"))
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
for v in ids:
|
||||||
|
db.add(Video(id=v, channel_id="UCtest", published_at=now))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _playlist(db, user, local, *, source="local", baseline=None):
|
||||||
|
pl = Playlist(
|
||||||
|
user_id=user.id,
|
||||||
|
name="Mix",
|
||||||
|
source=source,
|
||||||
|
yt_playlist_id="PLyt",
|
||||||
|
synced_video_ids=None if baseline is None else pl_mod._dump_ids(baseline),
|
||||||
|
)
|
||||||
|
db.add(pl)
|
||||||
|
db.flush()
|
||||||
|
for pos, v in enumerate(local):
|
||||||
|
db.add(PlaylistItem(playlist_id=pl.id, video_id=v, position=pos))
|
||||||
|
db.commit()
|
||||||
|
return pl
|
||||||
|
|
||||||
|
|
||||||
|
def _other_tab(pl_id, *, add=None, remove=None, rename=None, drop=False):
|
||||||
|
"""A second session's edit, committed at once — what another tab's request does. It runs on
|
||||||
|
the push's own thread: a lock the push wrongly held here would be waited on forever, so the
|
||||||
|
wait is bounded and a regression fails instead of hanging the gate."""
|
||||||
|
with SessionLocal() as s:
|
||||||
|
s.execute(text("SET LOCAL lock_timeout = '2s'"))
|
||||||
|
if drop:
|
||||||
|
s.execute(delete(Playlist).where(Playlist.id == pl_id))
|
||||||
|
if rename:
|
||||||
|
s.get(Playlist, pl_id).name = rename
|
||||||
|
if add:
|
||||||
|
pos = len(s.query(PlaylistItem).filter_by(playlist_id=pl_id).all())
|
||||||
|
s.add(PlaylistItem(playlist_id=pl_id, video_id=add, position=pos))
|
||||||
|
if remove:
|
||||||
|
s.query(PlaylistItem).filter_by(playlist_id=pl_id, video_id=remove).delete()
|
||||||
|
s.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_mirror_refresh_holds_its_lock_through_a_list_with_a_private_video(
|
||||||
|
db, make_user, fake_yt, trace
|
||||||
|
):
|
||||||
|
user = make_user()
|
||||||
|
_seed(db, ["a"])
|
||||||
|
pl = _playlist(db, user, ["a"], source="youtube")
|
||||||
|
fake = fake_yt(FakeYT(["a", "ghost"], unavailable={"ghost"}))
|
||||||
|
trace.clear()
|
||||||
|
pl_mod.pull_user_playlists(db, user)
|
||||||
|
assert _held_to_the_item_write(trace, "DELETE"), trace
|
||||||
|
# the private video is asked for once, not again under the lock
|
||||||
|
assert fake.get_calls == 1
|
||||||
|
assert pl_mod._local_video_ids(db, pl) == ["a"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_mirror_drop_locks_the_list_before_its_items(db, make_user, fake_yt, trace):
|
||||||
|
user = make_user()
|
||||||
|
_seed(db, ["a"])
|
||||||
|
pl = _playlist(db, user, ["a"], source="youtube")
|
||||||
|
pl.yt_playlist_id = "PLgone"
|
||||||
|
db.commit()
|
||||||
|
fake_yt(FakeYT([]))
|
||||||
|
trace.clear()
|
||||||
|
pl_mod.pull_user_playlists(db, user)
|
||||||
|
assert _held_to_the_item_write(trace, "DELETE"), trace
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_repull_ingests_before_its_lock_and_holds_it_to_the_replace(
|
||||||
|
db, make_user, fake_yt, trace
|
||||||
|
):
|
||||||
|
user = make_user()
|
||||||
|
_seed(db, ["a"])
|
||||||
|
pl = _playlist(db, user, ["a"])
|
||||||
|
fake = fake_yt(FakeYT(["b", "a", "ghost"], unavailable={"ghost"}, title="Renamed"))
|
||||||
|
trace.clear()
|
||||||
|
pl_mod.repull_playlist(db, user, pl)
|
||||||
|
assert _held_to_the_item_write(trace, "DELETE"), trace
|
||||||
|
assert fake.get_calls == 1
|
||||||
|
db.refresh(pl)
|
||||||
|
assert (pl.name, pl.dirty) == ("Renamed", False)
|
||||||
|
assert pl_mod._local_video_ids(db, pl) == ["b", "a"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_push_locks_the_list_before_it_replaces_the_items(db, make_user, fake_yt, trace):
|
||||||
|
user = make_user()
|
||||||
|
_seed(db, ["a", "b"])
|
||||||
|
pl = _playlist(db, user, ["a", "b"], baseline=["a"])
|
||||||
|
fake_yt(FakeYT(["a", "y"]))
|
||||||
|
trace.clear()
|
||||||
|
pl_mod.merge_playlist(db, user, pl)
|
||||||
|
assert _held_to_the_item_write(trace, "DELETE"), trace
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_add_made_while_a_push_reads_youtube_survives_it(db, make_user, fake_yt):
|
||||||
|
user = make_user()
|
||||||
|
_seed(db, ["a", "x"])
|
||||||
|
pl = _playlist(db, user, ["a"], baseline=["a"])
|
||||||
|
# 'y' is new on YouTube, so the push ingests it — and the other tab adds 'x' meanwhile.
|
||||||
|
fake = fake_yt(FakeYT(["a", "y"], on_get=lambda: _other_tab(pl.id, add="x")))
|
||||||
|
pl_mod.merge_playlist(db, user, pl)
|
||||||
|
local = pl_mod._local_video_ids(db, pl)
|
||||||
|
assert set(local) == {"a", "x", "y"}
|
||||||
|
assert "x" in [v for _, v in fake.items] # and it reached YouTube as a local add
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("linked", [True, False], ids=["linked", "first-push"])
|
||||||
|
def test_a_rename_made_during_a_push_s_youtube_writes_stays_dirty(db, make_user, fake_yt, linked):
|
||||||
|
"""The closing baseline is YouTube's title, not the one the lock re-reads: over the new name it
|
||||||
|
matched the rename, and the list read synced with YouTube still on the old title."""
|
||||||
|
user = make_user()
|
||||||
|
_seed(db, ["a", "b"])
|
||||||
|
pl = _playlist(db, user, ["a", "b"], baseline=["a"] if linked else None)
|
||||||
|
if not linked:
|
||||||
|
pl.yt_playlist_id = None
|
||||||
|
db.commit()
|
||||||
|
fake = fake_yt(
|
||||||
|
FakeYT(["a"] if linked else [], on_add=lambda: _other_tab(pl.id, rename="Mix 2"))
|
||||||
|
)
|
||||||
|
pl_mod.merge_playlist(db, user, pl)
|
||||||
|
db.refresh(pl)
|
||||||
|
assert (pl.name, fake.title) == ("Mix 2", "Mix")
|
||||||
|
assert pl.dirty is True # the rename has not reached YouTube; the list must say so
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_push_stops_at_a_list_deleted_while_it_read_youtube(db, make_user, fake_yt):
|
||||||
|
"""A delete landing mid-push left a plan over no items: every item "removed here", so the push
|
||||||
|
emptied the YouTube playlist the delete meant to keep."""
|
||||||
|
user = make_user()
|
||||||
|
_seed(db, ["a", "b"])
|
||||||
|
pl = _playlist(db, user, ["a", "b"])
|
||||||
|
# a video YouTube would not return last time, retried now — the read the delete lands in
|
||||||
|
pl.synced_video_ids = json.dumps({"ids": ["a", "b"], "unavailable": ["ghost"]})
|
||||||
|
db.commit()
|
||||||
|
fake = fake_yt(
|
||||||
|
FakeYT(
|
||||||
|
["a", "b", "ghost"], unavailable={"ghost"}, on_get=lambda: _other_tab(pl.id, drop=True)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
raised = None
|
||||||
|
try:
|
||||||
|
pl_mod.merge_playlist(db, user, pl)
|
||||||
|
except Exception as exc: # noqa: BLE001 — the regression raises too, AFTER its YouTube writes
|
||||||
|
raised = exc
|
||||||
|
assert [v for _, v in fake.items] == ["a", "b", "ghost"] # YouTube untouched
|
||||||
|
assert isinstance(raised, pl_mod.PlaylistGone) # which the route answers with a 404
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("linked", [True, False], ids=["linked", "first-push"])
|
||||||
|
def test_an_edit_made_during_a_push_s_youtube_writes_stays_dirty(db, make_user, fake_yt, linked):
|
||||||
|
user = make_user()
|
||||||
|
_seed(db, ["a", "b"])
|
||||||
|
pl = _playlist(db, user, ["a", "b"], baseline=["a"] if linked else None)
|
||||||
|
if not linked:
|
||||||
|
pl.yt_playlist_id = None
|
||||||
|
db.commit()
|
||||||
|
fake_yt(FakeYT(["a"] if linked else [], on_add=lambda: _other_tab(pl.id, remove="a")))
|
||||||
|
pl_mod.merge_playlist(db, user, pl)
|
||||||
|
db.refresh(pl)
|
||||||
|
assert pl_mod._local_video_ids(db, pl) == ["b"]
|
||||||
|
assert pl.dirty is True # the removal has not reached YouTube; the list must say so
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
"""E28 S3e2 — putting removed Plex playlist rows back WHERE THEY WERE: the list page's "Undo".
|
||||||
|
|
||||||
|
A removal can take one row, a season or a whole show, and a season's episodes need not sit together
|
||||||
|
(a reorder inside the show interleaves them), so each key carries its own index — over the ORDERED
|
||||||
|
rows, since positions have gaps after a removal. One request: an append plus a second reorder call
|
||||||
|
leaves the rows at the end whenever that call fails, and would overwrite another tab's changes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from app.models import PlexItem, PlexLibrary, PlexPlaylist, PlexPlaylistItem
|
||||||
|
|
||||||
|
|
||||||
|
def _seed(db, user, rks, *, positions=None, extra=("x", "y")):
|
||||||
|
lib = PlexLibrary(plex_key="p1", title="Movies", kind="movie", enabled=True)
|
||||||
|
db.add(lib)
|
||||||
|
db.flush()
|
||||||
|
items = {}
|
||||||
|
for rk in [*rks, *extra]:
|
||||||
|
it = PlexItem(rating_key=rk, library_id=lib.id, kind="movie", title=rk.upper())
|
||||||
|
db.add(it)
|
||||||
|
items[rk] = it
|
||||||
|
pl = PlexPlaylist(user_id=user.id, title="L")
|
||||||
|
db.add(pl)
|
||||||
|
db.flush()
|
||||||
|
for i, rk in enumerate(rks):
|
||||||
|
pos = (positions or range(99))[i]
|
||||||
|
db.add(PlexPlaylistItem(playlist_id=pl.id, item_id=items[rk].id, position=pos))
|
||||||
|
db.commit()
|
||||||
|
return pl
|
||||||
|
|
||||||
|
|
||||||
|
def _order(db, pid):
|
||||||
|
db.expire_all()
|
||||||
|
rows = db.execute(
|
||||||
|
PlexPlaylistItem.__table__.select()
|
||||||
|
.where(PlexPlaylistItem.playlist_id == pid)
|
||||||
|
.order_by(PlexPlaylistItem.position, PlexPlaylistItem.id)
|
||||||
|
).all()
|
||||||
|
by_id = {it.id: it.rating_key for it in db.query(PlexItem).all()}
|
||||||
|
return [by_id[r.item_id] for r in rows], [r.position for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def _add(client, pid, keys, positions=None):
|
||||||
|
body = {"item_rating_keys": keys}
|
||||||
|
if positions is not None:
|
||||||
|
body["positions"] = positions
|
||||||
|
return client.post(f"/api/plex/playlists/{pid}/items/bulk", json=body)
|
||||||
|
|
||||||
|
|
||||||
|
def test_one_row_goes_back_to_its_index(db, make_user, as_user):
|
||||||
|
user = make_user()
|
||||||
|
pl = _seed(db, user, ["a", "b", "c"])
|
||||||
|
r = _add(as_user(user), pl.id, ["x"], [1])
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["added"] == 1
|
||||||
|
assert _order(db, pl.id) == (["a", "x", "b", "c"], [0, 1, 2, 3])
|
||||||
|
|
||||||
|
|
||||||
|
def test_scattered_rows_go_back_to_each_of_their_indices(db, make_user, as_user):
|
||||||
|
# A season whose episodes were interleaved with another season's: removed rows at 1 and 3 of
|
||||||
|
# a, x, b, y, c. Given in any order, each lands where it stood.
|
||||||
|
user = make_user()
|
||||||
|
pl = _seed(db, user, ["a", "b", "c"])
|
||||||
|
_add(as_user(user), pl.id, ["y", "x"], [3, 1])
|
||||||
|
assert _order(db, pl.id)[0] == ["a", "x", "b", "y", "c"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_indices_count_rows_not_stored_numbers(db, make_user, as_user):
|
||||||
|
user = make_user()
|
||||||
|
pl = _seed(db, user, ["a", "b", "c"], positions=[0, 7, 20])
|
||||||
|
_add(as_user(user), pl.id, ["x"], [2])
|
||||||
|
assert _order(db, pl.id) == (["a", "b", "x", "c"], [0, 1, 2, 3])
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_index_past_the_end_appends(db, make_user, as_user):
|
||||||
|
user = make_user()
|
||||||
|
pl = _seed(db, user, ["a"])
|
||||||
|
_add(as_user(user), pl.id, ["x"], [50])
|
||||||
|
assert _order(db, pl.id)[0] == ["a", "x"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_row_already_in_the_list_does_not_move(db, make_user, as_user):
|
||||||
|
user = make_user()
|
||||||
|
pl = _seed(db, user, ["a", "b", "c"])
|
||||||
|
r = _add(as_user(user), pl.id, ["c"], [0])
|
||||||
|
assert r.json()["added"] == 0
|
||||||
|
assert _order(db, pl.id)[0] == ["a", "b", "c"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_without_positions_it_still_appends(db, make_user, as_user):
|
||||||
|
user = make_user()
|
||||||
|
pl = _seed(db, user, ["a", "b"])
|
||||||
|
_add(as_user(user), pl.id, ["y", "x"])
|
||||||
|
assert _order(db, pl.id)[0] == ["a", "b", "y", "x"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_positions_must_pair_with_the_keys(db, make_user, as_user):
|
||||||
|
user = make_user()
|
||||||
|
pl = _seed(db, user, ["a"])
|
||||||
|
assert _add(as_user(user), pl.id, ["x", "y"], [0]).status_code == 422
|
||||||
|
assert _add(as_user(user), pl.id, ["x"], [-1]).status_code == 422
|
||||||
|
assert _order(db, pl.id)[0] == ["a"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_remove_then_put_back_is_no_change(db, make_user, as_user):
|
||||||
|
user = make_user()
|
||||||
|
pl = _seed(db, user, ["a", "b", "c", "d"], extra=())
|
||||||
|
client = as_user(user)
|
||||||
|
client.post(
|
||||||
|
f"/api/plex/playlists/{pl.id}/items/remove-bulk", json={"item_rating_keys": ["b", "d"]}
|
||||||
|
)
|
||||||
|
assert _order(db, pl.id)[0] == ["a", "c"]
|
||||||
|
_add(client, pl.id, ["b", "d"], [1, 3])
|
||||||
|
assert _order(db, pl.id)[0] == ["a", "b", "c", "d"]
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
"""A sidecar subtitle in a legacy code page must render its accents, not U+FFFD.
|
||||||
|
|
||||||
|
Found live on Arrow S03E15 (a Hungarian .srt beside the .mp4): the served WebVTT held 1391 U+FFFD and
|
||||||
|
not one accented letter, because `_sidecar_vtt` decoded the bytes as UTF-8 with `errors="replace"`
|
||||||
|
while the file was Windows-1250 (ő = 0xF5, ű = 0xFB). Legacy-code-page .srt files are the norm for
|
||||||
|
Hungarian releases, and SubRip carries no charset of its own, so the stream's language is the only hint.
|
||||||
|
No Postgres: Plex and ffmpeg are stubbed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import cast
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.plex import subtitles
|
||||||
|
|
||||||
|
HU = "1\r\n00:00:01,000 --> 00:00:02,000\r\nMiért nem szóltál? Ősz, űr.\r\n"
|
||||||
|
FR = "1\r\n00:00:01,000 --> 00:00:02,000\r\nDéjà vu, garçon.\r\n"
|
||||||
|
RU = "1\r\n00:00:01,000 --> 00:00:02,000\r\nПривет, мир.\r\n"
|
||||||
|
|
||||||
|
|
||||||
|
class _Plex:
|
||||||
|
def __init__(self, raw: bytes):
|
||||||
|
self.raw = raw
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *exc):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def raw_get(self, key: str) -> bytes:
|
||||||
|
return self.raw
|
||||||
|
|
||||||
|
|
||||||
|
def _sidecar(monkeypatch, raw: bytes, lang: str | None, codec: str = "srt") -> str:
|
||||||
|
monkeypatch.setattr(subtitles, "PlexClient", lambda db: _Plex(raw))
|
||||||
|
stream = {"key": "/library/streams/1", "codec": codec, "languageCode": lang}
|
||||||
|
return subtitles._sidecar_vtt(cast(Session, None), stream)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("text", "encoding", "lang"),
|
||||||
|
[
|
||||||
|
(HU, "cp1250", "hun"), # the file that was found broken
|
||||||
|
(FR, "cp1252", "fre"), # a Western legacy file
|
||||||
|
(RU, "cp1251", "rus"), # a Cyrillic one
|
||||||
|
(HU, "utf-8", "hun"), # already UTF-8: untouched
|
||||||
|
(HU, "utf-8-sig", "hun"), # UTF-8 with a BOM
|
||||||
|
(HU, "utf-16", "hun"), # UTF-16 (carries its BOM)
|
||||||
|
],
|
||||||
|
ids=["cp1250-hu", "cp1252-fr", "cp1251-ru", "utf8", "utf8-bom", "utf16"],
|
||||||
|
)
|
||||||
|
def test_a_sidecar_srt_is_decoded_in_its_own_encoding(monkeypatch, text, encoding, lang):
|
||||||
|
vtt = _sidecar(monkeypatch, text.encode(encoding), lang)
|
||||||
|
assert "\ufffd" not in vtt
|
||||||
|
assert vtt.startswith("WEBVTT\n\n")
|
||||||
|
assert text.splitlines()[2] in vtt
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_non_srt_sidecar_reaches_ffmpeg_as_utf8(monkeypatch):
|
||||||
|
seen: dict[str, bytes] = {}
|
||||||
|
|
||||||
|
def fake_ffmpeg(src: str, sub_ord):
|
||||||
|
seen["bytes"] = Path(src).read_bytes()
|
||||||
|
return "WEBVTT\n"
|
||||||
|
|
||||||
|
monkeypatch.setattr(subtitles, "ffmpeg_to_vtt", fake_ffmpeg)
|
||||||
|
_sidecar(monkeypatch, "[Events]\nDialogue: Ősz\n".encode("cp1250"), "hun", codec="ass")
|
||||||
|
assert seen["bytes"].decode("utf-8") == "[Events]\nDialogue: Ősz\n"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_track_cached_before_the_encoding_fix_is_not_served(tmp_path, monkeypatch):
|
||||||
|
"""The cache has no eviction, so a mis-decoded track would have stayed broken for good: the key
|
||||||
|
is versioned, and a track cached under the old one is read again (once) instead of served."""
|
||||||
|
monkeypatch.setattr(subtitles, "CACHE_DIR", tmp_path)
|
||||||
|
(tmp_path / "sub_14881_1.vtt").write_text(
|
||||||
|
"WEBVTT\n\n1\n00:00:01.000 --> 00:00:02.000\nAz�rt\n", "utf-8"
|
||||||
|
)
|
||||||
|
assert subtitles.cached_track("14881", 1)[0] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_one_stray_byte_does_not_send_a_utf8_file_to_a_legacy_code_page(monkeypatch):
|
||||||
|
"""A UTF-8 release with ONE bad byte (a cut-and-paste Windows-1252 quote) failed strict UTF-8 as a
|
||||||
|
whole, and the whole file was then read as Windows-1250: every correct ő/é became mojibake. Nearly
|
||||||
|
all of it is valid UTF-8, so it stays UTF-8 and only the one byte is replaced."""
|
||||||
|
raw = (HU * 20).encode("utf-8") + b"\x93quoted\x94\r\n"
|
||||||
|
vtt = _sidecar(monkeypatch, raw, "hun")
|
||||||
|
assert "Miért nem szóltál? Ősz, űr." in vtt
|
||||||
|
assert "Miért" not in vtt
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_untagged_legacy_sidecar_is_read_as_central_european(monkeypatch):
|
||||||
|
"""No language tag: the fallback was Windows-1252, which turns a Hungarian ő/ű into õ/û."""
|
||||||
|
vtt = _sidecar(monkeypatch, HU.encode("cp1250"), None)
|
||||||
|
assert "Ősz, űr." in vtt
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_latin_serbian_sidecar_is_read_as_central_european(monkeypatch):
|
||||||
|
"""Serbian releases are overwhelmingly Latin script in Windows-1250 (like hrv/bos); read as
|
||||||
|
Windows-1251 every č/ć/š/ž/đ turned into Cyrillic noise."""
|
||||||
|
sr = "1\r\n00:00:01,000 --> 00:00:02,000\r\nŠta ćeš, đače? Čekaj.\r\n"
|
||||||
|
vtt = _sidecar(monkeypatch, sr.encode("cp1250"), "srp")
|
||||||
|
assert "Šta ćeš, đače? Čekaj." in vtt
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_track_cached_under_the_previous_decoder_is_not_served(tmp_path, monkeypatch):
|
||||||
|
"""The decoder changed again (stray-byte UTF-8, cp1250 fallbacks): the key is versioned once more
|
||||||
|
so a track the old rules mis-decoded is read again instead of served for good."""
|
||||||
|
monkeypatch.setattr(subtitles, "CACHE_DIR", tmp_path)
|
||||||
|
(tmp_path / "sub2_14881_1.vtt").write_text(
|
||||||
|
"WEBVTT\n\n1\n00:00:01.000 --> 00:00:02.000\nAzõrt\n", "utf-8"
|
||||||
|
)
|
||||||
|
assert subtitles.cached_track("14881", 1)[0] is None
|
||||||
@@ -311,8 +311,9 @@ def test_covered_ranges_coalesce_and_answer_membership():
|
|||||||
def test_a_cached_vtt_from_an_older_build_counts_as_complete(tmp_path):
|
def test_a_cached_vtt_from_an_older_build_counts_as_complete(tmp_path):
|
||||||
"""Before this change the cache only ever held whole tracks, and none of them has a sidecar.
|
"""Before this change the cache only ever held whole tracks, and none of them has a sidecar.
|
||||||
Reading one as "covers nothing" would re-extract every already-cached track after the upgrade."""
|
Reading one as "covers nothing" would re-extract every already-cached track after the upgrade."""
|
||||||
(tmp_path / "sub_98614_0.vtt").write_text(_vtt(_cue("00:01.000", "00:02.000", "x")), "utf-8")
|
key = subtitles.cache_key("98614", 0)
|
||||||
assert subtitles.read_meta("sub_98614_0") == ([[0.0, 1_000_000_000.0]], True)
|
(tmp_path / f"{key}.vtt").write_text(_vtt(_cue("00:01.000", "00:02.000", "x")), "utf-8")
|
||||||
|
assert subtitles.read_meta(key) == ([[0.0, 1_000_000_000.0]], True)
|
||||||
|
|
||||||
|
|
||||||
# --- the deep-review round: the seams between the window and the full pass ----------------------
|
# --- the deep-review round: the seams between the window and the full pass ----------------------
|
||||||
|
|||||||
@@ -18,12 +18,12 @@ _PW = "open sesame"
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def unlock_url(as_user, make_user, tmp_path, monkeypatch):
|
def unlock_url(as_user, make_user, tmp_path, monkeypatch, upload_headers):
|
||||||
monkeypatch.setattr(settings, "download_root", str(tmp_path))
|
monkeypatch.setattr(settings, "download_root", str(tmp_path))
|
||||||
monkeypatch.setattr(probemod, "probe_media", lambda _p: ("h264", "aac", 320, 240, 5))
|
monkeypatch.setattr(probemod, "probe_media", lambda _p: ("h264", "aac", 320, 240, 5))
|
||||||
owner = as_user(make_user())
|
owner = as_user(make_user())
|
||||||
job = owner.post(
|
job = owner.post(
|
||||||
"/api/downloads/upload", files={"file": ("clip.mp4", b"fake-video", "video/mp4")}
|
"/api/downloads/upload", content=b"fake-video", headers=upload_headers("clip.mp4")
|
||||||
)
|
)
|
||||||
assert job.status_code == 200, job.text
|
assert job.status_code == 200, job.text
|
||||||
link = owner.post(f"/api/downloads/{job.json()['id']}/links", json={"password": _PW})
|
link = owner.post(f"/api/downloads/{job.json()['id']}/links", json={"password": _PW})
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
"""The Python major is a contract, not a coincidence — the backend half (E27 S3).
|
||||||
|
|
||||||
|
`0.93.2` was released to fix exactly this bug on the JavaScript side: four machines built or checked
|
||||||
|
the app and none of them agreed on which Node they ran, the server gate went red for 73 of its last
|
||||||
|
90 runs, and nothing told anyone. The Python side had the same shape and nobody had looked: prod
|
||||||
|
(`Dockerfile`), the test image (`Dockerfile.test`) and CI (`check.yml`) each spelled **3.12** as an
|
||||||
|
independent literal, there was no `.python-version`, no `requires-python`, and ruff had no
|
||||||
|
`target-version` — while the dev machine had no 3.12 installed at all, only 3.13 and 3.14.
|
||||||
|
|
||||||
|
**The contract is a chain, and it is split across two test files because no single runner can see
|
||||||
|
every link.** This suite runs inside the test image, which mounts `backend/` and nothing else, so it
|
||||||
|
cannot read the repo-root Dockerfile or the workflow. It therefore holds the link it CAN see, and the
|
||||||
|
one that matters most here:
|
||||||
|
|
||||||
|
the interpreter actually running == backend/pyrightconfig.json == backend/ruff.toml
|
||||||
|
|
||||||
|
and `frontend/src/lib/pythonEngine.contract.test.ts` — which runs on the host, with the whole tree —
|
||||||
|
holds the rest of the chain:
|
||||||
|
|
||||||
|
/.python-version == pyrightconfig.json == ruff.toml == Dockerfile == Dockerfile.test == check.yml
|
||||||
|
|
||||||
|
Together they close the loop. To move the version: edit `/.python-version`, then follow the failures
|
||||||
|
in both files. Every place they name is a place that would otherwise have been forgotten.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
BACKEND = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
|
def _version_in(path: Path, pattern: str) -> tuple[int, int]:
|
||||||
|
"""The (major, minor) the given file declares. A rename or a moved line lands here — re-point
|
||||||
|
the test, the contract did not go away with it."""
|
||||||
|
text = path.read_text(encoding="utf-8")
|
||||||
|
match = re.search(pattern, text, re.MULTILINE)
|
||||||
|
assert match is not None, f"{pattern!r} not found in {path.name}"
|
||||||
|
return int(match.group(1)), int(match.group(2))
|
||||||
|
|
||||||
|
|
||||||
|
# pyrightconfig.json is JSONC (it carries comments), so it is read by regex rather than json.loads.
|
||||||
|
PINNED = _version_in(BACKEND / "pyrightconfig.json", r'"pythonVersion"\s*:\s*"(\d+)\.(\d+)"')
|
||||||
|
|
||||||
|
|
||||||
|
def test_pins_a_plausible_python_version():
|
||||||
|
"""Guards against a typo agreeing with nothing at all."""
|
||||||
|
assert PINNED >= (3, 12), PINNED
|
||||||
|
|
||||||
|
|
||||||
|
def test_this_interpreter_is_the_pinned_one():
|
||||||
|
"""The assertion that pays. A green suite on another minor does not mean the shipped image is
|
||||||
|
green — it means the suite and the image have drifted and nobody noticed yet."""
|
||||||
|
running = sys.version_info[:2]
|
||||||
|
assert running == PINNED, (
|
||||||
|
f"these tests are running on Python {running[0]}.{running[1]}, but the app is pinned to "
|
||||||
|
f"{PINNED[0]}.{PINNED[1]} (backend/pyrightconfig.json, /.python-version). The suite runs in "
|
||||||
|
f"the test image, so this failing means backend/Dockerfile.test has drifted from the pin."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_ruff_judges_the_pinned_version():
|
||||||
|
"""Unset, ruff falls back to a default that has nothing to do with us, and its syntax and
|
||||||
|
upgrade advice are then aimed at some other interpreter."""
|
||||||
|
major, minor = _version_in(BACKEND / "ruff.toml", r'^target-version\s*=\s*"py(\d)(\d+)"')
|
||||||
|
assert (major, minor) == PINNED
|
||||||
@@ -28,6 +28,8 @@ COMMITTING_HELPERS = {
|
|||||||
"commits nothing)",
|
"commits nothing)",
|
||||||
"messages.py:ensure_welcome": "the websocket push after it must announce a row the client "
|
"messages.py:ensure_welcome": "the websocket push after it must announce a row the client "
|
||||||
"can already fetch",
|
"can already fetch",
|
||||||
|
"debug_sessions.py:_end_send": "gives back the claim a send took, in a `finally`: it runs when "
|
||||||
|
"the handler is already unwinding and has no commit left to reach",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -12,15 +12,23 @@ parsing (image → file, cover-art skip, dims, duration) is covered directly by
|
|||||||
below, which stubs one level down (`run_ffprobe_json`) and exercises the real `probe_media`.
|
below, which stubs one level down (`run_ffprobe_json`) and exercises the real `probe_media`.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import ast
|
||||||
|
import asyncio
|
||||||
|
import io
|
||||||
|
import pathlib
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
from app.routes._common import capped_stream_to
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
from app.downloads import mediakind, quota, storage
|
from app.downloads import mediakind, quota, storage
|
||||||
from app.downloads import probe as probemod
|
from app.downloads import probe as probemod
|
||||||
from app.downloads import upload as uploadmod
|
from app.downloads import upload as uploadmod
|
||||||
from app.models import DownloadQuota, MediaAsset
|
from app.models import DISPLAY_TEXT_MAX, DownloadJob, DownloadQuota, MediaAsset
|
||||||
|
|
||||||
|
|
||||||
# --- pure: the kind invariant ---------------------------------------------------------------
|
# --- pure: the kind invariant ---------------------------------------------------------------
|
||||||
@@ -123,15 +131,143 @@ def test_ingest_rejects_over_footprint_cap_before_writing(db, make_user, _root,
|
|||||||
assert db.query(MediaAsset).count() == 0 # and no orphan asset row
|
assert db.query(MediaAsset).count() == 0 # and no orphan asset row
|
||||||
|
|
||||||
|
|
||||||
|
# --- route: the auth-first body (P1) --------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _counting_body(chunks: int = 4, size: int = 1024):
|
||||||
|
"""A request body that records how much of itself the server actually pulled.
|
||||||
|
|
||||||
|
httpx's ASGI transport feeds the app lazily, one `http.request` message per yielded chunk, so a
|
||||||
|
chunk that was never yielded is a chunk the app never asked for. That is the whole assertion
|
||||||
|
below: it is not "the upload was refused", it is "the refusal happened before the bytes moved".
|
||||||
|
"""
|
||||||
|
pulled: list[int] = []
|
||||||
|
|
||||||
|
def body():
|
||||||
|
for _ in range(chunks):
|
||||||
|
pulled.append(size)
|
||||||
|
yield b"x" * size
|
||||||
|
|
||||||
|
return body(), pulled
|
||||||
|
|
||||||
|
|
||||||
|
_BOUNDARY = "p1boundary"
|
||||||
|
|
||||||
|
|
||||||
|
def _counting_multipart(chunks: int = 4, size: int = 1024):
|
||||||
|
"""The same counter, wrapped in a real multipart envelope.
|
||||||
|
|
||||||
|
The content-type is the load-bearing part. Starlette's `request.form()` returns an EMPTY
|
||||||
|
FormData without touching the stream when the content-type is not multipart/urlencoded — so a
|
||||||
|
raw-body probe cannot tell "the route stopped reading strangers" apart from "there was nothing
|
||||||
|
here to parse". Only a well-formed multipart body makes the old shape actually spool.
|
||||||
|
"""
|
||||||
|
pulled: list[int] = []
|
||||||
|
head = (
|
||||||
|
f"--{_BOUNDARY}\r\n"
|
||||||
|
'Content-Disposition: form-data; name="file"; filename="clip.mp4"\r\n'
|
||||||
|
"Content-Type: application/octet-stream\r\n\r\n"
|
||||||
|
).encode()
|
||||||
|
tail = f"\r\n--{_BOUNDARY}--\r\n".encode()
|
||||||
|
|
||||||
|
def body():
|
||||||
|
pulled.append(len(head))
|
||||||
|
yield head
|
||||||
|
for _ in range(chunks):
|
||||||
|
pulled.append(size)
|
||||||
|
yield b"x" * size
|
||||||
|
pulled.append(len(tail))
|
||||||
|
yield tail
|
||||||
|
|
||||||
|
return body(), pulled
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_anonymous_upload_is_refused_before_a_byte_of_it_is_read(client):
|
||||||
|
"""P1 — the defect E27 bounded but did not close.
|
||||||
|
|
||||||
|
While the route declared `file: UploadFile = File(...)`, FastAPI parsed and spooled the body
|
||||||
|
BEFORE the first dependency ran, so `require_human` could not gate it: an unauthenticated
|
||||||
|
caller could make the server write the whole per-file allowance (500 MiB by default) to temp,
|
||||||
|
per request. SEC-20's ceiling capped the number; it could not stop the writing.
|
||||||
|
|
||||||
|
The route now reads its own body from a dependency declared AFTER the auth one, the shape
|
||||||
|
`POST /api/debug-sessions/{sid}/screenshots` already uses. The claim under test is the strong
|
||||||
|
one — a stranger's bytes are never read WHATEVER they claim to send, so the probe sends the
|
||||||
|
multipart the old route would have spooled, not the raw body the new one expects."""
|
||||||
|
body, pulled = _counting_multipart()
|
||||||
|
r = client.post(
|
||||||
|
"/api/downloads/upload",
|
||||||
|
content=body,
|
||||||
|
headers={"content-type": f"multipart/form-data; boundary={_BOUNDARY}"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 401
|
||||||
|
assert pulled == [], f"the server read {sum(pulled)} bytes from an anonymous caller"
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_authenticated_upload_still_has_its_body_read(as_user, make_user, _root, monkeypatch):
|
||||||
|
"""The mutation check for the test above: a route that read NOBODY's body would pass it just as
|
||||||
|
well as the fixed one. The same counting body, signed in, must be pulled in full."""
|
||||||
|
_probe(monkeypatch, (None, None, None, None, None))
|
||||||
|
body, pulled = _counting_body()
|
||||||
|
c = as_user(make_user())
|
||||||
|
r = c.post(
|
||||||
|
"/api/downloads/upload",
|
||||||
|
content=body,
|
||||||
|
headers={"content-type": "application/octet-stream", "x-upload-filename": "clip.bin"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert sum(pulled) == 4096
|
||||||
|
|
||||||
|
|
||||||
|
def test_capped_stream_to_drains_the_rest_before_refusing():
|
||||||
|
"""Measured in a real browser, then pinned HERE rather than at the route.
|
||||||
|
|
||||||
|
The cap now fires while the client is still sending, and answering + closing mid-upload is what
|
||||||
|
a browser reports as a NETWORK failure: the dialog said "the server couldn't be reached"
|
||||||
|
instead of naming the limit, because XHR never got to read the 422. `capped_stream_to` reads
|
||||||
|
the remainder to nowhere before raising, so the client finishes sending and reads the real
|
||||||
|
answer.
|
||||||
|
|
||||||
|
It is asserted against the helper and not through TestClient on purpose — httpx's ASGI
|
||||||
|
transport pumps the request body whether or not the app reads it, so a route-level version of
|
||||||
|
this test passes just as happily with the drain deleted. It was written that way first and
|
||||||
|
caught by its own mutation check.
|
||||||
|
"""
|
||||||
|
pulled = []
|
||||||
|
|
||||||
|
async def stream():
|
||||||
|
for i in range(8):
|
||||||
|
pulled.append(i)
|
||||||
|
yield b"x" * 1024
|
||||||
|
|
||||||
|
class _Req:
|
||||||
|
def stream(self):
|
||||||
|
return stream()
|
||||||
|
|
||||||
|
out = io.BytesIO()
|
||||||
|
refusal = HTTPException(status_code=422, detail={"code": "upload_too_large", "limit": 2048})
|
||||||
|
# `asyncio.run` rather than an async test: the suite has no async plugin, and adding one to
|
||||||
|
# pin four lines of helper would mean a new dev dependency and a lock regeneration.
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
asyncio.run(capped_stream_to(_Req(), out, 2048, refusal))
|
||||||
|
assert exc.value is refusal
|
||||||
|
assert len(pulled) == 8, "the body was abandoned mid-stream, not drained"
|
||||||
|
# Drained means DISCARDED: nothing past the limit is written.
|
||||||
|
assert len(out.getvalue()) <= 2048
|
||||||
|
|
||||||
|
|
||||||
# --- route ----------------------------------------------------------------------------------
|
# --- route ----------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def test_upload_route_generic_forces_download(as_user, make_user, _root, monkeypatch):
|
def test_upload_route_generic_forces_download(
|
||||||
|
as_user, make_user, _root, monkeypatch, upload_headers
|
||||||
|
):
|
||||||
_probe(monkeypatch, (None, None, None, None, None))
|
_probe(monkeypatch, (None, None, None, None, None))
|
||||||
c = as_user(make_user())
|
c = as_user(make_user())
|
||||||
r = c.post(
|
r = c.post(
|
||||||
"/api/downloads/upload",
|
"/api/downloads/upload",
|
||||||
files={"file": ("report.html", b"<script>alert(1)</script>", "text/html")},
|
content=b"<script>alert(1)</script>",
|
||||||
|
headers=upload_headers("report.html"),
|
||||||
)
|
)
|
||||||
assert r.status_code == 200, r.text
|
assert r.status_code == 200, r.text
|
||||||
job = r.json()
|
job = r.json()
|
||||||
@@ -145,7 +281,7 @@ def test_upload_route_generic_forces_download(as_user, make_user, _root, monkeyp
|
|||||||
|
|
||||||
|
|
||||||
def test_upload_route_media_html_extension_not_served_as_html(
|
def test_upload_route_media_html_extension_not_served_as_html(
|
||||||
as_user, make_user, _root, monkeypatch
|
as_user, make_user, _root, monkeypatch, upload_headers
|
||||||
):
|
):
|
||||||
"""The XSS guard: a MEDIA-classified upload (real codecs) named with an executable extension must
|
"""The XSS guard: a MEDIA-classified upload (real codecs) named with an executable extension must
|
||||||
NOT be served with a guessed text/html type. The served Content-Type comes from the verified
|
NOT be served with a guessed text/html type. The served Content-Type comes from the verified
|
||||||
@@ -156,7 +292,8 @@ def test_upload_route_media_html_extension_not_served_as_html(
|
|||||||
c = as_user(make_user())
|
c = as_user(make_user())
|
||||||
r = c.post(
|
r = c.post(
|
||||||
"/api/downloads/upload",
|
"/api/downloads/upload",
|
||||||
files={"file": ("evil.html", b"<script>alert(1)</script>", "text/html")},
|
content=b"<script>alert(1)</script>",
|
||||||
|
headers=upload_headers("evil.html"),
|
||||||
)
|
)
|
||||||
assert r.status_code == 200, r.text
|
assert r.status_code == 200, r.text
|
||||||
job = r.json()
|
job = r.json()
|
||||||
@@ -177,7 +314,9 @@ def test_upload_route_media_html_extension_not_served_as_html(
|
|||||||
assert "attachment" in pr.headers["content-disposition"].lower()
|
assert "attachment" in pr.headers["content-disposition"].lower()
|
||||||
|
|
||||||
|
|
||||||
def test_public_watch_file_forces_download_for_generic_file(as_user, make_user, _root, monkeypatch):
|
def test_public_watch_file_forces_download_for_generic_file(
|
||||||
|
as_user, make_user, _root, monkeypatch, upload_headers
|
||||||
|
):
|
||||||
"""The login-free public serve of a generic upload (media_kind='file') must be a neutral
|
"""The login-free public serve of a generic upload (media_kind='file') must be a neutral
|
||||||
force-download attachment — the branch that stops uploaded HTML/SVG executing on the public
|
force-download attachment — the branch that stops uploaded HTML/SVG executing on the public
|
||||||
origin. This is a SEPARATE code path from the authenticated /file route, so it needs its own
|
origin. This is a SEPARATE code path from the authenticated /file route, so it needs its own
|
||||||
@@ -186,7 +325,8 @@ def test_public_watch_file_forces_download_for_generic_file(as_user, make_user,
|
|||||||
c = as_user(make_user())
|
c = as_user(make_user())
|
||||||
job = c.post(
|
job = c.post(
|
||||||
"/api/downloads/upload",
|
"/api/downloads/upload",
|
||||||
files={"file": ("notes.html", b"<script>alert(1)</script>", "text/html")},
|
content=b"<script>alert(1)</script>",
|
||||||
|
headers=upload_headers("notes.html"),
|
||||||
).json()
|
).json()
|
||||||
assert job["asset"]["media_kind"] == "file"
|
assert job["asset"]["media_kind"] == "file"
|
||||||
link = c.post(f"/api/downloads/{job['id']}/links", json={"allow_download": True}).json()
|
link = c.post(f"/api/downloads/{job['id']}/links", json={"allow_download": True}).json()
|
||||||
@@ -197,7 +337,7 @@ def test_public_watch_file_forces_download_for_generic_file(as_user, make_user,
|
|||||||
|
|
||||||
|
|
||||||
def test_public_watch_file_serves_media_inline_with_real_type(
|
def test_public_watch_file_serves_media_inline_with_real_type(
|
||||||
as_user, make_user, _root, monkeypatch
|
as_user, make_user, _root, monkeypatch, upload_headers
|
||||||
):
|
):
|
||||||
"""A recognised media container IS still served inline with its real audio/video type (so it
|
"""A recognised media container IS still served inline with its real audio/video type (so it
|
||||||
plays in the watch page) — the fix must not break legitimate playback. A stream-only link →
|
plays in the watch page) — the fix must not break legitimate playback. A stream-only link →
|
||||||
@@ -206,7 +346,8 @@ def test_public_watch_file_serves_media_inline_with_real_type(
|
|||||||
c = as_user(make_user())
|
c = as_user(make_user())
|
||||||
job = c.post(
|
job = c.post(
|
||||||
"/api/downloads/upload",
|
"/api/downloads/upload",
|
||||||
files={"file": ("clip.mp4", b"fake-mp4-bytes", "video/mp4")},
|
content=b"fake-mp4-bytes",
|
||||||
|
headers=upload_headers("clip.mp4"),
|
||||||
).json()
|
).json()
|
||||||
link = c.post(f"/api/downloads/{job['id']}/links", json={"allow_download": False}).json()
|
link = c.post(f"/api/downloads/{job['id']}/links", json={"allow_download": False}).json()
|
||||||
pr = c.get(f"/api/public/watch/{link['token']}/file")
|
pr = c.get(f"/api/public/watch/{link['token']}/file")
|
||||||
@@ -215,7 +356,9 @@ def test_public_watch_file_serves_media_inline_with_real_type(
|
|||||||
assert "inline" in pr.headers["content-disposition"].lower()
|
assert "inline" in pr.headers["content-disposition"].lower()
|
||||||
|
|
||||||
|
|
||||||
def test_upload_route_enforces_per_file_cap(as_user, make_user, db, _root, monkeypatch):
|
def test_upload_route_enforces_per_file_cap(
|
||||||
|
as_user, make_user, db, _root, monkeypatch, upload_headers
|
||||||
|
):
|
||||||
from app import sysconfig
|
from app import sysconfig
|
||||||
|
|
||||||
sysconfig.set_value(db, "download_upload_max_bytes", 5)
|
sysconfig.set_value(db, "download_upload_max_bytes", 5)
|
||||||
@@ -223,47 +366,391 @@ def test_upload_route_enforces_per_file_cap(as_user, make_user, db, _root, monke
|
|||||||
c = as_user(make_user())
|
c = as_user(make_user())
|
||||||
r = c.post(
|
r = c.post(
|
||||||
"/api/downloads/upload",
|
"/api/downloads/upload",
|
||||||
files={"file": ("big.bin", b"way over the cap", "application/octet-stream")},
|
content=b"way over the cap",
|
||||||
|
headers=upload_headers("big.bin"),
|
||||||
)
|
)
|
||||||
assert r.status_code == 422
|
assert r.status_code == 422
|
||||||
assert r.json()["detail"]["code"] == "upload_too_large"
|
assert r.json()["detail"]["code"] == "upload_too_large"
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_cap_admits_a_body_of_EXACTLY_the_cap_and_refuses_one_byte_more(
|
||||||
|
as_user, make_user, db, _root, monkeypatch, upload_headers
|
||||||
|
):
|
||||||
|
"""The boundary, which the test above cannot see: it sends 16 bytes against a cap of 5, so a
|
||||||
|
`>` silently loosened to `>=` refuses it just the same and the mutation survives the whole
|
||||||
|
suite. The cap is `size > limit`, so a file of exactly the cap must be ACCEPTED.
|
||||||
|
"""
|
||||||
|
from app import sysconfig
|
||||||
|
|
||||||
|
_probe(monkeypatch, (None, None, None, None, None))
|
||||||
|
payload = b"x" * 64
|
||||||
|
sysconfig.set_value(db, "download_upload_max_bytes", len(payload))
|
||||||
|
db.commit()
|
||||||
|
c = as_user(make_user())
|
||||||
|
|
||||||
|
at_the_cap = c.post(
|
||||||
|
"/api/downloads/upload", content=payload, headers=upload_headers("exact.bin")
|
||||||
|
)
|
||||||
|
assert at_the_cap.status_code == 200, at_the_cap.text
|
||||||
|
|
||||||
|
over = c.post(
|
||||||
|
"/api/downloads/upload", content=payload + b"x", headers=upload_headers("over.bin")
|
||||||
|
)
|
||||||
|
assert over.status_code == 422
|
||||||
|
assert over.json()["detail"] == {"code": "upload_too_large", "limit": len(payload)}
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_non_ascii_filename_survives_the_header_round_trip(
|
||||||
|
as_user, make_user, db, _root, monkeypatch, upload_headers
|
||||||
|
):
|
||||||
|
"""The percent-decoding, which every other test in this file leaves vacuous.
|
||||||
|
|
||||||
|
Headers are latin-1 on the wire, so the client `encodeURIComponent`s the name and the route
|
||||||
|
`unquote`s it. Every filename the suite sends is pure ASCII — and `quote()` is the IDENTITY
|
||||||
|
function on ASCII — so deleting the `unquote` call changes nothing anywhere else. A Hungarian
|
||||||
|
name is the only input that can tell the two apart: without the decode the sanitizer would be
|
||||||
|
handed the literal `nyaral%C3%A1s.bin` and would keep the percent-escapes in the stored name.
|
||||||
|
"""
|
||||||
|
_probe(monkeypatch, (None, None, None, None, None))
|
||||||
|
c = as_user(make_user())
|
||||||
|
r = c.post(
|
||||||
|
"/api/downloads/upload", content=b"data", headers=upload_headers("nyaralás ősszel.bin")
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
asset = db.execute(select(MediaAsset).order_by(MediaAsset.id.desc())).scalars().first()
|
||||||
|
assert asset is not None
|
||||||
|
stored = Path(asset.rel_path).name
|
||||||
|
assert "%" not in stored and "c3" not in stored.lower(), stored
|
||||||
|
assert stored.startswith("nyaralas_osszel"), stored
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_long_display_name_is_truncated_on_EVERY_route_that_takes_one(
|
||||||
|
db, as_user, make_user, _root, monkeypatch, upload_headers
|
||||||
|
):
|
||||||
|
"""The sweep, held end to end rather than at one site.
|
||||||
|
|
||||||
|
`display_text` was introduced for the upload path and applied to three of the five writers of
|
||||||
|
this varchar(255) column; `POST /api/downloads` and `POST /api/downloads/edit` kept the old
|
||||||
|
`(x or "").strip() or None` and would still have answered a bare 500 (a psycopg DataError,
|
||||||
|
which main.py re-raises for everything but the NUL case) to a name a user simply pasted. Found
|
||||||
|
by four independent readers in deep-review round 2 — a sweep is exactly the kind of thing that
|
||||||
|
gets done four-fifths of the way, so this holds all of it at once.
|
||||||
|
"""
|
||||||
|
_probe(monkeypatch, ("h264", "aac", 640, 480, 60))
|
||||||
|
monkeypatch.setattr(uploadmod.editmod, "ensure_poster", lambda _a, _root: None)
|
||||||
|
long_name = "n" * 300
|
||||||
|
bound = "n" * DISPLAY_TEXT_MAX
|
||||||
|
c = as_user(make_user())
|
||||||
|
|
||||||
|
# 1. the upload route — the name arrives in a request header.
|
||||||
|
up = c.post(
|
||||||
|
"/api/downloads/upload", content=b"video", headers=upload_headers("a.mp4", long_name)
|
||||||
|
)
|
||||||
|
assert up.status_code == 200, up.text
|
||||||
|
assert up.json()["display_name"] == bound
|
||||||
|
|
||||||
|
# 2. the enqueue route — a plain JSON field, from the Add-download dialog. An inline `spec`
|
||||||
|
# rather than a profile id, so the test does not have to seed the builtin profile table.
|
||||||
|
enq = c.post(
|
||||||
|
"/api/downloads",
|
||||||
|
json={"source": "dQw4w9WgXcQ", "spec": {"kind": "video"}, "display_name": long_name},
|
||||||
|
)
|
||||||
|
assert enq.status_code == 200, enq.text
|
||||||
|
assert enq.json()["display_name"] == bound
|
||||||
|
|
||||||
|
# 3. the edit route — the same field, from the editor.
|
||||||
|
edit = c.post(
|
||||||
|
"/api/downloads/edit",
|
||||||
|
json={
|
||||||
|
"source_job_id": up.json()["id"],
|
||||||
|
"edit_spec": {"trim": {"start_s": 0, "end_s": 1}},
|
||||||
|
"display_name": long_name,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert edit.status_code == 200, edit.text
|
||||||
|
assert edit.json()["display_name"] == bound
|
||||||
|
|
||||||
|
# 4 + 5. the metadata PATCH's two fields.
|
||||||
|
patched = c.patch(
|
||||||
|
f"/api/downloads/{up.json()['id']}",
|
||||||
|
json={"display_name": long_name, "display_uploader": long_name},
|
||||||
|
)
|
||||||
|
assert patched.status_code == 200, patched.text
|
||||||
|
assert patched.json()["display_name"] == bound
|
||||||
|
assert patched.json()["display_uploader"] == bound
|
||||||
|
|
||||||
|
|
||||||
|
def _display_write_sites() -> dict[tuple[str, str], set[str]]:
|
||||||
|
"""Every place under `backend/app/` that puts a value into a display field, as
|
||||||
|
(module relative to `app/`, enclosing function) -> the set of value expressions written there.
|
||||||
|
|
||||||
|
TWO SHAPES, because the first version of this guard walked only `ast.Assign` and was therefore
|
||||||
|
blind to the shape the upload route itself uses — `SpooledUpload(..., display_name=...)` is an
|
||||||
|
`ast.keyword`, not an assignment, so the one site with an unbounded REQUEST HEADER behind it was
|
||||||
|
the site the guard could not see (deep-review round 3).
|
||||||
|
|
||||||
|
ANNOTATED assignments are excluded on purpose: `display_name: Mapped[str | None] =
|
||||||
|
mapped_column(...)` in `models.py` and `display_name: str | None = None` in a Pydantic model are
|
||||||
|
declarations of the field, not writes into it.
|
||||||
|
|
||||||
|
The whole of `app/`, not just `app/routes/`: a job row is constructed in `downloads/service.py`
|
||||||
|
and `downloads/upload.py`, so a walk stopping at the HTTP boundary misses the writers that
|
||||||
|
actually reach the column.
|
||||||
|
"""
|
||||||
|
app_dir = pathlib.Path(__file__).resolve().parents[1] / "app"
|
||||||
|
found: dict[tuple[str, str], set[str]] = {}
|
||||||
|
for py in sorted(app_dir.rglob("*.py")):
|
||||||
|
module = py.relative_to(app_dir).as_posix()
|
||||||
|
tree = ast.parse(py.read_text("utf-8"), filename=str(py))
|
||||||
|
enclosing: dict[int, str] = {}
|
||||||
|
for fn in ast.walk(tree):
|
||||||
|
if isinstance(fn, (ast.AsyncFunctionDef, ast.FunctionDef)):
|
||||||
|
for node in ast.walk(fn):
|
||||||
|
enclosing.setdefault(id(node), fn.name)
|
||||||
|
for node in ast.walk(tree):
|
||||||
|
if isinstance(node, ast.Assign):
|
||||||
|
names = {
|
||||||
|
t.id if isinstance(t, ast.Name) else t.attr
|
||||||
|
for t in node.targets
|
||||||
|
if isinstance(t, (ast.Name, ast.Attribute))
|
||||||
|
}
|
||||||
|
value = node.value
|
||||||
|
elif isinstance(node, ast.keyword):
|
||||||
|
names = {node.arg} if node.arg else set()
|
||||||
|
value = node.value
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
if not names & {"display_name", "display_uploader"}:
|
||||||
|
continue
|
||||||
|
key = (module, enclosing.get(id(node), "<module>"))
|
||||||
|
found.setdefault(key, set()).add(ast.unparse(value))
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
# Every write into a display field, with the expression each one writes. A varchar(255) column with
|
||||||
|
# a request header behind it turns a too-long name into a psycopg DataError — a 500 AFTER the whole
|
||||||
|
# file has transferred — so each of these has to be bounded, and each one here has been read.
|
||||||
|
#
|
||||||
|
# Bounded by `display_text` unless noted:
|
||||||
|
# auth.get_or_create_demo_user a literal.
|
||||||
|
# service.redownload CLEARS the field so the fresh metadata wins — `None` is the
|
||||||
|
# one value that needs no bound, and a clear is still a write.
|
||||||
|
# service.enqueue / enqueue_edit `display_name` is already bounded by its caller; the fallback
|
||||||
|
# slices the YouTube title to the same width.
|
||||||
|
# downloads.upload_download passes through `spool_upload`'s value, bounded there.
|
||||||
|
#
|
||||||
|
# Held as EXACT EQUALITY rather than as a list of offenders. The offender form could only fail
|
||||||
|
# upward: refactoring the writers into a shape the walker did not recognise left it asserting
|
||||||
|
# `not []` and enforcing nothing, which is how a drift guard dies quietly (round 3). This one fails
|
||||||
|
# when a site appears, when one disappears, and when one changes what it writes.
|
||||||
|
DISPLAY_WRITE_SITES = {
|
||||||
|
("auth.py", "get_or_create_demo_user"): {"'Demo'"},
|
||||||
|
("downloads/service.py", "redownload"): {"None"},
|
||||||
|
("downloads/service.py", "enqueue"): {
|
||||||
|
"display_name or (asset.title[:DISPLAY_TEXT_MAX] "
|
||||||
|
"if asset.status == 'ready' and asset.title else None)"
|
||||||
|
},
|
||||||
|
("downloads/service.py", "enqueue_edit"): {"display_name or asset.title[:DISPLAY_TEXT_MAX]"},
|
||||||
|
("downloads/upload.py", "ingest_upload"): {"display_text(display_name)"},
|
||||||
|
("routes/auth.py", "callback"): {"display_text(userinfo.get('name'))"},
|
||||||
|
("routes/auth.py", "_complete_link"): {"display_text(userinfo.get('name'))"},
|
||||||
|
("routes/downloads.py", "enqueue_download"): {"display_text(body.display_name)"},
|
||||||
|
("routes/downloads.py", "enqueue_edit"): {"display_text(body.display_name)"},
|
||||||
|
("routes/downloads.py", "update_download_meta"): {
|
||||||
|
"display_text(body.display_name)",
|
||||||
|
"display_text(body.display_uploader)",
|
||||||
|
},
|
||||||
|
("routes/downloads.py", "upload_download"): {"upload.display_name"},
|
||||||
|
("routes/downloads.py", "spool_upload"): {
|
||||||
|
"display_text(_header_text(request, UPLOAD_DISPLAY_NAME_HEADER))"
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_code_path_assigns_a_display_field_without_the_shared_helper():
|
||||||
|
"""The drift guard for the test above: it holds five routes, this holds the rule.
|
||||||
|
|
||||||
|
Parsed, not grepped. Every value that reaches a display field takes a string a user typed (or
|
||||||
|
YouTube supplied) and puts it on its way to a varchar(255) column, so every one of them must be
|
||||||
|
bounded. A writer added tomorrow fails here instead of 500ing in production.
|
||||||
|
"""
|
||||||
|
found = _display_write_sites()
|
||||||
|
moved = set(found) ^ set(DISPLAY_WRITE_SITES)
|
||||||
|
changed = {
|
||||||
|
key: (found[key], DISPLAY_WRITE_SITES[key])
|
||||||
|
for key in set(found) & set(DISPLAY_WRITE_SITES)
|
||||||
|
if found[key] != DISPLAY_WRITE_SITES[key]
|
||||||
|
}
|
||||||
|
assert found == DISPLAY_WRITE_SITES, (
|
||||||
|
"a display-field write appeared, moved or changed shape. Every value reaching "
|
||||||
|
"display_name/display_uploader must be bounded to models.DISPLAY_TEXT_MAX — normally by "
|
||||||
|
"models.display_text — or a name longer than the column is a 500 rather than a truncation. "
|
||||||
|
f"Sites that appeared or vanished: {moved}. Sites that changed: {changed}."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_display_text_bound_matches_the_columns_it_guards():
|
||||||
|
"""A number living in two places is how the `cancelled`/`canceled` bug happened. `display_text`
|
||||||
|
truncates to `DISPLAY_TEXT_MAX`; the columns it feeds are `String(255)`. Held against the model
|
||||||
|
itself, so widening one without the other fails here rather than as a DataError in production."""
|
||||||
|
for name in ("display_name", "display_uploader"):
|
||||||
|
assert DownloadJob.__table__.c[name].type.length == DISPLAY_TEXT_MAX
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_display_name_header_reaches_the_job_and_is_bounded(
|
||||||
|
as_user, make_user, _root, monkeypatch, upload_headers
|
||||||
|
):
|
||||||
|
"""The second header, which had no backend test at all — and the length nothing bounded.
|
||||||
|
|
||||||
|
Since P1 the display name arrives in a REQUEST HEADER rather than as a form field, so the only
|
||||||
|
limit on it was the header size. The column is varchar(255), so a longer one reached Postgres as
|
||||||
|
a DataError: a 500 AFTER the whole file had been transferred. It is truncated now, by the same
|
||||||
|
helper the metadata PATCH uses.
|
||||||
|
"""
|
||||||
|
_probe(monkeypatch, (None, None, None, None, None))
|
||||||
|
c = as_user(make_user())
|
||||||
|
|
||||||
|
ok = c.post(
|
||||||
|
"/api/downloads/upload",
|
||||||
|
content=b"data",
|
||||||
|
headers=upload_headers("clip.bin", "Nyári vakáció"),
|
||||||
|
)
|
||||||
|
assert ok.status_code == 200, ok.text
|
||||||
|
assert ok.json()["display_name"] == "Nyári vakáció"
|
||||||
|
|
||||||
|
long_name = "n" * 300
|
||||||
|
bounded = c.post(
|
||||||
|
"/api/downloads/upload", content=b"data2", headers=upload_headers("clip2.bin", long_name)
|
||||||
|
)
|
||||||
|
assert bounded.status_code == 200, bounded.text
|
||||||
|
assert bounded.json()["display_name"] == "n" * DISPLAY_TEXT_MAX
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_upload_holds_no_db_transaction_while_the_body_streams(
|
||||||
|
as_user, make_user, db, _root, monkeypatch, upload_headers
|
||||||
|
):
|
||||||
|
"""P1 moved the body read behind the auth dependency, which put the whole transfer INSIDE the
|
||||||
|
request's database session: resolving the caller and reading the cap are SELECTs, SQLAlchemy 2.0
|
||||||
|
autobegins on the first one, and the session then holds one of the twenty pooled connections —
|
||||||
|
`idle in transaction` to Postgres, with no `idle_in_transaction_session_timeout` to end it — for
|
||||||
|
as long as the client keeps sending. `db.py` sizes the pool on that not happening and
|
||||||
|
`plex/playback.py` records the production incident behind the rule.
|
||||||
|
|
||||||
|
Asserted on the session rather than on `engine.pool.checkedout()`: the transaction is the cause,
|
||||||
|
the checkout is its consequence (measured), and the pool counter is shared with whatever else
|
||||||
|
the test process is holding.
|
||||||
|
"""
|
||||||
|
from app.routes import downloads as downloads_routes
|
||||||
|
|
||||||
|
_probe(monkeypatch, (None, None, None, None, None))
|
||||||
|
seen: dict[str, bool] = {}
|
||||||
|
real_stream = downloads_routes.capped_stream_to
|
||||||
|
real_release = downloads_routes.release_read_connection
|
||||||
|
|
||||||
|
def spy_release(session):
|
||||||
|
# The PRECONDITION, so this test cannot pass vacuously. Asserting only "no transaction
|
||||||
|
# while streaming" would stay green if the auth/cap reads stopped opening one at all —
|
||||||
|
# and under `as_user` the auth SELECT is already overridden away, so that is a live risk
|
||||||
|
# rather than a theoretical one.
|
||||||
|
seen["before"] = bool(session.in_transaction())
|
||||||
|
real_release(session)
|
||||||
|
seen["after_release"] = bool(session.in_transaction())
|
||||||
|
|
||||||
|
async def spy_stream(request, out, limit, refusal):
|
||||||
|
seen["while_streaming"] = bool(db.in_transaction())
|
||||||
|
return await real_stream(request, out, limit, refusal)
|
||||||
|
|
||||||
|
monkeypatch.setattr(downloads_routes, "release_read_connection", spy_release)
|
||||||
|
monkeypatch.setattr(downloads_routes, "capped_stream_to", spy_stream)
|
||||||
|
c = as_user(make_user())
|
||||||
|
r = c.post("/api/downloads/upload", content=b"data", headers=upload_headers("held.bin"))
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert seen["before"] is True, (
|
||||||
|
"nothing had opened a transaction before the release, so this test proves nothing — the "
|
||||||
|
"reads it is meant to follow must have moved or been stubbed away"
|
||||||
|
)
|
||||||
|
assert seen["after_release"] is False
|
||||||
|
assert seen["while_streaming"] is False, (
|
||||||
|
"the request's session was inside a transaction when the transfer began, so a pooled "
|
||||||
|
"connection is pinned for the whole upload"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_usage_route_reports_the_per_file_upload_cap(as_user, make_user, db):
|
||||||
|
"""The dialog refuses an over-cap file off this number, and it is the only layer that produces
|
||||||
|
a refusal carrying the limit on a default install — so the field is load-bearing, not
|
||||||
|
decorative. Nothing held it: the route had no test at all, and the frontend's `DownloadUsage`
|
||||||
|
is an unvalidated cast, so dropping the key would have been green everywhere (round 2).
|
||||||
|
"""
|
||||||
|
from app import sysconfig
|
||||||
|
|
||||||
|
c = as_user(make_user())
|
||||||
|
r = c.get("/api/downloads/usage")
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert r.json()["upload_max_bytes"] == sysconfig.get_int(db, "download_upload_max_bytes")
|
||||||
|
|
||||||
|
# And it is the KNOB, not a constant: a lowered override must show through.
|
||||||
|
sysconfig.set_value(db, "download_upload_max_bytes", 4096)
|
||||||
|
db.commit()
|
||||||
|
assert c.get("/api/downloads/usage").json()["upload_max_bytes"] == 4096
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_refused_upload_leaves_nothing_in_the_staging_directory(
|
||||||
|
as_user, make_user, db, _root, monkeypatch, upload_headers
|
||||||
|
):
|
||||||
|
"""`spool_upload` writes the body to a staging temp file as it arrives, so a refusal happens
|
||||||
|
with a partial file already on disk — its `finally` is what removes it. Nothing asserted that:
|
||||||
|
deleting the unlink left the whole suite green while every refused upload leaked its partial
|
||||||
|
bytes onto the volume, one file per attempt, with no job to ever clean them up.
|
||||||
|
"""
|
||||||
|
from app import sysconfig
|
||||||
|
|
||||||
|
sysconfig.set_value(db, "download_upload_max_bytes", 8)
|
||||||
|
db.commit()
|
||||||
|
staging = Path(settings.download_root) / ".staging" / "uploads"
|
||||||
|
c = as_user(make_user())
|
||||||
|
|
||||||
|
r = c.post("/api/downloads/upload", content=b"x" * 4096, headers=upload_headers("leaky.bin"))
|
||||||
|
assert r.status_code == 422
|
||||||
|
assert staging.exists(), "the dependency did not get as far as creating the staging dir"
|
||||||
|
assert list(staging.iterdir()) == [], "a refused upload left its partial file behind"
|
||||||
|
|
||||||
|
|
||||||
def test_upload_cap_cannot_be_disabled_with_zero(db):
|
def test_upload_cap_cannot_be_disabled_with_zero(db):
|
||||||
"""download_upload_max_bytes floors at 1: a stored 0 would read falsy in the route's
|
"""download_upload_max_bytes floors at 1, and set_value must enforce it.
|
||||||
`if cap and size > cap` guard and SILENTLY disable the per-file cap, so set_value must reject it.
|
|
||||||
Pins the min=1 invariant so a revert to min=0 can't pass the gate."""
|
The falsy-0 footgun this originally guarded is gone — the route's old `if cap and size > cap`
|
||||||
|
read a stored 0 as "no cap at all", while `capped_stream_to` compares unconditionally and a 0
|
||||||
|
would refuse everything instead. Refusing everything is the safe direction, but it is still
|
||||||
|
wrong, and the knob is also the number the admin page and the 422 `limit` advertise. Pins the
|
||||||
|
min=1 invariant so a revert to min=0 can't pass the gate."""
|
||||||
from app import sysconfig
|
from app import sysconfig
|
||||||
|
|
||||||
with pytest.raises(ValueError):
|
with pytest.raises(ValueError):
|
||||||
sysconfig.set_value(db, "download_upload_max_bytes", 0)
|
sysconfig.set_value(db, "download_upload_max_bytes", 0)
|
||||||
|
|
||||||
|
|
||||||
def test_upload_route_rejects_empty_file(as_user, make_user, _root, monkeypatch):
|
def test_upload_route_rejects_empty_file(as_user, make_user, _root, monkeypatch, upload_headers):
|
||||||
c = as_user(make_user())
|
c = as_user(make_user())
|
||||||
r = c.post(
|
r = c.post("/api/downloads/upload", content=b"", headers=upload_headers("empty.bin"))
|
||||||
"/api/downloads/upload", files={"file": ("empty.bin", b"", "application/octet-stream")}
|
|
||||||
)
|
|
||||||
assert r.status_code == 400
|
assert r.status_code == 400
|
||||||
assert r.json()["detail"]["code"] == "upload_empty"
|
assert r.json()["detail"]["code"] == "upload_empty"
|
||||||
|
|
||||||
|
|
||||||
def test_redownload_rejects_an_upload(as_user, make_user, _root, monkeypatch):
|
def test_redownload_rejects_an_upload(as_user, make_user, _root, monkeypatch, upload_headers):
|
||||||
_probe(monkeypatch, (None, None, None, None, None))
|
_probe(monkeypatch, (None, None, None, None, None))
|
||||||
c = as_user(make_user())
|
c = as_user(make_user())
|
||||||
job = c.post(
|
job = c.post("/api/downloads/upload", content=b"data", headers=upload_headers("a.bin")).json()
|
||||||
"/api/downloads/upload", files={"file": ("a.bin", b"data", "application/octet-stream")}
|
|
||||||
).json()
|
|
||||||
r = c.post(f"/api/downloads/{job['id']}/redownload")
|
r = c.post(f"/api/downloads/{job['id']}/redownload")
|
||||||
assert r.status_code == 409
|
assert r.status_code == 409
|
||||||
|
|
||||||
|
|
||||||
def test_edit_rejects_a_generic_upload_source(as_user, make_user, _root, monkeypatch):
|
def test_edit_rejects_a_generic_upload_source(
|
||||||
|
as_user, make_user, _root, monkeypatch, upload_headers
|
||||||
|
):
|
||||||
_probe(monkeypatch, (None, None, None, None, None))
|
_probe(monkeypatch, (None, None, None, None, None))
|
||||||
c = as_user(make_user())
|
c = as_user(make_user())
|
||||||
job = c.post(
|
job = c.post("/api/downloads/upload", content=b"data", headers=upload_headers("a.bin")).json()
|
||||||
"/api/downloads/upload", files={"file": ("a.bin", b"data", "application/octet-stream")}
|
|
||||||
).json()
|
|
||||||
r = c.post(
|
r = c.post(
|
||||||
"/api/downloads/edit",
|
"/api/downloads/edit",
|
||||||
json={"source_job_id": job["id"], "edit_spec": {"trim": {"start_s": 0, "end_s": 1}}},
|
json={"source_job_id": job["id"], "edit_spec": {"trim": {"start_s": 0, "end_s": 1}}},
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ def test_a_failure_is_sorted_by_what_the_user_can_do(exc, reason):
|
|||||||
("route", "target"),
|
("route", "target"),
|
||||||
[
|
[
|
||||||
("/api/sync/subscriptions", "app.routes.sync.import_subscriptions"),
|
("/api/sync/subscriptions", "app.routes.sync.import_subscriptions"),
|
||||||
("/api/playlists/sync-youtube", "app.routes.playlists.sync_user_playlists"),
|
("/api/playlists/sync-youtube", "app.routes.playlists.pull_user_playlists"),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_a_manual_sync_youtube_refuses_is_a_422_not_a_500(
|
def test_a_manual_sync_youtube_refuses_is_a_422_not_a_500(
|
||||||
|
|||||||
@@ -0,0 +1,638 @@
|
|||||||
|
# DESIGN.md — how Siftlode looks and behaves
|
||||||
|
|
||||||
|
This is the one written source of truth for the UI: principles, tokens, the canonical solution per pattern,
|
||||||
|
and the interaction rules. Screens are measured against it. A pattern that has more than one look in the
|
||||||
|
code is either a decision still open here (marked **Pending**) or a violation to fix.
|
||||||
|
|
||||||
|
**Status:** written in E28; every chapter is _Decided_ and in force. The design lab (below) renders each rule's
|
||||||
|
chosen form. A new question opens a **Pending** item here, with its variants in the lab, until the product
|
||||||
|
owner settles it.
|
||||||
|
|
||||||
|
## How this document changes
|
||||||
|
|
||||||
|
- A rule changes only through an explicit decision recorded in the [decision log](#decision-log): what was
|
||||||
|
decided, the options it was chosen from, and why. No mid-sprint taste changes. A new idea goes on the
|
||||||
|
list for the next design round.
|
||||||
|
- Every rule rests on a named standard: Nielsen's heuristics, WCAG 2.2 AA, WAI-ARIA APG, Apple HIG or
|
||||||
|
Material. The tag is in brackets after the rule.
|
||||||
|
- A rule stated here should also be enforced in code (an eslint rule, a primitive's type, a test). A rule
|
||||||
|
that lives only in prose drifts. That is the finding that started this document.
|
||||||
|
|
||||||
|
## Conformance — the check before a UI change is done
|
||||||
|
|
||||||
|
A screen is measured against this document, not against taste. Every change that renders something new
|
||||||
|
runs this check before it is called done (E28 S3g):
|
||||||
|
|
||||||
|
1. **Primitives at a named rung.** Each control goes through its primitive (lint refuses the raw element,
|
||||||
|
and a `<div role="button">` with it), at a rung or role this document names: a `Button` size, an
|
||||||
|
`IconButton` size, a `Chip` size, the `SegmentedToggle` rung, a `SURFACE` role. The lint holds the
|
||||||
|
element, not the rung, so the rung is checked by eye and by measure.
|
||||||
|
2. **The pattern's chapter, re-read.** The change matches the chapter of every pattern it touches, or it
|
||||||
|
opens a **Pending** item and a log row. It never quietly adds a second look.
|
||||||
|
3. **Measured.** On the changed pages, the rendered elements are grouped by kind (button, icon button,
|
||||||
|
chip, field, tab, segment, menu row, surface) and by their computed look (radius, height, type, fill,
|
||||||
|
edge). Each distinct look maps to a rung or role named here; a look that maps to none is a violation
|
||||||
|
or a new decision. The S3g baseline across the 15 pages at 1920 × 1080: every button, icon button,
|
||||||
|
chip, field, tab, segment and menu row is drawn by its primitive. What the measure still counts as
|
||||||
|
hand-drawn is by design: text links (no primitive), surfaces painted by their `SURFACE` role (a class,
|
||||||
|
which the measure cannot trace to a component) and the playlist table's own rows.
|
||||||
|
4. **Seen.** A screenshot of every changed state, thin edges at 1:1; the tablet widths (1024 × 768, a
|
||||||
|
narrow split screen) when the layout can reflow. Phones are the E29 epic's, not this check's.
|
||||||
|
|
||||||
|
## The design lab
|
||||||
|
|
||||||
|
`npm run dev`, then open **`http://localhost:5173/design-lab`**. The lab renders each open decision's variants
|
||||||
|
with the app's real CSS, tokens, glass and themes, and has its own switches for theme, scheme, text size and
|
||||||
|
backdrop. It is dev-only: `main.tsx` reaches it behind `import.meta.env.DEV`, and the build plugin
|
||||||
|
`frontend/devOnlyGuard.ts` fails a production build that would contain it. Once a decision is taken, the
|
||||||
|
losing variants are deleted and the chosen one stays as the reference rendering: the lab is the living
|
||||||
|
style guide.
|
||||||
|
|
||||||
|
## Principles
|
||||||
|
|
||||||
|
1. **Glass over a calm background is the look.** Surfaces are translucent layers (`.glass`,
|
||||||
|
`.glass-card`, `.glass-menu`, `.glass-media`) over a per-scheme backdrop. Depth comes from the
|
||||||
|
**header + fade**: a crisp sticky header with a short gradient just below it, so content dissolves as it
|
||||||
|
slides under. The reference is `DataTable`'s sticky header. A flat opaque box where a glass surface
|
||||||
|
belongs is a violation.
|
||||||
|
2. **Built for one known, daily user, on a desktop and a tablet.** Density is welcome where a mouse is
|
||||||
|
the pointer; on touch every target grows. Nothing is designed for phones.
|
||||||
|
3. **One primitive per pattern.** Where a `components/ui/` primitive exists and is used, the looks don't
|
||||||
|
drift. The drift lives in hand-rolled markup. A new generic control goes into `ui/`, and a screen
|
||||||
|
composes primitives rather than restyling them.
|
||||||
|
4. **Measured, not eyeballed.** Contrast, target sizes and layout at the contract sizes are measured in a
|
||||||
|
real browser before a UI change is called done.
|
||||||
|
5. **Honest states.** An error never renders as an empty state; loading, empty and error are three
|
||||||
|
different screens (`QueryState` / `StateMessage`).
|
||||||
|
|
||||||
|
## Device adaptation — _Decided_
|
||||||
|
|
||||||
|
- **Supported:** tablet portrait and landscape and every size above them (from 768px wide). Phones are not a
|
||||||
|
target. Measure a UI change at 1024×768, 820×1180 (portrait) and a desktop size.
|
||||||
|
- **Adapt by capability, never by user agent:** `@media (pointer: coarse)` and `(hover: none)` decide touch
|
||||||
|
behaviour; container queries decide how a panel lays itself out. Breakpoints are named tokens, never
|
||||||
|
literal pixel values scattered in call sites. [HIG, WCAG 1.4.10]
|
||||||
|
- **Touch never loses a function.** Anything revealed on hover is visible at rest on `hover: none` (the
|
||||||
|
`.reveal` class), or it is removed on touch because the same action is reachable another way
|
||||||
|
(`.reveal-hover`). [WCAG 1.4.13, Nielsen #6]
|
||||||
|
- **Targets:** at least 2.75rem (44px) on a coarse pointer, as a real box, not an invisible hit area:
|
||||||
|
overlapping hit areas fire the wrong control. It covers every control primitive, not only `Button`
|
||||||
|
(A3, under Controls). [HIG, WCAG 2.5.8]
|
||||||
|
- **Text inputs** use at least 1rem text on a coarse pointer, so a tablet browser doesn't zoom the page
|
||||||
|
when an input gets focus.
|
||||||
|
- **Side panels (C2):** on touch, and at any width below 768 (a narrow window, split screen), the rail tab
|
||||||
|
or the band's "Filters · n" button slides the panel **over** the content with a scrim; a tap outside, Esc
|
||||||
|
or Back closes it, and the content never reflows. Pinning is its own button in the panel header, offered
|
||||||
|
only where a pinned panel leaves the content room (≥ 1024 wide). The mouse keeps hover-to-peek and
|
||||||
|
click-to-pin. A filter is never unreachable at any supported width. Built in S3f3: `ui/SidePanel`
|
||||||
|
opens `ui/Modal`'s `drawer-start` placement (the dialog's own Esc, Back, scrim, focus trap and scroll
|
||||||
|
lock); the tab shows at every width and is 44 wide on touch; a pin saved on a desktop is left alone
|
||||||
|
below 1024, not honoured. [Nielsen #7, WCAG 1.4.10]
|
||||||
|
- **A row answers to its own width.** A list row that must fit a narrow column (the download row, the
|
||||||
|
playlist table, the Channels tag row) stacks by a container query on its own box, not by the
|
||||||
|
window: the same list sits beside a rail, in a split-screen tablet and in a zoomed-in browser. A
|
||||||
|
line of facts (the download row's "Ready · MP4 · 18.9 MB · 1:11 · 1w ago") is one line cut at its
|
||||||
|
end, never a flex row that breaks its pieces apart.
|
||||||
|
- **A table gives its secondary columns back.** By the table's own width, as a row does: below 42rem
|
||||||
|
a column marked `narrowHidden` steps aside, below 64rem (a 1024 window) one marked `tabletHidden`
|
||||||
|
does, so the name keeps its room. The Channels table drops Prio, About and Length there (at 1024 the
|
||||||
|
name had 60 px); the row's "…" still sets the priority.
|
||||||
|
- **Players on touch (C3):** see [Players](#players).
|
||||||
|
|
||||||
|
## Tokens
|
||||||
|
|
||||||
|
- **Colour:** only semantic tokens (`--bg --surface --card --fg --muted --accent --accent-fg --danger
|
||||||
|
--warn --success --info --border`, each with a `-fg` partner where text sits on it), and no palette class
|
||||||
|
(`text-amber-500`: lint-refused). A score's gold is `--rating` (theme-scoped, 4.5:1 on the page and on its
|
||||||
|
own chip), never `--warn`: a score is not a warning. What sits on a picture looks the same in both themes,
|
||||||
|
because the picture does: the broadcast red `--live` under `--live-fg` (never `--danger`: "live" is not an
|
||||||
|
error), `--rating-media` on a poster's scrim, and `--media-chrome` for the always-dark player's menus
|
||||||
|
(owner decision F16). Black and white are the media scrims and their text. Five schemes × dark
|
||||||
|
and light (B2): **Midnight** (blue), **Forest** (cyan), **Slate** (fuchsia), **Matrix** (moss) each earn their
|
||||||
|
place with their own hue family; **Fjord** (Midnight's navy with a calm steel-blue accent) is the one kept
|
||||||
|
exception, the calm twin of the vivid Midnight. A new scheme still needs its own hue family. Normal text ≥ 4.5:1 and a control's edge ≥ 3:1 in **every** scheme and mode.
|
||||||
|
[WCAG 1.4.3, 1.4.11] The input edge token is set under Controls (A4). The ≥ 3:1 edge is the field
|
||||||
|
family's (a field, the form's dropdown trigger), whose edge is the only thing that says "type here". A
|
||||||
|
labelled pill (a chip, a toolbar trigger, a tab, a segment) keeps the faint `--border`: its text names it,
|
||||||
|
and 1.4.11 does not ask an edge of a control its label already identifies (owner decision F14).
|
||||||
|
- **The accent never looks like a status.** A scheme's accent is at least 30° of OKLCH hue away from
|
||||||
|
`--danger`, `--warn` and `--success`, and passes 4.5:1 both as text on `--bg` and under `--accent-fg` on
|
||||||
|
the fill, in dark and light. A primary button must never read as a destructive one. A new scheme is
|
||||||
|
measured against this before it ships (the lab's B2 tiles do the arithmetic). [WCAG 1.4.1, 1.4.3]
|
||||||
|
- **Held in code:** `frontend/src/lib/theme.contract.test.ts` measures every scheme × mode in `index.css`
|
||||||
|
against the two rules above (text 4.5:1, a control edge 3:1, the status and rating colours as text, the accent's
|
||||||
|
hue gap), and the media colours against what they sit on. A scheme or a retuned colour that breaks them does not pass the gate.
|
||||||
|
- **No per-user colour tuning.** The schemes are the only colour choice; the old floating glass tuner was
|
||||||
|
retired (E28), because free tuning is exactly the drift these rules exist to stop.
|
||||||
|
- **Backdrops:** one per scheme and mode (`public/backdrops/`), soft pools in the accent's family, with a
|
||||||
|
grey `overlay` grain that breaks the gradient banding (measured by pixel-scanning a canvas, never a
|
||||||
|
screenshot, which rescales).
|
||||||
|
- **State layers (B3):** hover and pressed are a layer, not a colour swap: the text colour mixed at **10%**
|
||||||
|
(hover) / **16%** (pressed) over whatever the control sits on, so it reads on the page, a card and glass
|
||||||
|
alike. A filled control mixes its own text colour into its fill at 14% / 24%. Opacity dimming and
|
||||||
|
`bg-card` hovers are violations (invisible on a light card). Selected stays the solid accent. The one
|
||||||
|
exception is an edged pill (a chip, a toolbar or form trigger, a segment): its hover is the accent edge
|
||||||
|
(`.pill:hover`, `glass-hover`), which reads on every surface too (owner decision F14).
|
||||||
|
A card that is one row with actions answers as a whole: the layer is the card's, under its badges and
|
||||||
|
buttons, and the row's open button spans the card beneath the actions (a hover that stopped at the first
|
||||||
|
badge read as broken; owner, E28 — `AdminUsers` user rows).
|
||||||
|
- **Tokens as utilities:** the decided values live once in `index.css` — `--hover` / `--press`,
|
||||||
|
`--edge-strong`, `--touch-target`, and the utilities `state-layer`, `state-filled`, `state-danger`,
|
||||||
|
`edge-strong`, `fade-below`, `fade-below-panel`, `fade-above-panel`, `fade-edges-x`. A primitive or a lab
|
||||||
|
rendering uses them; restating one as an arbitrary class is a violation.
|
||||||
|
- **Type and size:** everything is rem-derived off `--font-scale` (the Settings text-size slider), so a
|
||||||
|
px font size or a px-frozen box is an eslint error.
|
||||||
|
- **Radius:** chips and segmented controls `rounded-full`; tabs `rounded-lg`. Buttons, icon buttons and
|
||||||
|
form fields `rounded-lg`; toolbar triggers and search a pill (Controls). Surfaces: see Surfaces.
|
||||||
|
- **Layers:** the fixed z-index scale in `index.css` (`base < menu < chrome < paneltab < panel < rail <
|
||||||
|
overlay < popover < tooltip`), plus `under`, a surface's own layer under its content inside its `isolate`.
|
||||||
|
No ad-hoc z-index. [maintainability]
|
||||||
|
|
||||||
|
## Controls — _Decided_ (batches A and B)
|
||||||
|
|
||||||
|
The chosen rendering of each rule is in the lab under "Decided". The primitives are built to it in S3.
|
||||||
|
|
||||||
|
- **Buttons (A1):** `rounded-lg` (r8): a control's corner is smaller than the surface it sits on (card r12,
|
||||||
|
dialog r16). The rung follows the context: `md` in forms and dialog footers, `sm` in toolbars and panel
|
||||||
|
headers, `xs` inside a table row. Glass is a `Button` variant, not a hand-built glass button. [HIG, Material]
|
||||||
|
- **Density (B1): compact, by explicit height.** `md` 2rem (32), `sm` 1.75rem (28), `xs` 1.5rem (24); icon
|
||||||
|
buttons 1.75rem / 2rem. A toolbar's search pill, dropdown trigger, `sm` button and small icon button are
|
||||||
|
the same 28, so the row lines up. Nothing goes under 24 (WCAG 2.5.8); touch grows it to 44 (A3).
|
||||||
|
- **Icon buttons (A2):** two sizes (28 / 32), r8: the smaller in rows and dense toolbars, the larger on its own. On a
|
||||||
|
picture, a round, dark translucent button, readable on any thumbnail. Round means "floats over something":
|
||||||
|
on media, or the one floating button over the page (back to top, a 48 glass circle, the FAB); a button
|
||||||
|
inside a pill field is round too, so the radii nest. A text
|
||||||
|
button on a picture is a pill (the card's Continue in the accent, Restart dark translucent, C5), and
|
||||||
|
the one 48px glyph is the Play in the middle of a not-started card (C5).
|
||||||
|
- **Touch (A3):** on a coarse pointer every control primitive (`Button`, `SegmentedToggle`, `FilterSelect`,
|
||||||
|
`Tabs`, `Switch`, chips, the nav rail) grows to 2.75rem (44px) through one token, as a real box. Mouse
|
||||||
|
sizes don't change. [HIG, WCAG 2.5.8]
|
||||||
|
- **Input (A4):** one bordered input primitive, `ui/Input` (`Textarea` for several lines; a frame that also
|
||||||
|
holds an icon or chips is painted with `fieldClass` around an `InnerInput`). The edge is
|
||||||
|
`color-mix(in srgb, var(--fg) 55%, var(--bg))`, ≥ 3:1 against the page in every scheme and mode; focus
|
||||||
|
turns it into a 2px accent edge, `invalid` into the danger edge. A form field is r8 at the `md` height
|
||||||
|
(32); a toolbar search, a field in a popover or an inline rename is `sm` (28), the toolbar search a pill;
|
||||||
|
the page's one hero field is `lg` (40): the header search, a glass pill floating over the page (the
|
||||||
|
YouTube/Gmail shape), and the command palette's field;
|
||||||
|
a field in a table row or the pager `xs` (24); an inline rename of a page title keeps the heading's type
|
||||||
|
(`heading`). Text is 1rem and the box 44 on a coarse pointer. [WCAG 1.4.11]
|
||||||
|
- **Dropdown trigger (A5):** `ui/SelectTrigger`. A pill in a toolbar (where the pill means "a filter or a
|
||||||
|
choice"), r8 and full width in a form, where it wears the field's frame (see-through, the ≥ 3:1 edge) and
|
||||||
|
lines up with the inputs. Open is the accent edge and the chevron turned over.
|
||||||
|
- **Checkbox, switch, slider (A6):** the browser's own controls tinted with `accent-color` (`Checkbox`,
|
||||||
|
`CheckRow`, `Slider`); `ColorInput` is the browser's colour well, which `accent-color` does not reach (it
|
||||||
|
tints only checkboxes, radios, ranges and progress bars). The whole labelled row is the 44px target. Keyboard, screen reader and
|
||||||
|
forced-colours support come from the browser. The switch stays `form`'s `Switch`: no browser has a native
|
||||||
|
one yet. A raw `<input>` or `<textarea>` outside those primitives is a lint error (a hidden file picker
|
||||||
|
passes). A checkable row that cannot hold an `<input>` — a menu row, a whole-row button whose role says
|
||||||
|
checkbox — draws the ONE `ui/MenuCheck` in its leading icon slot: a box filled with the accent for a set
|
||||||
|
(tags, playlists, a filter's values; a minus for a group only partly in), a bare tick for one choice (a
|
||||||
|
language, a sort, a priority step), an unticked choice keeping the slot empty so the labels line up.
|
||||||
|
- **In-place editing:** a field that replaces its trigger (a title rename, the priority number) hands the
|
||||||
|
focus back to that trigger when Enter or Escape closes it (`useReturnFocus`), never to `<body>`.
|
||||||
|
[WCAG 2.4.3]
|
||||||
|
|
||||||
|
Also in force:
|
||||||
|
|
||||||
|
- **One primitive per button family** (E28 S3b): a labelled action is `Button` (a rung from the ladder,
|
||||||
|
plus `lg` 40 for a page's single hero call to action, and nothing else); a glyph is `IconButton` (a
|
||||||
|
required `label`); a card, row, tile or menu row whose content is the control is `Pressable`; a filter or
|
||||||
|
tag chip is `Chip`; a sortable row's grip is `DragHandle`. On the always-dark player chrome both
|
||||||
|
`Button` and `IconButton` use the `overlay` variant (white, a white layer), whatever the theme; the Plex
|
||||||
|
control row is IconButton's `player` rung (36, a 20px glyph, round, 44 on touch). A pressed toggle is
|
||||||
|
`pressed`: the solid accent on a `Button`, the accent glyph on an `IconButton`. A raw `<button>` (or a bare
|
||||||
|
`FocusSafeButton`) outside the control primitives (eslint `BUTTON_PRIMITIVES`) is a lint error. A text-less button must have an accessible name, and the type enforces
|
||||||
|
it. [WCAG 4.1.2]
|
||||||
|
- **The chip family** (`Chip`, E28 S3b): rounded-full, never under 24 (A3 grows it to 44 on touch).
|
||||||
|
`filter` is a selectable option, solid accent when `pressed`; the "+N" and "More…" triggers are filter
|
||||||
|
chips too, and while their popover is open they show it as an open trigger does, the accent edge and
|
||||||
|
text (not the solid fill, which would say "selected"); `token` is an applied narrowing, the accent tint, the whole chip removing
|
||||||
|
it; `tag` is a user's tag, the solid accent; `suggest` is a suggestion not yet taken (dashed accent);
|
||||||
|
`ghost` the quiet "add a tag" (dashed neutral). A chip whose label is not the control but carries its
|
||||||
|
own × is `DismissibleChip`: two sibling controls at most, never a button in a button. An overflowing chip
|
||||||
|
lane measures a hidden mirror painted with `chipClass`, so the mirror cannot drift from the chips.
|
||||||
|
- **Unavailable with a reason** stays focusable: `aria-disabled` plus the reason in the label or tooltip,
|
||||||
|
never native `disabled`, which drops it from the tab order with its explanation. [WCAG 2.1.1]
|
||||||
|
- A choice between a few values is `SegmentedToggle`; a long list or a user's own list is `FilterSelect`.
|
||||||
|
Never a native `<select>` (lint-enforced). Its rung is the toolbar's, 28 with `text-xs`, so it lines up
|
||||||
|
with the triggers and the pager's buttons; the 24 rung with the smallest type is only a side panel's
|
||||||
|
captioned match toggle (S3g: the old small default had put a pager and a toolbar count on 10px type).
|
||||||
|
- **A multi-value filter's any/all switch** (tags, topics, languages, genres, collections, actors) is
|
||||||
|
`ui/MatchToggle`, on the panel right above its picker, from two picks on — never in the picker's
|
||||||
|
popover, where a closed picker hid that "all" was on. One wording: Match · Any / All. [Nielsen #1]
|
||||||
|
- **The pager** is four glyphs — first, previous, next, last — round the page box (`IconButton`s, their
|
||||||
|
names as the tooltip); no words, at any width.
|
||||||
|
- A single-select row of filter chips goes through `FilterChipGroup` (its options are `Chip`s).
|
||||||
|
- A button that starts slow work shows `loading` (a spinner, `aria-busy`) and can't be pressed twice.
|
||||||
|
[Nielsen #1]
|
||||||
|
- **A glyph means one thing.** Play (▷) starts playback, or resumes a paused transfer; a retry is the
|
||||||
|
refresh arrow (`RefreshCw`); a reset or revert is the counter-clockwise arrow (`RotateCcw`). A failed
|
||||||
|
download's Retry had worn the Play glyph, which promised a video. [Nielsen #4]
|
||||||
|
|
||||||
|
## Surfaces and page chrome — _Decided_ (batch B)
|
||||||
|
|
||||||
|
- **Surface per role (B5):** a dialog or panel is `.glass` r16; a card or picker `.glass-card` r12; a menu
|
||||||
|
`.glass-menu` r12 with 4px padding and r8 items (12 − 4, so the corners nest); a tooltip `.glass-menu` r8.
|
||||||
|
Translucency comes only from the tiers (the app backdrop, Plex media, light); a per-site alpha override is a
|
||||||
|
violation. A call site names the ROLE (`SURFACE.panel` / `drawer` / `card` / `menu` / `popover` / `tooltip`,
|
||||||
|
`ui/surfaces.ts`), which brings the tier AND the radius; a page section is a panel, and what floats over
|
||||||
|
undimmed content (a menu, a hover card, a status popover, a toast, a tooltip) is `.glass-menu`. A bare glass
|
||||||
|
class is a lint error. A role's depth is its own shadow (the top highlight + the drop); a `shadow-*` size beside
|
||||||
|
a role would replace both and is a lint error too — a field that floats takes `shadow-float`, the same shadow.
|
||||||
|
- **Static vs pressable (owner, S3d):** a label that does nothing (a topic, a keyword, a status, a count) is a
|
||||||
|
flat, tonal `Badge` with no edge; everything that does something has an edge (a chip, a trigger, a field).
|
||||||
|
A genre that filters the library is a chip; the same genre where it can't is a badge. A badge's `tone`
|
||||||
|
(neutral / accent / warn) is its own colour at 6%, measured at 4.5:1 in every scheme; a `<span>` drawn with a
|
||||||
|
round edge is a lint error (F29). A toggle button states itself through `pressed` (the solid accent), never a
|
||||||
|
bare `aria-pressed`, which the type refuses (F28).
|
||||||
|
- **A link that looks like a button** (a download, a sign-in that leaves the app) is `LinkButton` /
|
||||||
|
`IconLink`: still an `<a>`, wearing the button's own face.
|
||||||
|
- **Page chrome (B4):** a page's tabs and filters sit in ONE sticky band of the page colour, with the fade
|
||||||
|
under it (below). Below ~600px of height the filter row folds into one "Filters · n" button. Every page has
|
||||||
|
a visually hidden `h1`; the nav rail and the browser tab title are the visible identity. [WCAG 2.4.6]
|
||||||
|
Built as: the band is `PageShell`'s toolbar slot (`PageToolbar`), above the one scroller; a console
|
||||||
|
whose toolbar lives inside its frame (the server log) makes that toolbar `sticky` with `fade-below`
|
||||||
|
instead. The band holds controls only: an intro paragraph scrolls with the content (in the band it
|
||||||
|
ate three lines of a short window). The fold is `useFilterFold` + `FilterFoldButton`: the same row
|
||||||
|
shown or hidden in place, never copied into a popover. The hidden `h1` is `PageShell`'s `title`; a
|
||||||
|
view with its own visible title (a channel, a Plex show, a playlist) renders it as `PageHeading`
|
||||||
|
and the hidden one steps aside, so a page has exactly one `h1` (a hand `<h1>` inside the shell is
|
||||||
|
refused by a contract test). A section's caption is `SectionLabel`, an `h2` (an `h3` in a dialog),
|
||||||
|
never a styled `div`. The first Tab stop is "Skip to content" (`SkipLink`), which focuses the
|
||||||
|
scroller's `<main>`. [WCAG 2.4.1, 1.3.1]
|
||||||
|
- **The header + fade edge (B9):** a crisp band and a 1rem gradient of the colour it sits on, so content
|
||||||
|
dissolves as it slides under; it shows only once something is under it, never over a list at rest. It goes on sticky toolbars and table headers, tabs over a scrolling panel, a
|
||||||
|
long dialog's header and footer, and the clipped ends of an overflowing chip row. Not on thumbnails (they
|
||||||
|
are the content) and not on buttons (it becomes a dated gloss). Built as: a sticky element takes the
|
||||||
|
`fade-below` utility (`fade-below-panel` / `fade-above-panel` on a panel); the page band and a
|
||||||
|
scrolling panel under its header fade by the scroller's own edge mask (`useScrollFade`), because what
|
||||||
|
they sit on is the backdrop, not a colour. The chip rows fold their overflow into "+N" rather than
|
||||||
|
scroll, so no row has clipped ends; the carousels fade theirs. The dialog edges come with C4's pinned
|
||||||
|
footer (S3f).
|
||||||
|
- **Viewport height:** `dvh`, never `vh` / `h-screen`: `100vh` is the viewport with a mobile browser's
|
||||||
|
toolbar retracted, so an `h-screen` shell runs its last rows under the toolbar (contract-tested).
|
||||||
|
- **Nav rail (B7):** the `ui/NavRail` primitive, slim: 3.75rem wide. "You are here" is a soft accent tint plus a
|
||||||
|
3px edge bar. Labels under the icons are optional, a per-user switch in Settings (off by default); they add
|
||||||
|
height, never width, so each module has a short rail label that fits, and the full name stays in the
|
||||||
|
tooltip. No wide padding around the icons. The switch is Settings → Appearance → "Labels in the side
|
||||||
|
menu"; with the labels on, a row's accessible name is its visible short label (WCAG 2.5.3), and an
|
||||||
|
unread count is part of the name, not only drawn.
|
||||||
|
- **Loading (B8):** a first load of a grid, list or table shows its skeleton (`LoadingState shape="grid" |
|
||||||
|
"poster" | "list" | "table"`, built from the `Skeleton` primitive), so
|
||||||
|
nothing jumps when the data lands; a control's own work shows its spinner (`loading`). Neither appears
|
||||||
|
before ~300ms. [Nielsen #1]
|
||||||
|
- **The public watch page (B6)** follows these same rules (glass, `Button`, the pressed-pill toggle, the
|
||||||
|
scheme's backdrop); only the video keeps the browser's own controls, because a stranger's device is
|
||||||
|
unknown.
|
||||||
|
|
||||||
|
Already in force:
|
||||||
|
|
||||||
|
- The glass role is load-bearing: a dropdown or menu is `.glass-menu` (it floats over undimmed content, so a
|
||||||
|
card alpha would bleed through), a dialog is `.glass`, and a grid card is `.glass-card`.
|
||||||
|
- Every overlay is a `Modal`/`Overlay` or an anchored popover from the one popover engine. Escape closes the
|
||||||
|
topmost layer only, and focus returns to the control that opened it. [WCAG 2.4.3, 2.1.2]
|
||||||
|
|
||||||
|
## Action placement — _Decided_
|
||||||
|
|
||||||
|
Which actions are visible and which live in a menu.
|
||||||
|
|
||||||
|
- **One menu, several triggers.** An object's actions are ONE list. The visible "…" button, a right-click,
|
||||||
|
a long-press (touch) and Shift+F10 / the Menu key all open the same `ContextMenu` built from that list.
|
||||||
|
A second, hand-made menu for the same object is a violation.
|
||||||
|
- **Nothing is reachable only by a gesture.** Every menu action also has a visible path: the "…" button,
|
||||||
|
the page toolbar, or the object's detail view. A right-click is a shortcut, never the only door.
|
||||||
|
[Nielsen #6, #7]
|
||||||
|
- **What stays visible:** the object's primary action (play or open) and any state the user needs to see
|
||||||
|
(watched, saved, downloading). **What goes into the menu:** rare, secondary and destructive actions.
|
||||||
|
Destructive ones keep their confirmation. [Nielsen #8, #5]
|
||||||
|
- **On touch, a card shows fewer actions and a "…" button.** The rest are in the menu.
|
||||||
|
- **The classification (C1)** says, per object, what is visible at rest, on hover and on touch; its full
|
||||||
|
table is the lab's C1 reference, and S3 turns it into each object's one action list in code. In short:
|
||||||
|
- A video or Plex card: play and its state (watched, progress) at rest; the everyday toggles (watched,
|
||||||
|
save, add to playlist) and "…" on hover; on touch the state and a 44px "…" beside the title. Details,
|
||||||
|
download, hide and reset live in the menu.
|
||||||
|
- A row (playlist item, channel, download): its primary action and its values (priority, tags, error)
|
||||||
|
stay visible; destructive and rare row buttons (unsubscribe, delete, cancel, remove) move into the menu.
|
||||||
|
A playlist item's X column goes: Delete key plus an undo toast.
|
||||||
|
- Downloads, Notifications and the Scheduler get a menu (they have none today); a page's destructive
|
||||||
|
header action ("Clear everything", "Purge discovery") goes into the header's "…".
|
||||||
|
- **Built as:** one `useContextMenu` per list; the row spreads `triggerProps` (right-click, long-press,
|
||||||
|
Shift+F10) and its "…" is `ui/MoreButton` with `buttonProps` (the same menu, under the button; a second
|
||||||
|
press closes it). An object's list is one builder (`useVideoActions` for a video, `usePlexCardActions`
|
||||||
|
for a Plex title, composed by their lists and by Home's rails); a row that opens a panel ("Add to playlist…", "Details") anchors it to
|
||||||
|
`menu.anchor`, the element the menu came from. "Copy title" / "Copy link" come from `useCopyItems` and
|
||||||
|
run inside the click (`inGesture`, Safari's clipboard rule). The card's hover row sits on its picture
|
||||||
|
(the duration steps aside while it shows); on touch the toggles are gone and a 44px "…" sits beside
|
||||||
|
the title (`.touch-only`). The hover chrome and what steps aside for it are one pair of class strings
|
||||||
|
(`ui/reveal.ts`), shared by the video and the Plex cards. A table or rail ROW shows its "…" at the
|
||||||
|
row's end on hover or focus and always on touch (`.reveal`). A playlist's rows take Remove from the
|
||||||
|
Delete key too (the `listDelete` key scope, registered only by a list that can remove) and answer it
|
||||||
|
with an undo toast whose Undo puts the row back at its index in one request; a playlist itself has
|
||||||
|
one list (`usePlaylistActions.items`) that its rail row and its page header's "…" both open. A
|
||||||
|
subscribed channel's row ends in its "…" (`useChannelActions`: open, pin, Tags, Priority, YouTube,
|
||||||
|
copy, full history, hide with an undo toast, unsubscribe behind its confirm), docked beside the name
|
||||||
|
in the card layout. Its Tags submenu lists the reader's tags A–Z, each a check that toggles in place
|
||||||
|
(the menu stays open for the next); its Priority submenu offers the fixed steps 10 · 5 · 3 · 2 · 1 · 0
|
||||||
|
(an off-scale current value shows ticked in its place), and a pick re-sorts the list at once — only
|
||||||
|
that row moves — with the pager and the focus (its "…") following it. The row's stepper still sets any
|
||||||
|
value and leaves the order until the next load. A Plex playlist's title, show group and season each open the page's one row menu
|
||||||
|
(a title's is its `usePlexCardActions` list plus the removal); removals take the same undo toast,
|
||||||
|
`j`/`k` walk the visible rows and Delete removes the walked one; its rename and delete sit in the
|
||||||
|
header's "…". A toast that carries an action lives at least 8 s, whatever the toast setting. A
|
||||||
|
download row keeps play (pause / resume / retry in the queue) and the save link, a library row also
|
||||||
|
Share (the link copy, ahead of save), and ends in its "…" (`useDownloadActions`: the editors,
|
||||||
|
managing the shares, re-download, the copies, cancel / delete / remove
|
||||||
|
behind their confirms); a link inside a row keeps the browser's own right-click menu. A notification
|
||||||
|
row keeps its link and its unread mark, shows its dismiss on hover (in the "…" on touch), and its
|
||||||
|
"…" holds find / undo for an activity notice. The notification center's band keeps Mark all read
|
||||||
|
and puts Clear everything, now behind a confirm, in its "…"; the Scheduler's strip keeps Run all
|
||||||
|
and Pause and puts Purge discovery in its "…". A list whose row objects are rebuilt each render or replaced by a
|
||||||
|
poll passes `useContextMenu({ keyOf })` (its id), so the "…" still closes its own menu. A menu's
|
||||||
|
groups are `ui/menuGroups`: a rule between
|
||||||
|
two non-empty groups only.
|
||||||
|
- **Background menu:** a right-click on EMPTY content area (not over text, inputs, links or media) may open
|
||||||
|
a page menu. It only mirrors page-scope actions that already exist in the toolbar (sort, view, quick
|
||||||
|
filters, select all); it adds no new function. Shift+right-click falls through to the browser's own menu.
|
||||||
|
Desktop only: on touch a long-press on the background clashes with scrolling and text selection, so the
|
||||||
|
toolbar stays the touch path. Built (`ui/PageMenu`; PageShell listens, `lib/backgroundTarget` decides
|
||||||
|
"empty") on five pages: the **feed** (sort, reshuffle, view, watch state, content types, source, clear
|
||||||
|
filters), the **Plex library grid** (sort, watch state, clear filters), the **channel manager** (layout,
|
||||||
|
the cards' sort, sync status, edit tags, clear the tag filter, read subscriptions from YouTube), a
|
||||||
|
**playlist** (play all, group by channel, reset view, save order, undo / redo, and the list's own "…")
|
||||||
|
and the **notification center** (mark all read). Never a destructive row (delete, revert, clear
|
||||||
|
everything) and never a long quota run (backfill): a stray right-click must not be one click from those.
|
||||||
|
The tab-only pages keep the browser's menu; `pageMenuScope.contract.test.ts` holds the list.
|
||||||
|
|
||||||
|
### Text stays selectable — _Decided_
|
||||||
|
|
||||||
|
Wherever there is text, the user can select it and copy it, and no click action overrides that.
|
||||||
|
|
||||||
|
- A title or a name inside a click surface stays selectable: the card, row, person, channel or log line
|
||||||
|
that opens is `selectable` (`ui/FocusSafeButton`, under `Pressable` and `Button`). Chrome selects no
|
||||||
|
text inside a `<button>` unless it is `user-select: text` (measured), so the flag sets that and the
|
||||||
|
click guard below. A control's own label (a menu row, an option, a tab, a nav item, a switch) is not
|
||||||
|
a title and stays plain; `pressableSelectable.contract.test.ts` makes every new `Pressable` choose.
|
||||||
|
- A click that ends a text selection does nothing: if the selection inside the element is non-empty, or
|
||||||
|
the pointer moved past the drag slop, no play or open fires.
|
||||||
|
- A right-click on selected words opens the browser's own menu (Copy, Search, Translate), not the
|
||||||
|
object's; a right-click beside the selection still opens the object's menu (`ui/useContextMenu`).
|
||||||
|
- On touch the long-press belongs to the context menu, so the object's menu offers **Copy title** and
|
||||||
|
**Copy link**.
|
||||||
|
|
||||||
|
## Media card — _Decided_ (S3h)
|
||||||
|
|
||||||
|
A film, a show, an episode, a video or a pinned channel is ONE card wherever it appears: the Plex grid,
|
||||||
|
the feed, Home's shelves and hero, the Plex rails, the media-info page's collection strip. A place may
|
||||||
|
pick a density step; it never changes the anatomy or loses an action. [Nielsen #4]
|
||||||
|
|
||||||
|
- **Anatomy (the owner's pick C).** One frame: the picture, the title, the meta line pinned to the
|
||||||
|
frame's bottom edge, so a one-line title leaves no hole above the meta. What is ON the picture: the
|
||||||
|
length (a film's runtime, a show's season count, a video's duration) as a dark badge in the corner,
|
||||||
|
the watched mark, the progress bar along the bottom. Built as `ui/MediaCard` + the classes in
|
||||||
|
`ui/mediaCardStyles` (`MEDIA_PICTURE`, `MEDIA_IMG`, `MEDIA_BADGE`, `MEDIA_HOVER_LAYER`, `MEDIA_BAR`,
|
||||||
|
`mediaTitleClass`, `mediaPictureBox`).
|
||||||
|
- **Play only where the click plays.** The hover layer dims the picture and shows `MediaPlayGlyph`, the
|
||||||
|
one round media Play (the button family's `media` variant), only on a card whose click starts
|
||||||
|
playback; a show without a next episode opens instead and shows none. The hover row (watched, save or
|
||||||
|
add, "…") docks at the picture's bottom-right; the length badge steps aside while it shows.
|
||||||
|
- **Density.** A grid card: a two-line title and the full meta (Plex: year · ★ score; a video: the
|
||||||
|
channel, then views · age). On a shelf (any horizontal strip) the title is one line, cut with an
|
||||||
|
ellipsis, over ONE meta line (Plex: year · ★; a video: the channel; a tile: its count), so every card on
|
||||||
|
the strip is the same height. A Home shelf slot is 232px: at 200 the Play and the hover row
|
||||||
|
overlapped by 2px, at 232 they stand 6px apart; a landscape shelf picture takes the smaller Play.
|
||||||
|
- **A mixed shelf is landscape.** Beside videos a Plex title shows its wide art, not its poster; a
|
||||||
|
rail's slot is a grid cell, so each card fills the height the strip stretches to. A carousel turns
|
||||||
|
the cards' content-visibility off (`mediaRail.contract.test.ts`): an off-screen card's reserved height
|
||||||
|
had become the strip's.
|
||||||
|
- **The preview (the owner's pick C).** Resting the mouse on a Plex card for ~0.9 s opens a panel beside
|
||||||
|
it: the whole title, year · length · ★, and the synopsis in short lines (`ui/MediaPreview` +
|
||||||
|
`useMediaPreview`). Mouse only (a keyboard walk or a tap never opens it; "…" → Media info has the same),
|
||||||
|
passive (it never takes a click), and any press on the card closes it. Long text never goes in a
|
||||||
|
native `title` tooltip.
|
||||||
|
- **Home's hero is a card** (the owner's pick A): the same hover row and Play on its picture, the same
|
||||||
|
whole menu on right-click, long-press and a touch "…" in its button row. In a wide window it stands
|
||||||
|
three quarters wide and centred, with even padding; its text is one centred stack — kicker, title,
|
||||||
|
subtitle (dropped when it is only the year), one fact line, a summary only when there is one.
|
||||||
|
- **Home's shelf tiles act like their object.** A pinned channel or playlist: Play when it has a next
|
||||||
|
video, ↗ to open it, "…" with an Open row. A show carries its next episode: the Play, the watched
|
||||||
|
toggle and add act on that episode ("Next: S6·E16" is its meta line), and an ⓘ opens the show. A
|
||||||
|
channel's Home menu is play, open and the shelf's own rows; its tags, priority, hide and unsubscribe
|
||||||
|
stay on the Channels page.
|
||||||
|
- **The compact list row (the owner's pick B).** The title leads; a faint "…" is always at the row's end;
|
||||||
|
on hover or focus the quick toggles float in beside it over the date, which steps aside (the Gmail
|
||||||
|
row). On touch only the "…".
|
||||||
|
- **The media-info page (layout B).** Under the hero a main column — the collection strips (the same
|
||||||
|
card), then the crew, then the cast — beside a 20rem sidebar of facts (details, keywords). Every
|
||||||
|
section is the reader's to hide; a column with nothing in it is not drawn and the other takes the
|
||||||
|
width. A show's page uses the same meta row: a value that filters is a chip, a plain fact is flat
|
||||||
|
text, spaced by the gap, no "·".
|
||||||
|
- **Browse tiles** (genres, playlists, collections) answer the pointer as a card does: the edge takes
|
||||||
|
the accent, the shadow lifts and the art zooms.
|
||||||
|
- **A missing picture says what the item is.** No picture at all (a link that failed before its
|
||||||
|
details, a poster that never came) shows a quiet glyph — a film, a note, a file — never an empty box,
|
||||||
|
which reads as "still loading". A download with no name yet is titled by its short link, once: no
|
||||||
|
channel line, no second link line.
|
||||||
|
- **Reading measure.** Prose (a synopsis, a biography, a channel's description) is capped at ~65
|
||||||
|
characters a line (the `measure` utility) wherever its container can be wider. [WCAG 1.4.8]
|
||||||
|
|
||||||
|
## Interaction rules — _Decided_
|
||||||
|
|
||||||
|
- **Keyboard:** everything works by keyboard, in visual order, with no traps. A shortcut is a row in
|
||||||
|
`lib/keymap.ts`; list and grid navigation is `NavGrid`/`keyboardNav`. [WCAG 2.1.1, 2.4.3, 2.1.2]
|
||||||
|
- **Focus:** every control has a visible focus ring (the base ring in `index.css`; don't add a second). A
|
||||||
|
sticky or fixed layer never covers the focused element. A list item the keyboard lands on rings 2 px
|
||||||
|
outside itself where it has a gap round it (a card); a table row, a log line or a row flush in a
|
||||||
|
clipping card rings inside itself, following its corners, since outside the ring ran under the table
|
||||||
|
head or was clipped away. [WCAG 2.4.7, 2.4.11]
|
||||||
|
- **Menus and tabs follow WAI-ARIA APG:** arrow keys move, Home/End jump, Escape closes and returns focus;
|
||||||
|
tabs carry `tablist`/`tab`/`tabpanel` roles.
|
||||||
|
- **Feedback:** a finished action is confirmed briefly (a toast that leaves by itself); anything that must
|
||||||
|
be read or acted on is not only in a toast. Status changes are announced through a live region.
|
||||||
|
[Nielsen #1, WCAG 2.2.1, 4.1.3]
|
||||||
|
- **Errors:** a failed write goes through `meta.failTitle` / `notifyActionError`, never a silent `catch`.
|
||||||
|
An error sits next to what failed and says how to recover; a field error is under its field and linked
|
||||||
|
with `aria-describedby`. Colour is never the only signal. [Nielsen #9, WCAG 3.3.1, 1.4.1]
|
||||||
|
- **Loading:** anything slower than ~300ms shows a loading state; a near-instant load doesn't flash one.
|
||||||
|
Space is reserved for late content so the layout doesn't jump; the form is under Surfaces (B8).
|
||||||
|
- **Reordering:** anything you can drag also has a button or menu way to move it (Top / Up / Down / Bottom),
|
||||||
|
and a drag handle carries `touch-action: none` so a finger drags instead of scrolling. [WCAG 2.5.7]
|
||||||
|
A list whose rows have a "…" (a YouTube playlist, a Plex playlist: a block among the blocks, an
|
||||||
|
episode within its show) puts the four moves there (`ui/useOrderItems`); the saved views' editor
|
||||||
|
shows them as four buttons with the item's place beside the selected view; a side panel in edit mode
|
||||||
|
gives each group an Up / Down pair (`ui/OrderButtons`, `compact`). A move that would go nowhere is
|
||||||
|
shown off, not hidden. A grip also picks up from the keyboard (space, the arrows, space).
|
||||||
|
- **Motion:** 150–300ms micro-interactions, transform/opacity only; `prefers-reduced-motion` is respected.
|
||||||
|
[WCAG 2.3.3]
|
||||||
|
|
||||||
|
## Players — _Decided_ (C3, C5, built in S3f)
|
||||||
|
|
||||||
|
- **A tap on the Plex video only shows or hides the controls** (C3); play/pause is the button. The mouse keeps
|
||||||
|
click-to-pause. Measured live: before this, a tap with the bar hidden both revealed it and paused. [Nielsen #3]
|
||||||
|
- **The hide timer starts with playback,** not with the first touch (before, on touch, the bar stayed up until
|
||||||
|
the first tap however long the video played).
|
||||||
|
- **The bars stay up while the pointer rests on them** (F22, owner); leaving starts a fresh 3 s. YouTube and
|
||||||
|
Plex Web do the same: before, the row faded out from under a mouse parked on Next.
|
||||||
|
- **Volume is the native `Slider`** (A6); the hand-drawn bar had no hover, focus, keyboard or screen-reader
|
||||||
|
state. A pointer drag gives the focus back, so Space and the arrows keep driving the player.
|
||||||
|
- **The seek bar:** the track stays thin inside a 2.75rem (44px) hit area with `touch-action: none`, so a finger
|
||||||
|
drag scrubs instead of panning; the scrub head is always visible on touch. Controls are 44 on touch. [WCAG 2.5.8]
|
||||||
|
- **The Plex control set (C5, the owner's model):** one row that fits a portrait tablet.
|
||||||
|
- No Stop (closing the player is stopping), no Restart, no ±10 s buttons.
|
||||||
|
- **Back:** more than 3 s in → back to the start; within the first 3 s → the previous item, at its resume
|
||||||
|
point if it has one, else its start. The first item has only the restart.
|
||||||
|
- **Next** shows only when there is a next item (an episode, a playlist queue). It starts that item at its
|
||||||
|
resume point and leaves one behind on the current item, which does not count as watched unless it is
|
||||||
|
already inside the finish margin (the credits).
|
||||||
|
- **±10 s:** the keyboard keeps ← / →; on touch a double-tap on the left or right third of the picture skips
|
||||||
|
∓10 s, while a single tap still only shows the controls. Touch never loses a function. [WCAG 2.5.1]
|
||||||
|
- The row: play/pause, Back, Next, mute, the time, audio & subtitles, a fixed **⋯** (player settings, media
|
||||||
|
info, stats, keyboard shortcuts, download the original file), fullscreen. The volume slider is
|
||||||
|
mouse only: a tablet's volume is its buttons.
|
||||||
|
- **Restarting lives on the media card** (C5): not started → Play; started → **Continue** (primary) +
|
||||||
|
**Restart**, the video card's own pair and words (`MediaResumeButtons`, one component for every card and
|
||||||
|
Home's hero). A poster stacks the pair; a landscape picture sets it side by side, icon-only on a small one.
|
||||||
|
On touch a tap continues and the card's "…" has "Play from the beginning" under its Play row. The restart
|
||||||
|
applies to the item the player opened on (`fromStart`); every item it moves on to resumes.
|
||||||
|
- **The YouTube modal:** its chrome uses the `Button` rungs (44 on touch), the side arrows stay inside the
|
||||||
|
screen, and the description opens from an ⓘ button, because a hover popover never opens on a tap (measured
|
||||||
|
live). [WCAG 1.4.13]
|
||||||
|
|
||||||
|
## Dialogs — _Decided_ (C4, built in S3f)
|
||||||
|
|
||||||
|
- **Initial focus by purpose** (`Modal`'s required `initialFocus`, so no dialog inherits "the first
|
||||||
|
focusable", which is always the ✕): `field` — a form opens on the body's first control, and one that
|
||||||
|
loads after opening moves the focus there when it arrives, as long as the focus still rests on the ✕;
|
||||||
|
`action` — a confirm, where the least destructive button takes `autoFocus`; `close` — anything to read,
|
||||||
|
browse or manage. [WAI-ARIA APG]
|
||||||
|
- **The header and the footer are fixed; only the body scrolls,** with the B9 fade at each edge, so the
|
||||||
|
primary action stays on screen when the on-screen keyboard takes half of it (the viewport's
|
||||||
|
`interactive-widget=resizes-content` makes the keyboard shrink the layout, where Chrome would otherwise
|
||||||
|
slide it over a fixed dialog). A fade shows only while content is under it (the body's scroll timeline):
|
||||||
|
a dialog that fits draws none. [Nielsen #3, WCAG 2.4.11]
|
||||||
|
- **The footer is the dialog's commit row** — Cancel / Save, Start, Retry / Close, a confirm's choices —
|
||||||
|
placed with `ModalFooter` from any depth of the body. A section's own buttons (a sub-form inside a
|
||||||
|
manager, a list row's Delete) stay in the body; a manager with no commit row has no footer.
|
||||||
|
|
||||||
|
## Copy — _Decided_ (S3g)
|
||||||
|
|
||||||
|
- Every user-facing string is in both Hungarian and English with zero key drift, and a user-facing error is
|
||||||
|
a structured `{code, reason}` translated on the client. Hungarian speaks to the user as "te".
|
||||||
|
- **One name per feature.** The nav rail's label is the feature's name; page titles, menus, shortcuts,
|
||||||
|
settings tabs and onboarding use the same words. The rail may shorten them ("Config" for
|
||||||
|
"Configuration", "Listák" for "Lejátszási listák"), never swap in another word. [Nielsen #4]
|
||||||
|
- **One verb per meaning.** Delete / Törlés: gone for good. Remove / Eltávolítás: out of a list, the
|
||||||
|
thing itself survives. Dismiss / Elrejtés: a notice leaves the view. Discard / Elvetés: unsaved edits
|
||||||
|
are thrown away. A confirm uses its action's verb. [Nielsen #4]
|
||||||
|
- **Sentence case.** Capitals only for brand names (YouTube, Plex, Google) and the app's places used as
|
||||||
|
places ("Hide from Feed", "Megnézés a Könyvtárban"). [Material, HIG]
|
||||||
|
- **"…" when the command asks first.** A label ends in "…" when it opens a dialog, form or picker before
|
||||||
|
anything happens ("Edit tags…"); not on a submenu (its chevron says it) or an immediate action. Progress
|
||||||
|
text ("Loading…") keeps its own. [HIG, Windows]
|
||||||
|
- **Messages.** A toast or error of one sentence has no full stop and no "✓": "Couldn't sync
|
||||||
|
subscriptions" / "Nem sikerült szinkronizálni". Two sentences keep their stops. Help text is prose.
|
||||||
|
- **Quotes** around a name are curly: “abc” in English, „abc” in Hungarian.
|
||||||
|
- **Dates**, in the UI language, only from `lib/format.ts` (lint: `local/date-format`): recency in a list
|
||||||
|
is relative ("3d ago" / "3 napja") through `ui/RelativeTime`, which carries the exact minute on hover;
|
||||||
|
a date is "Sep 27, 2026" / "2026. szept. 27."; a moment "Sep 27, 2026, 08:30 AM" /
|
||||||
|
"2026. szept. 27. 08:30"; seconds only in the server log, the audit and the recorder.
|
||||||
|
- **Units.** A figure with a unit (a length, a countdown, a range of days) is the browser's short form
|
||||||
|
(`Intl`), through `formatUnit` / `formatRuntime`: "90 min", "2 hr 13 min" / "90 p", "2 ó 13 p"; a fixed
|
||||||
|
label holding one is written in that same form ("Under 90 min"). Never a bare letter ("90m", "24h",
|
||||||
|
"7d"; test-held). In a sentence, and in a list of time spans named in words ("24 hours", "1 week"), the
|
||||||
|
unit is a word. A clock-style length on a picture stays "2:34:11".
|
||||||
|
- A label says what happens ("Delete playlist"), not "OK"; a destructive confirm names the object.
|
||||||
|
|
||||||
|
## Anti-patterns
|
||||||
|
|
||||||
|
- A flat `bg-card` / `bg-surface` box where a glass surface belongs.
|
||||||
|
- A hand-rolled control that restyles what a primitive already does.
|
||||||
|
- Information or actions that exist only on hover, or only behind a right-click.
|
||||||
|
- A click handler on text that swallows the user's text selection.
|
||||||
|
- A colour literal, a px font size, a px-frozen box.
|
||||||
|
- An empty state shown for an error.
|
||||||
|
|
||||||
|
## Decision log
|
||||||
|
|
||||||
|
| Date | Decision | Chosen | Over | Why |
|
||||||
|
| ---------- | ------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| 2026-09-24 | Floating button | Back to top: a round 48 glass FAB; "round" = floats over something (media or the page) | A Material 3 rounded square; a labelled pill; the old 64 circle | The common web convention for back to top; round tells it apart from every toolbar button; 64 was oversized |
|
||||||
|
| 2026-09-24 | Device contract | Tablet portrait + landscape and up; phones out | A 1024px floor with a "too small" message | Portrait tablet use is real, and the app already renders at 820 portrait |
|
||||||
|
| 2026-09-24 | Card actions on touch | Fewer visible, the rest in a "…" menu | 44px buttons that no longer fit the card | Six 44px targets need more width than a card has |
|
||||||
|
| 2026-09-24 | Action placement | One menu with several triggers, never gesture-only, a desktop background menu that mirrors the toolbar | Visible buttons for everything | Fewer non-critical buttons on screen, without hiding any function |
|
||||||
|
| 2026-09-24 | Text selection | Text stays selectable; a click that ends a selection does nothing | Click-to-play on titles | The user must be able to copy any text the app shows |
|
||||||
|
| 2026-09-24 | A1 Button shape | r8 + a rung per context; glass is a Button variant | r12; a pill | Nested corners (r8 in r12 in r16); the pill already means "a filter or a choice" |
|
||||||
|
| 2026-09-24 | A2 Icon buttons | Two sizes, r8; a round button only on media | One size; all round | A lone toolbar icon at the row size looks lost; round rows read as tags |
|
||||||
|
| 2026-09-24 | A3 Touch size | 44 for every control primitive | 36 + gaps; Button only | One rule (HIG 44) instead of two sizes nobody remembers |
|
||||||
|
| 2026-09-24 | A4 Input | Bordered, a 55% fg-into-bg edge (≥ 3:1) | Filled; today's look as a primitive | The only option that clears WCAG 1.4.11 without a heavy fill on glass |
|
||||||
|
| 2026-09-24 | A5 Dropdown trigger | Pill in a toolbar, r8 in a form | Pill everywhere; r8 everywhere | Matches the neighbours in each context |
|
||||||
|
| 2026-09-24 | A6 Checkbox, switch, slider | Native, accent-tinted, the row is the target | Drawn primitives | Free keyboard / screen-reader / forced-colours support; only 3 sites |
|
||||||
|
| 2026-09-24 | B1 Density | Compact 32 / 28 / 24 by explicit height | Standard 38 / 32 / 26 | The owner's read of the lab; touch is covered separately (A3), and one toolbar row lines up at 28 |
|
||||||
|
| 2026-09-24 | B2 Scheme set | Midnight, Forest→cyan, Slate→fuchsia, Matrix→lime | Today's six | The accent collided with danger (YouTube), warn (Slate) and success (Forest, Matrix); Midnight and Starship were one scheme twice; 4 of 6 light accents failed 4.5:1 |
|
||||||
|
| 2026-09-24 | B3 State layers | Text colour at 10% / 16% over anything | Material's 8 / 12%; today's opacity + bg-card | Visible in every scheme and mode (≥ 1.2:1); today's row hover was 1.00:1 on a card |
|
||||||
|
| 2026-09-24 | B4 Page chrome | One sticky band + fade; filters fold when short | A floating glass bar; everything scrolls | Tabs and filters always in reach, in one place; a keyboard-up tablet keeps its content |
|
||||||
|
| 2026-09-24 | B5 Surfaces | One class + radius per role | Today's per-site mix | Nested corners and no per-site alpha drift |
|
||||||
|
| 2026-09-24 | B6 /watch | The app's surfaces, native video controls | Flat and minimal | The page strangers see should look like the product; native controls work on any device |
|
||||||
|
| 2026-09-24 | B7 Nav rail | Soft tint + edge bar, optional labels (a user setting), slim | Solid accent (recommended); a Material rail | The owner's combination: calmer than a solid fill, labels for who wants them, no width cost |
|
||||||
|
| 2026-09-24 | B8 Loading | Skeleton by shape, spinner in a control | A spinner everywhere | Nothing jumps when data lands; a long wait doesn't look stuck |
|
||||||
|
| 2026-09-24 | B9 Fade | Toolbars / headers, panel tabs, dialog edges, chip rows | Thumbnails; buttons | The north-star depth where content scrolls under chrome; not on content or small controls |
|
||||||
|
| 2026-09-24 | C1 Action classification | The table (lab C1): state + primary at rest, everyday toggles on hover, "…" on touch, destructive into the menu | Visible buttons per site, as today | One action list per object, nothing gesture-only, fewer buttons without losing a function |
|
||||||
|
| 2026-09-24 | C2 Side panels | A tap opens an overlay drawer; pin is a separate button ≥ 1024 | A tap pins and the content reflows | Below 768 the filters were unreachable; on touch a tap pinned and pushed the grid |
|
||||||
|
| 2026-09-24 | C3 Players on touch | Tap = show the controls; 44px seek hit area; 44 buttons; the YT description behind ⓘ | Today (tap also pauses; 7.5px seek bar) | Measured live: you couldn't reach the seek bar without pausing, and the hover-only description never opened on a tap |
|
||||||
|
| 2026-09-24 | C4 Dialogs | Focus by purpose; a pinned footer | Focus on Close; the footer scrolls | A form starts in its first field, and Save stays on screen with the keyboard up |
|
||||||
|
| 2026-09-24 | C5 Plex player controls | The owner's lean set: no Stop / Restart / ±10 s buttons, Back by position, Next only with a next item, Resume + From the start on the card, touch double-tap ±10 s | Every control in one row (it ran off-screen below 1024); Back by a press counter; ±10 s keyboard-only | The row fits a portrait tablet; the decision to restart is made on the card; a tablet keeps the 10 s skip |
|
||||||
|
| 2026-09-24 | Glass tuner | Retired; the schemes are the only colour choice | Keep it with ranges clamped to the rules | Free tuning is the drift the constitution exists to stop (owner) |
|
||||||
|
| 2026-09-24 | Matrix toned down | Moss #86a05a at Midnight's lightness, near-neutral greys; every white-on-accent part → `--accent-fg` | The B2 lime #a3e635 | The owner found the lime neon and tiring; it was the loudest accent (L 0.85, C 0.21), and fixed white parts sat on it at 1.5:1 |
|
||||||
|
| 2026-09-24 | Field primitives | `Input` / `Textarea` / `InnerInput` + `fieldClass`, `SelectTrigger`, the native controls; the lint rule "no raw `<input>`" | `inputCls` and ~40 hand-styled boxes | Six edges, four radii and two focus looks for one control; the ≥ 3:1 edge held nowhere |
|
||||||
|
| 2026-09-25 | Surface roles | `SURFACE` roles in `ui/surfaces.ts`; the lint rule "no bare glass class"; menus p-1 with r8 rows | Three tiers paired ad hoc with five radii and three menu paddings | ~140 sites drifted; nested corners only hold if the radius comes with the role |
|
||||||
|
| 2026-09-25 | Static labels | Flat tonal `Badge`, no edge; a filtering tag is an edged chip | A glass pill with an edge, like the chips | Edged = pressable at a glance; a static tag that looks like a chip gets clicked and does nothing |
|
||||||
|
| 2026-09-25 | Header search | A 40 glass pill with the field edge (`lg`); what sits inside it is round | 44, r16, a glass box with a faint edge | The one rounded box among pills, and its edge under 3:1; the hero search is the YouTube/Gmail pill |
|
||||||
|
| 2026-09-25 | Switch row (F12) | The whole settings row toggles (`SwitchRow`; a hand-laid row points a label at the switch) | Only the 36×24 switch toggles | The iOS / Material settings row; the text was a dead click beside the control it names |
|
||||||
|
| 2026-09-25 | Edges and hovers (F14) | The ≥ 3:1 edge on the field family (fields, the form trigger); labelled pills keep the faint edge and the accent-edge hover | The ≥ 3:1 edge and a state-layer hover on every control | 1.4.11 asks an edge only where nothing else identifies the control; a heavy edge on every chip and tab weighs the whole UI down |
|
||||||
|
| 2026-09-25 | Rating and live (F16) | Own tokens: `--rating` (theme-scoped gold), `--live`/`--live-fg`, `--rating-media`, `--media-chrome`; palette classes lint-refused | Map them to `--warn` / `--danger` | A gold star and a red LIVE are what the eye expects; as warn/danger they would read as a status. The old amber score was 2:1 on light |
|
||||||
|
| 2026-09-25 | Fjord trial (B2 exception) | A fifth scheme, temporary: Midnight's neutrals, Playnite's steel accent (#337599 lifted to #5aa1cb) and steel rules; exit = fold into Midnight or remove | Retune Midnight straight away; borrow only the qualities | The owner wants to live with the calmer palette app-wide before it replaces the default; it breaks "own hue family" on purpose, and only until that call |
|
||||||
|
| 2026-09-25 | Whole-card row (owner) | A card row's B3 layer is the card's `::before` under its content; the open button's hit layer spans the card, the actions sit above it | The name button's own hover, ending at the first badge or button | A row that half-answers the pointer reads as broken; a badge click did nothing |
|
||||||
|
| 2026-09-25 | Badge tones (F29) | `Badge tone` neutral / accent / warn at 6%; light `--warn` #b45309 → #a14a07; edged label spans lint-refused | Keep edged status pills (a private Badge in Users, Settings "Granted", the suggestions count) | Edged = pressable at a glance; amber-700 was 4.58:1 as text and fell under 4.5 on its own tint |
|
||||||
|
| 2026-09-26 | Page band built (B4) | Controls only in the band (intros scroll); the fold shows the same row in place; one `h1` per page (`PageHeading` steps the hidden one aside); `SectionLabel` headings; Skip to content | A popover of the folded filters; the page title kept visible | A folded row is still the same controls, nothing duplicated; the rail and the tab title already name the page |
|
||||||
|
| 2026-09-26 | Fade built (B9) | `fade-below` on sticky toolbars and table headers; the scroller mask where the band sits on the backdrop | A painted band over the backdrop | The backdrop is the colour under the band, so fading the content into it is the same edge |
|
||||||
|
| 2026-09-26 | Viewport units | `dvh` everywhere (the Plex player stage with S3f) | `vh` / `h-screen` | `100vh` hides the last rows under a tablet browser's toolbar |
|
||||||
|
| 2026-09-26 | Video actions built (C1) | One list per video (menu, right-click, long-press, "…"); the card keeps watched / save / add to playlist on its picture on hover; download, details, copy, hide, reset in the menu | The six-button row under the card and the hover ⓘ | The menu is the one door every device has; the card loses a line of height and the touch card its row of 28px buttons |
|
||||||
|
| 2026-09-26 | Plex actions built (C1) | One list per Plex title (play/open, watched, add to playlist, media info, copy title, pin); the poster keeps watched / add / "…" on its picture on hover; seasons get open / watched / add / copy | The hover ⓘ, the pin-only menu, an episode's add button at rest, a collection's "Remove" X on the card | The same door as a video's; the collection grid goes to two columns on a phone so the touch "…" leaves the title its line |
|
||||||
|
| 2026-09-26 | Playlist actions built (C1) | A playlist row's "…" (play, pin, copy title / link, move, order, remove), Delete + an undo toast; one list per playlist (rename, pin, YouTube open / push / revert, delete) from its rail row and its header's "…" | The row's X column and its confirm; the header's pencil, YouTube link, push, revert and trash buttons | Remove is reversible, so it needs no confirm; the header keeps Play all; a narrow table drops "#" and Channel so the title keeps a width |
|
||||||
|
| 2026-09-26 | Channel + Plex list actions (C1) | A channel row's "…" (open, pin, YouTube, copy, full history, hide + undo, unsubscribe); a Plex playlist's title / show / season "…", Delete + undo; its header's "…" (rename, delete); an action toast lasts ≥ 8 s | The channel row's full-history, hide and unsubscribe buttons; the Plex list's red X on every row, show and season; the header's pencil and trash | Hiding and removing are reversible, so they need no confirm and the undo needs time; unsubscribe and deleting a playlist keep theirs |
|
||||||
|
| 2026-09-26 | Download / notification / Scheduler actions (C1) | A download row's "…" (play, share, save, edit, details, re-download, manage sharing, copies, cancel / delete / remove); a library row's Share stays in sight, ahead of save; a notification row's "…" and hover dismiss; the inbox band's and the Scheduler strip's "…" | The download row's five rare buttons and its cancel / delete / remove; the source link's copy button; an activity notice's Find / Undo links; the band's Clear everything; the strip's Purge discovery | The destructive rows keep their confirm, and Clear everything gains one; a row link keeps the browser's own menu; Find in feed drops the tag / search / age filters that could hide its video |
|
||||||
|
| 2026-09-26 | Channel menu: Tags + Priority (C1) | Tags A–Z as in-place checks; Priority as fixed steps 10 · 5 · 3 · 2 · 1 · 0, the current one ticked; a pick re-sorts at once and the page + focus follow the row; one drawn `MenuCheck` for every checkable row that cannot be an `<input>` | Relative steps (+1 / +5); straight to the top; the stepper only; seven hand-drawn check marks (three box sizes, a tinted box, a × for "on", a trailing tick) | Bubbling a channel up is one pick; fixed levels keep the scale the reader already uses; the list moving where the reader can see it is the feedback |
|
||||||
|
| 2026-09-26 | Reorder without a drag (WCAG 2.5.7) | Row menus: Top / Up / Down / Bottom (YouTube + Plex playlists); saved views: four buttons with the place beside the selected view (owner: A); side panels in edit mode: an Up / Down pair per group (owner: A), the name wrapping there | A "…" on every saved-view row; a "…" on every panel group; drag only | A visible, one-click path where the list is edited; the edge moves shown off so the buttons never shift |
|
||||||
|
| 2026-09-26 | Titles in click surfaces | The surface stays a button, flagged `selectable`: its text selects, and the click that ends a selection does nothing; every plain `Pressable` pinned with its reason | Rebuilding each card and row so the title sits outside the button; exceptions for master-detail lists | Chrome selects inside a button once it is `user-select: text` (measured), so one flag on the primitive keeps the keyboard, focus and list walking every surface already has |
|
||||||
|
| 2026-09-26 | Right-click on a selection | The browser's own menu opens on selected words; beside them the object's menu | A "Copy selection" row in the app menu | The reader selected the words to do something with them; the native menu copies, searches and translates, and changes nothing else |
|
||||||
|
| 2026-09-26 | Keyboard ring on a flush row | Inside the row (a table row by its tag, a log line, a show header in its clipping card, with the card's corners) | The ring outside, as on a card | Outside it ran under the table head and past the table, and a clipping card cut it away entirely (invisible focus) |
|
||||||
|
| 2026-09-26 | Fade at rest | The sticky header's fade appears with the first 1rem of scroll (a scroll-driven animation; no fade where unsupported) | A fade that is always drawn | At rest it dimmed the first row and hid its ring's top edge; nothing is under the header yet |
|
||||||
|
| 2026-09-26 | Narrow playlist table | "#" and Channel hide by the table's own width (under 42rem), not the window's | Hiding below the `sm` window width | Beside the playlist rail a desktop window left the table 432 px and the title 0 px |
|
||||||
|
| 2026-09-26 | Background menu built | Five list pages (feed, Plex grid, channels, playlist, notifications); the toolbar's sort / view / filters and the band's actions; no destructive row | Every page with a toolbar; the "…" menus copied whole, delete included | The tab-only pages have nothing a menu would add; a blank-area right-click must not be one click from delete |
|
||||||
|
| 2026-09-26 | Media card anatomy (C) | One frame; the meta line pinned to its bottom; the length a badge on the picture; the Plex card on the same primitive as the video card | A: the year as an overline over the title; B: the year inline after it; a fixed two-line title slot | A one-line title left a hole the eye reads as a missing line; C puts nothing under a variable-height title |
|
||||||
|
| 2026-09-26 | One Play | `MediaPlayGlyph` (the media button variant) and only where the click plays | The Plex card's hand-painted accent circle; a playable dot on every card | One Play for every card; a dot that only a colour and a tooltip explain says nothing |
|
||||||
|
| 2026-09-26 | Filtering values on the info page | A value that filters (year, certificate, studio, director) is an edged chip; a fact is flat text | Everything flat, the clickable ones only hover-tinted | The S3d rule: edged = pressable, so the reader sees which values do something |
|
||||||
|
| 2026-09-26 | Shelf density | On any strip: one title line over one meta line | The grid's two lines and full meta on a strip | The tallest card set the strip's height and left holes in the cards with less to say |
|
||||||
|
| 2026-09-26 | Compact list row (B) | The title leads; a faint "…" at the end; the toggles float over the date on hover | A leading 128px action column | That column sat empty at rest, a hole at the start of every row |
|
||||||
|
| 2026-09-26 | Media-info layout (B) | A main column (collections first, crew, cast) beside a facts sidebar; an empty column is not drawn | Every section full width, stacked | The collection is the most used; the keywords' column left a void beside a short details box |
|
||||||
|
| 2026-09-26 | Mixed Home shelf (A) | Plex titles as wide art beside videos | Posters, with the videos not stretched | Stretching a video card to a poster's height left an ~80px gap |
|
||||||
|
| 2026-09-27 | Home hero | A card (hover row + Play on the picture, the whole menu, a touch "…"; owner: A); three quarters wide and centred on a wide window; one fact line; the summary at the measure | The hero's pin row alone; full width; an 18rem picture column | The one answer stands in focus, and it offers what the same card offers anywhere |
|
||||||
|
| 2026-09-27 | Home shelf tiles | A channel / playlist tile: Play, ↗ open, "…" with Open; a show acts on its next episode, ⓘ opens it; the shelf slot 232px | The tile's pin row alone; 200px | A tile without its object's actions read as broken; at 200 the Play touched the hover row |
|
||||||
|
| 2026-09-27 | Synopsis preview (C) | A ~0.9 s mouse rest opens a passive panel beside the card | The synopsis as the picture's `title` tooltip; an ⓘ to hover; none | One long tooltip line fired anywhere on the art and clashed with the cut title's own tooltip |
|
||||||
|
| 2026-09-27 | Reading measure | Prose capped at ~65 characters a line (`measure`) | The container's full width | At 110–130 characters a line the eye loses the next line's start |
|
||||||
|
| 2026-09-24 | Chip family | One `Chip` (filter / token / tag / suggest / ghost) + `DismissibleChip`; nothing under 24; the lint rule "no raw `<button>` outside ui/" | Five chip geometries in five files | The applied-filter row, the tag cells, the year picker and the saved-view editor each drew their own chip; a one-letter tag was 21px wide |
|
||||||
|
| 2026-09-24 | Button families | `Button` / `IconButton` / `Pressable` / chips / `DragHandle`; `lg` 40 for a hero CTA; pills and a 48 Play on media (from C5) | Every button hand-painted | 268 hand-rolled buttons in 79 files drifted into as many looks; the ladder only holds if every button stands on it |
|
||||||
|
| 2026-09-27 | C4 as built | `initialFocus` required on every `Modal`; the commit row in `ModalFooter`, portalled under the body from any depth; the fades driven by the body's scroll timeline; `interactive-widget=resizes-content` | An optional prop defaulting to the ✕; a `footer` prop (the issue composer's buttons live three components down); always-on fades | A default is how 21 dialogs came to open on the ✕; lifting the composer's state to reach a prop is the larger change; a resting fade dimmed the first line |
|
||||||
|
| 2026-09-27 | C3 + C5 as built | A tap toggles the bar through `pointerType` from `pointerdown`; a double-tap takes back its first tap's toggle; the card's pair is the video card's `MediaResumeButtons` (Continue / Restart), a sibling over the picture; the YT description is an ⓘ click popover | A delayed single tap (wait out the double-tap window); new "Resume / From the start" words for Plex; the pair inside the picture's button | A delayed tap feels dead; one media card says one pair of words; a button inside a button is invalid and the hover row already sits beside the picture |
|
||||||
|
| 2026-09-27 | C2 as built | The side panel's drawer is `ui/Modal` `drawer-start`; drawer = a coarse pointer or below 768; the pin offered in its header ≥ 1024 | A drawer of its own inside `SidePanel` | Esc, Back, the scrim, the trap and the scroll lock are the dialog's; a second implementation is how two shells drift |
|
||||||
|
| 2026-09-27 | Narrow rows by container | The download row, the playlist table and the Channels tag row stack by a container query; a fact line never wraps | Breakpoints on the window | The iPad zoomed in, split screen and a column beside a rail are narrow while the window is not; the zoomed download row broke "18.9 / MB" apart |
|
||||||
|
| 2026-09-27 | Phones deferred | A phone-only layout (the hamburger nav, a folded feed band) waits for its own epic; E28 stays desktop + tablet | Fix the phone views inside E28 | Principle 2; the first phone attempts made the feed worse, and the epic's review grows with every sprint |
|
||||||
|
| 2026-09-27 | Conformance check (S3g) | A four-step check before a UI change is done: primitive at a named rung, the chapter re-read, the rendered page measured by kind and look, every state seen | A reviewer's eye per sprint | The S1 probe found 69 button looks; after S3 every control kind is drawn by its primitive, and the check keeps a new look from arriving unnoticed |
|
||||||
|
| 2026-09-27 | Segmented rung | `SegmentedToggle` defaults to the toolbar rung (28, `text-xs`); 24 only for a panel's captioned match toggle | The small default | Two toolbars had inherited 10px type from the default beside 28px buttons |
|
||||||
|
| 2026-09-27 | Copy rule (S3g) | One name per feature (the rail may shorten it); four verbs for four meanings; sentence case with the app's places capitalised; "…" when a command asks first; one-sentence messages without a stop; curly quotes; dates relative in lists with the exact minute on hover, three absolute forms; units from Intl, short | Word-for-word names everywhere; the verbs as they were; all lower case; no "…"; always a stop; always absolute dates; long or compact units | The copy survey found 7 EN and 6 HU double names, 22 Delete against 27 Remove, five date shapes and three spellings of a minute; the owner picked every recommended option |
|
||||||
|
| 2026-09-27 | One glyph, one meaning (S4) | Play only for playback or resuming a paused transfer; Retry = `RefreshCw`; reset / revert = `RotateCcw` | Retry drawn with Play | The first UI-review run (Downloads) found a failed row whose Retry showed ▷ in the row and its menu |
|
||||||
|
| 2026-09-27 | Fjord kept | Fjord stays a fifth scheme beside Midnight; the trial wording goes | Fold it into Midnight; remove it | The owner lived with it and likes it; the vivid Midnight stays for those who want it. B2's own-hue rule still binds any new scheme |
|
||||||
|
| 2026-09-27 | Tablet columns | A table hides `tabletHidden` columns below 64rem of its own width; Channels drops Prio, About, Length | Hide About only; give the name a bigger share | At 1024 the channel name had 60 px; the owner chose to drop all three there |
|
||||||
|
| 2026-09-27 | Glyph pager | First / previous / next / last as glyph buttons round the page box, names as tooltips | Words beside the arrows (hidden only on a phone) | The owner's call: the words widened the row; four arrows are the shared convention |
|
||||||
|
| 2026-09-27 | Match switch on the panel | `ui/MatchToggle` above the picker from two picks, everywhere; one wording (Any / All) | The picker's popover footer (Feed, Plex collection) | A closed picker hid an active "all"; six pickers had drawn it two ways |
|
||||||
|
| 2026-09-27 | Missing picture, bare link | A glyph of the kind for no picture; a download with no name is titled by its short link, once | An empty box; the URL as title, channel and link | The owner's picks 4A and 3A: an empty box read as loading, the URL showed three times |
|
||||||
|
| 2026-09-27 | Copy on a selection | Right-click on selected words keeps the browser's menu; no app Copy row | An app menu with Copy first | A real browser's menu has copy, search and translate; only the desktop pane's is thin |
|
||||||
@@ -3,6 +3,17 @@
|
|||||||
How to run Siftlode locally and pass the gate. Conventions for the backend code itself live in
|
How to run Siftlode locally and pass the gate. Conventions for the backend code itself live in
|
||||||
[`backend/app/README.md`](../backend/app/README.md).
|
[`backend/app/README.md`](../backend/app/README.md).
|
||||||
|
|
||||||
|
## Node
|
||||||
|
|
||||||
|
One major, everywhere. `.nvmrc` pins it; the CI container, the `Dockerfile`'s frontend build stage,
|
||||||
|
`frontend/package.json`'s `engines` and `@types/node` must all match it — and so must the machine you
|
||||||
|
run the gate on. `frontend/src/lib/nodeEngine.contract.test.ts` fails the gate the moment any of them
|
||||||
|
drift apart, including when yours is the odd one out. `nvm use` in the repo root picks it up.
|
||||||
|
|
||||||
|
This is a contract, not a preference: tsc and vitest read the engine's own `Intl` and web APIs, and
|
||||||
|
those change between majors — the same currency format renders `$160M` on one and `$160.0M` on the
|
||||||
|
next. A gate that is green on a major you do not ship is green about nothing.
|
||||||
|
|
||||||
## Run it locally
|
## Run it locally
|
||||||
|
|
||||||
The whole stack (Postgres + API + worker) comes up from the localdev compose file:
|
The whole stack (Postgres + API + worker) comes up from the localdev compose file:
|
||||||
@@ -21,6 +32,10 @@ For a fast frontend edit loop, run the Vite dev server with HMR against that API
|
|||||||
cd frontend && npm install && npm run dev # http://localhost:5173, proxying the API on :8080
|
cd frontend && npm install && npm run dev # http://localhost:5173, proxying the API on :8080
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The dev server also serves the **design lab** at `http://localhost:5173/design-lab`: the open design
|
||||||
|
decisions and the chosen patterns, rendered with the app's real CSS. It never reaches a production build
|
||||||
|
(see [`DESIGN.md`](DESIGN.md)).
|
||||||
|
|
||||||
## The gate
|
## The gate
|
||||||
|
|
||||||
A change is done when all of this is green. CI runs the same checks server-side
|
A change is done when all of this is green. CI runs the same checks server-side
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import type { Plugin } from "vite";
|
||||||
|
|
||||||
|
// The design lab (`src/design-lab/`) is a dev-only page: main.tsx reaches it behind
|
||||||
|
// `import.meta.env.DEV`, which Vite folds to `false` in a production build, so the dynamic import
|
||||||
|
// is dead code and the chunk is never emitted. That rule used to be a sentence; this makes it a
|
||||||
|
// build failure. If a future edit imports the lab unguarded (a static import, a shared helper, a
|
||||||
|
// re-export), the production build refuses instead of shipping the lab to every visitor.
|
||||||
|
const DEV_ONLY_DIR = "/src/design-lab/";
|
||||||
|
|
||||||
|
export function devOnlyGuard(): Plugin {
|
||||||
|
return {
|
||||||
|
name: "siftlode-dev-only-guard",
|
||||||
|
apply: "build",
|
||||||
|
generateBundle(_options, bundle) {
|
||||||
|
for (const [fileName, output] of Object.entries(bundle)) {
|
||||||
|
if (output.type !== "chunk") continue;
|
||||||
|
const leaked = Object.keys(output.modules).find((id) =>
|
||||||
|
id.replaceAll("\\", "/").includes(DEV_ONLY_DIR),
|
||||||
|
);
|
||||||
|
if (leaked) {
|
||||||
|
this.error(
|
||||||
|
`dev-only module ${leaked} reached the production bundle (${fileName}). ` +
|
||||||
|
"Import src/design-lab only behind `import.meta.env.DEV`.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -46,7 +46,8 @@ user), reset to a deterministic baseline every run by
|
|||||||
[`scripts/e2e_seed.py`](../../scripts/e2e_seed.py) (run
|
[`scripts/e2e_seed.py`](../../scripts/e2e_seed.py) (run
|
||||||
in-container so it reuses the app's models + argon2 hasher): the top-3 organic channels subscribed,
|
in-container so it reuses the app's models + argon2 hasher): the top-3 organic channels subscribed,
|
||||||
one in-progress + one watched + one saved video, prefs pinned to `language: en`. Credentials come
|
one in-progress + one watched + one saved video, prefs pinned to `language: en`. Credentials come
|
||||||
from `E2E_EMAIL` / `E2E_PASSWORD` (local-only defaults). Override the container name with
|
from `E2E_EMAIL` (default `e2e@siftlode.local`) and `E2E_PASSWORD` — **required, no default**: run
|
||||||
|
the suite through the ops wrapper, which exports it from the secret store. Override the container name with
|
||||||
`E2E_API_CONTAINER`; skip the in-container seed with `E2E_SKIP_SEED=1`.
|
`E2E_API_CONTAINER`; skip the in-container seed with `E2E_SKIP_SEED=1`.
|
||||||
|
|
||||||
## Selectors
|
## Selectors
|
||||||
|
|||||||
@@ -14,7 +14,9 @@ import { fileURLToPath } from "node:url";
|
|||||||
|
|
||||||
const STORAGE = "e2e/.auth/state.json";
|
const STORAGE = "e2e/.auth/state.json";
|
||||||
const EMAIL = process.env.E2E_EMAIL || "e2e@siftlode.local";
|
const EMAIL = process.env.E2E_EMAIL || "e2e@siftlode.local";
|
||||||
const PASSWORD = process.env.E2E_PASSWORD || "e2e-golden-master";
|
// No default: a password committed to a public repo is not a credential. The ops wrapper
|
||||||
|
// (`siftlode e2e` / `check-full`) exports it from the secret store.
|
||||||
|
const PASSWORD = process.env.E2E_PASSWORD ?? "";
|
||||||
const CONTAINER = process.env.E2E_API_CONTAINER || "siftlode-api-1";
|
const CONTAINER = process.env.E2E_API_CONTAINER || "siftlode-api-1";
|
||||||
const BASE_URL = process.env.PW_BASE_URL || "http://127.0.0.1:8080";
|
const BASE_URL = process.env.PW_BASE_URL || "http://127.0.0.1:8080";
|
||||||
|
|
||||||
@@ -25,6 +27,11 @@ const SEED_SCRIPT = resolve(HERE, "../../scripts/e2e_seed.py");
|
|||||||
const isLocalTarget = /\/\/(127\.0\.0\.1|localhost)[:/]/.test(BASE_URL);
|
const isLocalTarget = /\/\/(127\.0\.0\.1|localhost)[:/]/.test(BASE_URL);
|
||||||
|
|
||||||
setup("seed + authenticate", async ({ page }) => {
|
setup("seed + authenticate", async ({ page }) => {
|
||||||
|
expect(
|
||||||
|
PASSWORD,
|
||||||
|
"E2E_PASSWORD is not set — run the suite through `siftlode e2e` (it reads the secret store)",
|
||||||
|
).not.toBe("");
|
||||||
|
|
||||||
// 1. Preflight: fail fast (with a useful message) if the stack isn't running.
|
// 1. Preflight: fail fast (with a useful message) if the stack isn't running.
|
||||||
const health = await page.request.get("/healthz");
|
const health = await page.request.get("/healthz");
|
||||||
expect(
|
expect(
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ const focusSafeDisabled = {
|
|||||||
meta: {
|
meta: {
|
||||||
type: "problem",
|
type: "problem",
|
||||||
messages: {
|
messages: {
|
||||||
raw: "A raw <button disabled> throws a focused user to <body> when it turns disabled. Use ui/FocusSafeButton (or ui/Button).",
|
raw: "A raw <button disabled> throws a focused user to <body> when it turns disabled. Build the primitive on ui/FocusSafeButton; anywhere else use ui/Button, IconButton or Pressable.",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
create(context) {
|
create(context) {
|
||||||
@@ -82,6 +82,199 @@ const focusSafeDisabled = {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
// E28 S3g (docs/DESIGN.md "Copy"): the app shows a date in three forms plus the relative one, all
|
||||||
|
// built in lib/format.ts in the UI language. A bare `toLocaleString()` formats in the BROWSER's locale
|
||||||
|
// and as numbers with seconds; seven call sites had drifted into five shapes. Outside lib/format.ts
|
||||||
|
// a `toLocale*String` call or an `Intl.DateTimeFormat` / `Intl.RelativeTimeFormat` is refused (a
|
||||||
|
// number goes through formatNumber, which the same rule covers).
|
||||||
|
const dateFormat = {
|
||||||
|
meta: {
|
||||||
|
type: "problem",
|
||||||
|
messages: {
|
||||||
|
date: "Format dates and numbers through lib/format.ts (formatDate, formatDateTime, formatDateTimeSec, relativeTime, formatNumber), in the UI language.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
create(context) {
|
||||||
|
const path = context.filename.split("\\").join("/");
|
||||||
|
// lib/format.ts is where the forms live; a test computes its expectations; the Plex player's
|
||||||
|
// on-screen clock (lib/plexPlayerPrefs.ts) is a clock face with its own 12/24h switch, not a date.
|
||||||
|
if (/\/src\/lib\/(format|plexPlayerPrefs)\.ts$|\.test\.tsx?$/.test(path)) return {};
|
||||||
|
return {
|
||||||
|
"CallExpression > MemberExpression.callee[property.name=/^toLocale(Date|Time)?String$/]"(
|
||||||
|
node,
|
||||||
|
) {
|
||||||
|
context.report({ node, messageId: "date" });
|
||||||
|
},
|
||||||
|
"MemberExpression[object.name='Intl'][property.name=/^(DateTimeFormat|RelativeTimeFormat)$/]"(
|
||||||
|
node,
|
||||||
|
) {
|
||||||
|
context.report({ node, messageId: "date" });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
// E28 S3b: one primitive per button family (docs/DESIGN.md). 268 hand-rolled buttons in 79 files had
|
||||||
|
// drifted into as many looks, heights and hover styles; they were swept onto ui/Button, IconButton,
|
||||||
|
// Pressable, Chip and DragHandle, and this keeps the next one out. The raw element lives only in the
|
||||||
|
// files that ARE a control primitive — not in the rest of components/ui/, whose composites (a pager, a
|
||||||
|
// side panel, a toast) are built from the primitives like any feature component.
|
||||||
|
const BUTTON_PRIMITIVES = new Set([
|
||||||
|
"FocusSafeButton.tsx", // the focus-keeping <button> everything stands on
|
||||||
|
"Button.tsx",
|
||||||
|
"IconButton.tsx",
|
||||||
|
"Pressable.tsx",
|
||||||
|
"Chip.tsx",
|
||||||
|
"DragHandle.tsx",
|
||||||
|
"AnchoredPopover.tsx", // the menu row: floating-ui's roving focus reads its native `disabled`
|
||||||
|
"SegmentedToggle.tsx",
|
||||||
|
"Tabs.tsx",
|
||||||
|
"form.tsx", // Switch
|
||||||
|
"PriorityStepper.tsx",
|
||||||
|
"SortControl.tsx",
|
||||||
|
"SelectTrigger.tsx", // the dropdown trigger (A5)
|
||||||
|
"FilterSelect.tsx", // its option rows
|
||||||
|
"Combobox.tsx", // the field's chevron (A4)
|
||||||
|
]);
|
||||||
|
const buttonPrimitive = {
|
||||||
|
meta: {
|
||||||
|
type: "problem",
|
||||||
|
messages: {
|
||||||
|
raw: "A raw <{{name}}> outside the control primitives: use ui/Button (a labelled action), IconButton (a glyph), Pressable (a card or row that is the control), Chip or DragHandle.",
|
||||||
|
role: 'A <{{name}} role="button"> is a hand-built button (its own Enter/Space, no focus-safe disabled, no touch target): use ui/Pressable for a surface that is the control.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
create(context) {
|
||||||
|
const path = context.filename.split("\\").join("/");
|
||||||
|
const file = path.slice(path.lastIndexOf("/") + 1);
|
||||||
|
if (path.includes("/src/components/ui/") && BUTTON_PRIMITIVES.has(file)) return {};
|
||||||
|
return {
|
||||||
|
JSXOpeningElement(node) {
|
||||||
|
if (node.name.type !== "JSXIdentifier") return;
|
||||||
|
const name = node.name.name;
|
||||||
|
if (name === "button" || name === "FocusSafeButton")
|
||||||
|
context.report({ node, messageId: "raw", data: { name } });
|
||||||
|
// S3g: the same button spelled as a div with a role slipped past the rule above (my own
|
||||||
|
// S3h Plex picture). A component (capitalised) may take a role for its own reasons.
|
||||||
|
else if (
|
||||||
|
/^[a-z]/.test(name) &&
|
||||||
|
node.attributes.some(
|
||||||
|
(a) =>
|
||||||
|
a.type === "JSXAttribute" &&
|
||||||
|
a.name.name === "role" &&
|
||||||
|
a.value?.type === "Literal" &&
|
||||||
|
a.value.value === "button",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
context.report({ node, messageId: "role", data: { name } });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
// The same for a text field and the native checkbox / slider / colour well (docs/DESIGN.md A4, A6;
|
||||||
|
// E28 S3c): ~100 hand-styled boxes carried six edges, four radii and two focus looks between them. A
|
||||||
|
// file picker has no look (it is hidden behind a Button), so it passes.
|
||||||
|
const INPUT_PRIMITIVES = new Set([
|
||||||
|
"Input.tsx",
|
||||||
|
"NativeControls.tsx",
|
||||||
|
"PriorityStepper.tsx", // the number box laid over the stepper's own face
|
||||||
|
]);
|
||||||
|
const inputPrimitive = {
|
||||||
|
meta: {
|
||||||
|
type: "problem",
|
||||||
|
messages: {
|
||||||
|
raw: "A raw <{{name}}> outside the field primitives: use ui/Input, Textarea or InnerInput (inside a fieldClass frame), or Checkbox / CheckRow / Slider / ColorInput.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
create(context) {
|
||||||
|
const path = context.filename.split("\\").join("/");
|
||||||
|
const file = path.slice(path.lastIndexOf("/") + 1);
|
||||||
|
if (path.includes("/src/components/ui/") && INPUT_PRIMITIVES.has(file)) return {};
|
||||||
|
return {
|
||||||
|
JSXOpeningElement(node) {
|
||||||
|
if (node.name.type !== "JSXIdentifier") return;
|
||||||
|
const name = node.name.name;
|
||||||
|
if (name !== "input" && name !== "textarea") return;
|
||||||
|
const type = node.attributes.find(
|
||||||
|
(a) => a.type === "JSXAttribute" && a.name.name === "type",
|
||||||
|
);
|
||||||
|
const v = type?.value?.type === "Literal" ? type.value.value : null;
|
||||||
|
if (name === "input" && (v === "file" || v === "hidden")) return;
|
||||||
|
context.report({ node, messageId: "raw", data: { name } });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
// B5 (docs/DESIGN.md; E28 S3d): a glass surface is a ROLE from ui/surfaces.ts, which brings its radius.
|
||||||
|
// ~140 sites had paired the three glass tiers with five radii and three menu paddings. A class string
|
||||||
|
// (two or more tokens, so `variant="glass"` passes) holding a bare glass tier is refused outside the
|
||||||
|
// role table and the control primitives that paint a glass BUTTON face.
|
||||||
|
const SURFACE_OWNERS = new Set([
|
||||||
|
"surfaces.ts",
|
||||||
|
"buttonStyles.ts", // the `glass` button face
|
||||||
|
"SelectTrigger.tsx",
|
||||||
|
"fieldStyles.ts", // the header search floats over the page
|
||||||
|
]);
|
||||||
|
const GLASS_TIER = /^glass(-card|-menu)?$/;
|
||||||
|
// A glass surface's depth is its own box-shadow, whose inset top highlight a `shadow-*` utility would
|
||||||
|
// replace wholesale (the header search and the debug pill lost theirs). A shadow COLOUR only sets a
|
||||||
|
// variable, and `shadow-float` IS the surface's shadow, so both pass.
|
||||||
|
const SHADOW_SIZE = /^shadow-(xs|sm|md|lg|xl|2xl|\[.+\]|\(.+\))$/;
|
||||||
|
const isShadowSize = (w) => SHADOW_SIZE.test(w.slice(w.lastIndexOf(":") + 1));
|
||||||
|
const isSurfaceRole = (n) =>
|
||||||
|
n?.type === "MemberExpression" && n.object.type === "Identifier" && n.object.name === "SURFACE";
|
||||||
|
const surfaceRole = {
|
||||||
|
meta: {
|
||||||
|
type: "problem",
|
||||||
|
messages: {
|
||||||
|
raw: "A bare `{{tier}}` class: use a role from ui/surfaces.ts (SURFACE.panel / drawer / card / menu / popover / tooltip), which brings the radius too.",
|
||||||
|
shadow:
|
||||||
|
"`{{shadow}}` on a glass surface replaces its own shadow and drops the top highlight; the role's depth is the intended one.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
create(context) {
|
||||||
|
const path = context.filename.split("\\").join("/");
|
||||||
|
const file = path.slice(path.lastIndexOf("/") + 1);
|
||||||
|
if (path.includes("/src/components/ui/") && SURFACE_OWNERS.has(file)) return {};
|
||||||
|
if (path.includes("/src/lib/")) return {}; // no classes live there, only prose ("a glass tuner")
|
||||||
|
const check = (node, text) => {
|
||||||
|
if (typeof text !== "string") return;
|
||||||
|
const words = text.split(/\s+/).filter(Boolean);
|
||||||
|
// A lone word is a value (`variant="glass"`); a class string has company, or is a template
|
||||||
|
// fragment that a space joins to its neighbour.
|
||||||
|
if (words.length < 2 && text === text.trim()) return;
|
||||||
|
const tier = words.find((w) => GLASS_TIER.test(w));
|
||||||
|
if (tier) context.report({ node, messageId: "raw", data: { tier } });
|
||||||
|
};
|
||||||
|
// A role plus a shadow size in one class list: `${SURFACE.x} shadow-lg` or clsx(SURFACE.x, "shadow-lg").
|
||||||
|
const shadowBeside = (node, parts) => {
|
||||||
|
if (!parts.some(isSurfaceRole)) return;
|
||||||
|
for (const part of parts) {
|
||||||
|
const text =
|
||||||
|
part.type === "TemplateElement"
|
||||||
|
? part.value.cooked
|
||||||
|
: part.type === "Literal" && typeof part.value === "string"
|
||||||
|
? part.value
|
||||||
|
: "";
|
||||||
|
const shadow = text.split(/\s+/).find(isShadowSize);
|
||||||
|
if (shadow) context.report({ node, messageId: "shadow", data: { shadow } });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
Literal(node) {
|
||||||
|
check(node, node.value);
|
||||||
|
},
|
||||||
|
TemplateElement(node) {
|
||||||
|
check(node, node.value.cooked);
|
||||||
|
},
|
||||||
|
TemplateLiteral(node) {
|
||||||
|
shadowBeside(node, [...node.quasis, ...node.expressions]);
|
||||||
|
},
|
||||||
|
CallExpression(node) {
|
||||||
|
shadowBeside(node, node.arguments);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
// The words a screen reader or a tooltip says are user-facing text like any other, and the HU/EN
|
// The words a screen reader or a tooltip says are user-facing text like any other, and the HU/EN
|
||||||
// parity rule covers them too. Five `aria-label="remove"`-style literals survived every lane (the
|
// parity rule covers them too. Five `aria-label="remove"`-style literals survived every lane (the
|
||||||
// Plex playlist rows, a filter chip, the player's reset) and a whole opt-in panel was English only —
|
// Plex playlist rows, a filter chip, the player's reset) and a whole opt-in panel was English only —
|
||||||
@@ -152,11 +345,46 @@ const sortableA11y = {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
// An <a> whose content is only a glyph is a link that looks like a button (DESIGN.md): `IconLink`, with
|
||||||
|
// a name, a 24+ box and the icon button's layer. The E28 audit found five bare 14px ones. Text or an
|
||||||
|
// expression anywhere in its CHILDREN (not its attributes: every link has an `href={…}`) is a text
|
||||||
|
// link or a card, and passes; a self-closing <a> is a `Trans` slot that i18n fills with text.
|
||||||
|
const glyphLink = {
|
||||||
|
meta: {
|
||||||
|
type: "problem",
|
||||||
|
messages: {
|
||||||
|
glyph:
|
||||||
|
"a glyph-only <a> is a link that looks like a button: use ui/LinkButton's IconLink (a name, a 24+ box, the state layer).",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
create(context) {
|
||||||
|
const hasText = (children) =>
|
||||||
|
children.some(
|
||||||
|
(c) =>
|
||||||
|
(c.type === "JSXText" && /\S/.test(c.value)) ||
|
||||||
|
(c.type === "JSXExpressionContainer" && c.expression.type !== "JSXEmptyExpression") ||
|
||||||
|
((c.type === "JSXElement" || c.type === "JSXFragment") && hasText(c.children)),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
JSXElement(node) {
|
||||||
|
const open = node.openingElement;
|
||||||
|
if (open.name.type !== "JSXIdentifier" || open.name.name !== "a" || open.selfClosing)
|
||||||
|
return;
|
||||||
|
if (!hasText(node.children)) context.report({ node: open, messageId: "glyph" });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
const localRules = {
|
const localRules = {
|
||||||
rules: {
|
rules: {
|
||||||
|
"glyph-link": glyphLink,
|
||||||
"sortable-a11y": sortableA11y,
|
"sortable-a11y": sortableA11y,
|
||||||
"jsx-comment-text": jsxCommentText,
|
"jsx-comment-text": jsxCommentText,
|
||||||
"focus-safe-disabled": focusSafeDisabled,
|
"focus-safe-disabled": focusSafeDisabled,
|
||||||
|
"button-primitive": buttonPrimitive,
|
||||||
|
"date-format": dateFormat,
|
||||||
|
"input-primitive": inputPrimitive,
|
||||||
|
"surface-role": surfaceRole,
|
||||||
"literal-a11y-text": literalA11yText,
|
"literal-a11y-text": literalA11yText,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -167,6 +395,43 @@ const BOX_PX =
|
|||||||
"/(^|[\\s:])(min-|max-)?(w|h|size|left|right|top|bottom|inset|gap|p[xytrbl]?|m[xytrbl]?)-\\[[\\d.]+px\\]/";
|
"/(^|[\\s:])(min-|max-)?(w|h|size|left|right|top|bottom|inset|gap|p[xytrbl]?|m[xytrbl]?)-\\[[\\d.]+px\\]/";
|
||||||
const BOX_PX_MESSAGE =
|
const BOX_PX_MESSAGE =
|
||||||
"px-frozen box size doesn't scale with --font-scale: use the spacing scale (w-37.5, max-w-60), not w-[Npx].";
|
"px-frozen box size doesn't scale with --font-scale: use the spacing scale (w-37.5, max-w-60), not w-[Npx].";
|
||||||
|
// A stacking order outside the layer scale (DESIGN.md Tokens: `base < menu < chrome < … < tooltip` in
|
||||||
|
// index.css). A numeric `z-10` equals a level today but doesn't follow it when the scale moves; name it.
|
||||||
|
const Z_RAW = "/(^|[\\s:])-?z-([1-9]\\d*|\\[[^\\]]+\\])(?![\\w-])/";
|
||||||
|
const Z_RAW_MESSAGE =
|
||||||
|
"z-index off the layer scale: use a named level (z-under, z-base, z-menu, z-chrome, z-panel, z-rail, z-overlay, z-popover, z-tooltip), not z-N or z-[…].";
|
||||||
|
|
||||||
|
// A hover that swaps a colour instead of laying the B3 state layer (DESIGN.md Tokens): `bg-card`,
|
||||||
|
// `bg-border` and `bg-surface` vanish on a light card, an opacity dim reads as "disabled", and
|
||||||
|
// `bg-(--hover)` restates the `state-layer` utility by hand. `group-hover:` (a reveal on a parent's
|
||||||
|
// hover, C1) and an edged pill's accent hover (F14) are not matched.
|
||||||
|
const HOVER_SWAP =
|
||||||
|
"/(^|[\\s:])hover:(bg-(card|border|surface|muted)(?![\\w-])|bg-\\(--(hover|press)\\)|opacity-)/";
|
||||||
|
const HOVER_SWAP_MESSAGE =
|
||||||
|
"hover by a colour swap: use the B3 layer (`state-layer`, Pressable `layer`, `state-filled` on a fill), not hover:bg-card / bg-border / opacity-N.";
|
||||||
|
|
||||||
|
// A palette colour (DESIGN.md Tokens "only semantic tokens", audit F16): nothing measures `text-amber-500`,
|
||||||
|
// so a gold that reads on dark ships at 2:1 on light. Black and white are not matched: they are the
|
||||||
|
// media scrims and the text on them, the same in every theme.
|
||||||
|
const PALETTE =
|
||||||
|
"/(^|[\\s:])(text|bg|border|fill|stroke|from|via|to|ring|outline|decoration|divide|caret|shadow)-(red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose|slate|gray|zinc|neutral|stone)-\\d/";
|
||||||
|
const PALETTE_MESSAGE =
|
||||||
|
"a palette colour: use a semantic token (text-rating, bg-live, bg-media-chrome, text-danger, …; DESIGN.md Tokens), not text-amber-500.";
|
||||||
|
|
||||||
|
// A static label with an edge (DESIGN.md "Static vs pressable", audit F29): a <span> drawn round with a
|
||||||
|
// border reads as a chip, is clicked and does nothing. A label that does nothing is ui/Badge (flat).
|
||||||
|
// A small `rounded` box (a check mark drawn inside a menu row) is a glyph, not a label: only the label
|
||||||
|
// radii match.
|
||||||
|
const EDGED_LABEL = "/^(?=.*(^|\\s)rounded-(full|md|lg|xl|2xl)(\\s|$))(?=.*(^|\\s)border(\\s|$))/";
|
||||||
|
const EDGED_LABEL_MESSAGE =
|
||||||
|
"a static label with an edge reads as a pressable chip: use ui/Badge (flat, tonal) for a label that does nothing.";
|
||||||
|
|
||||||
|
// A class name split by an interpolation: the text right after `${…}` starts with a variant (`hover:`),
|
||||||
|
// or the interpolation IS the variant and the text goes on with its class (`${NARROW}:w-24`, E28 S3f3).
|
||||||
|
// A clock (`${m}:00`) and a URL (`${u}://`) are not classes.
|
||||||
|
const SPLIT_CLASS = "/^([a-z][\\w-]*:[\\w\\[!-]|:[a-z\\[!])/";
|
||||||
|
const SPLIT_CLASS_MESSAGE =
|
||||||
|
"a class split by an interpolation (`${x}hover:…`): Tailwind never sees it whole and emits no CSS; write the class literally (a shared guard is the `act:` variant).";
|
||||||
|
|
||||||
export default tseslint.config(
|
export default tseslint.config(
|
||||||
{ ignores: ["dist", "node_modules", "playwright-report", "test-results", "e2e/.auth"] },
|
{ ignores: ["dist", "node_modules", "playwright-report", "test-results", "e2e/.auth"] },
|
||||||
@@ -187,9 +452,36 @@ export default tseslint.config(
|
|||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
...reactHooks.configs.recommended.rules,
|
...reactHooks.configs.recommended.rules,
|
||||||
|
// eslint-plugin-react-hooks 7's `recommended` is no longer two rules — it carries the whole
|
||||||
|
// React Compiler rule set (E27 S1). On this codebase that landed 283 findings, and they were
|
||||||
|
// triaged rather than bulk-suppressed, because the two answers are different:
|
||||||
|
//
|
||||||
|
// react-hooks/refs 217 — OFF, see below.
|
||||||
|
// the other 66 — real. They went into eslint-suppressions.json as a ratchet for one
|
||||||
|
// sprint and E27 S2 burned the file down to nothing: what survives is
|
||||||
|
// an inline directive at the site, each naming the invariant it
|
||||||
|
// protects. There is DELIBERATELY no suppressions file any more — a
|
||||||
|
// bulk `--suppress-all` would hide the next violation instead of
|
||||||
|
// answering it, and it cannot hold a warning-level rule anyway.
|
||||||
|
//
|
||||||
|
// `refs` is off because it fires on this app's popover architecture, not on its history, so
|
||||||
|
// suppressing it would not help the next popover anyone writes. Measured over the 217:
|
||||||
|
// 79 are `pop.refs.setReference` / `getReferenceProps` — @floating-ui/react's callback-ref
|
||||||
|
// setters, which are the documented way to use it — and 74 more are passing the popover handle
|
||||||
|
// down as a prop (`popover={pop}`, `ref={fade.ref}`), which is what `components/ui`'s popover
|
||||||
|
// engine is FOR. That is 153 of 217 flagged for having the word `refs` in an object that is not
|
||||||
|
// a React ref. The remaining ~64 are genuine render-phase `someRef.current = x` writes and are
|
||||||
|
// real (E27 S2 answered the ones the other rules could see, at their sites).
|
||||||
|
// Re-evaluate when the rule can: it shipped in 1.0.0 and this looks like over-approximation.
|
||||||
|
"react-hooks/refs": "off",
|
||||||
"local/jsx-comment-text": "error",
|
"local/jsx-comment-text": "error",
|
||||||
"local/focus-safe-disabled": "error",
|
"local/focus-safe-disabled": "error",
|
||||||
|
"local/button-primitive": "error",
|
||||||
|
"local/date-format": "error",
|
||||||
|
"local/input-primitive": "error",
|
||||||
|
"local/surface-role": "error",
|
||||||
"local/literal-a11y-text": "error",
|
"local/literal-a11y-text": "error",
|
||||||
|
"local/glyph-link": "error",
|
||||||
"local/sortable-a11y": "error",
|
"local/sortable-a11y": "error",
|
||||||
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
|
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
|
||||||
// tsc already enforces these under strict + noUnusedLocals/Parameters — let it own them so a
|
// tsc already enforces these under strict + noUnusedLocals/Parameters — let it own them so a
|
||||||
@@ -221,12 +513,19 @@ export default tseslint.config(
|
|||||||
{
|
{
|
||||||
selector: "Literal[value=/(^|\\s)enabled:hover:/]",
|
selector: "Literal[value=/(^|\\s)enabled:hover:/]",
|
||||||
message:
|
message:
|
||||||
"`enabled:hover:` misses a focused FocusSafeButton (aria-disabled): use `enabled:not-aria-disabled:hover:`.",
|
"`enabled:hover:` misses a focused FocusSafeButton (aria-disabled): use `act:hover:` (index.css).",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
selector: "TemplateElement[value.raw=/(^|\\s)enabled:hover:/]",
|
selector: "TemplateElement[value.raw=/(^|\\s)enabled:hover:/]",
|
||||||
message:
|
message:
|
||||||
"`enabled:hover:` misses a focused FocusSafeButton (aria-disabled): use `enabled:not-aria-disabled:hover:`.",
|
"`enabled:hover:` misses a focused FocusSafeButton (aria-disabled): use `act:hover:` (index.css).",
|
||||||
|
},
|
||||||
|
// Tailwind emits CSS only for a class it finds WHOLE in the source. A variant glued onto an
|
||||||
|
// interpolated prefix (`${ACT}hover:text-fg`) is never found, so it ships with no rule: every
|
||||||
|
// hover colour of the button and chip primitives was dead that way (E28 audit).
|
||||||
|
{
|
||||||
|
selector: `TemplateLiteral > TemplateElement:not(:first-child)[value.raw=${SPLIT_CLASS}]`,
|
||||||
|
message: SPLIT_CLASS_MESSAGE,
|
||||||
},
|
},
|
||||||
// The canonical "FE control" row (CLAUDE.md): a short fixed set is a SegmentedToggle, a
|
// The canonical "FE control" row (CLAUDE.md): a short fixed set is a SegmentedToggle, a
|
||||||
// longer one or a user's own list a FilterSelect. Six native selects had drifted in; the
|
// longer one or a user's own list a FilterSelect. Six native selects had drifted in; the
|
||||||
@@ -255,6 +554,20 @@ export default tseslint.config(
|
|||||||
// 150px at the default size) — 32 of these were swept in the 2026-09 debt burn.
|
// 150px at the default size) — 32 of these were swept in the 2026-09 debt burn.
|
||||||
{ selector: `Literal[value=${BOX_PX}]`, message: BOX_PX_MESSAGE },
|
{ selector: `Literal[value=${BOX_PX}]`, message: BOX_PX_MESSAGE },
|
||||||
{ selector: `TemplateElement[value.raw=${BOX_PX}]`, message: BOX_PX_MESSAGE },
|
{ selector: `TemplateElement[value.raw=${BOX_PX}]`, message: BOX_PX_MESSAGE },
|
||||||
|
{ selector: `Literal[value=${Z_RAW}]`, message: Z_RAW_MESSAGE },
|
||||||
|
{ selector: `TemplateElement[value.raw=${Z_RAW}]`, message: Z_RAW_MESSAGE },
|
||||||
|
{ selector: `Literal[value=${HOVER_SWAP}]`, message: HOVER_SWAP_MESSAGE },
|
||||||
|
{ selector: `TemplateElement[value.raw=${HOVER_SWAP}]`, message: HOVER_SWAP_MESSAGE },
|
||||||
|
{ selector: `Literal[value=${PALETTE}]`, message: PALETTE_MESSAGE },
|
||||||
|
{ selector: `TemplateElement[value.raw=${PALETTE}]`, message: PALETTE_MESSAGE },
|
||||||
|
{
|
||||||
|
selector: `JSXOpeningElement[name.name='span'] JSXAttribute[name.name='className'] Literal[value=${EDGED_LABEL}]`,
|
||||||
|
message: EDGED_LABEL_MESSAGE,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
selector: `JSXOpeningElement[name.name='span'] JSXAttribute[name.name='className'] TemplateElement[value.raw=${EDGED_LABEL}]`,
|
||||||
|
message: EDGED_LABEL_MESSAGE,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -335,6 +648,13 @@ export default tseslint.config(
|
|||||||
files: ["src/components/ui/FocusSafeButton.tsx", "src/components/ui/AnchoredPopover.tsx"],
|
files: ["src/components/ui/FocusSafeButton.tsx", "src/components/ui/AnchoredPopover.tsx"],
|
||||||
rules: { "local/focus-safe-disabled": "off" },
|
rules: { "local/focus-safe-disabled": "off" },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// The design lab (src/design-lab, E28) is a dev-only page that never reaches the production
|
||||||
|
// bundle (devOnlyGuard fails the build if it does). Its copy is the developer's and the product
|
||||||
|
// owner's working text, not the product's, so the translate-every-label rule does not apply.
|
||||||
|
files: ["src/design-lab/**/*.{ts,tsx}"],
|
||||||
|
rules: { "local/literal-a11y-text": "off" },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
// Tests stand doubles in for the network (`vi.stubGlobal("fetch", …)`) and may reach it directly.
|
// Tests stand doubles in for the network (`vi.stubGlobal("fetch", …)`) and may reach it directly.
|
||||||
// …and render throwaway markup whose labels no user ever hears.
|
// …and render throwaway markup whose labels no user ever hears.
|
||||||
@@ -343,6 +663,11 @@ export default tseslint.config(
|
|||||||
"no-restricted-globals": "off",
|
"no-restricted-globals": "off",
|
||||||
"no-restricted-properties": "off",
|
"no-restricted-properties": "off",
|
||||||
"local/literal-a11y-text": "off",
|
"local/literal-a11y-text": "off",
|
||||||
|
// A test renders a raw button as a stand-in trigger or a focus target, not as product UI.
|
||||||
|
"local/button-primitive": "off",
|
||||||
|
"local/input-primitive": "off",
|
||||||
|
"local/surface-role": "off",
|
||||||
|
"local/glyph-link": "off",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
prettier,
|
prettier,
|
||||||
|
|||||||
@@ -2,7 +2,13 @@
|
|||||||
<html lang="en" data-theme="dark" data-scheme="midnight">
|
<html lang="en" data-theme="dark" data-scheme="midnight">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<!-- resizes-content: the on-screen keyboard shrinks the layout viewport, not only the visual one,
|
||||||
|
so a dialog's pinned footer (DESIGN C4) sits above the keyboard instead of under it. Chrome's
|
||||||
|
default since 108 only shrinks the visual viewport; Safari ignores the key. -->
|
||||||
|
<meta
|
||||||
|
name="viewport"
|
||||||
|
content="width=device-width, initial-scale=1, interactive-widget=resizes-content"
|
||||||
|
/>
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<!-- OG:START — the server replaces this block for /watch/{token} so shared links unfurl with
|
<!-- OG:START — the server replaces this block for /watch/{token} so shared links unfurl with
|
||||||
the video's title/channel/thumbnail; every other route keeps these generic defaults. -->
|
the video's title/channel/thumbnail; every other route keeps these generic defaults. -->
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "siftlode-frontend",
|
"name": "siftlode-frontend",
|
||||||
|
"license": "AGPL-3.0-only",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
@@ -17,22 +18,23 @@
|
|||||||
"hls.js": "^1.6.16",
|
"hls.js": "^1.6.16",
|
||||||
"i18next": "^23.11.5",
|
"i18next": "^23.11.5",
|
||||||
"lucide-react": "^0.408.0",
|
"lucide-react": "^0.408.0",
|
||||||
"react": "^18.3.1",
|
"react": "19.3.0",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "19.3.0",
|
||||||
"react-i18next": "^14.1.2"
|
"react-i18next": "^15.7.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "9.39.1",
|
"@eslint/js": "9.39.1",
|
||||||
"@playwright/test": "^1.61.1",
|
"@playwright/test": "^1.61.1",
|
||||||
"@tailwindcss/postcss": "^4.3.3",
|
"@tailwindcss/postcss": "^4.3.3",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@types/node": "^26.1.1",
|
"@types/node": "^24.13.6",
|
||||||
"@types/react": "^18.3.3",
|
"@types/react": "19.3.0",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "19.3.0",
|
||||||
"@vitejs/plugin-react": "^4.3.1",
|
"@vitejs/plugin-react": "^4.3.1",
|
||||||
|
"babel-plugin-react-compiler": "1.0.0",
|
||||||
"eslint": "9.39.1",
|
"eslint": "9.39.1",
|
||||||
"eslint-config-prettier": "10.1.8",
|
"eslint-config-prettier": "10.1.8",
|
||||||
"eslint-plugin-react-hooks": "5.2.0",
|
"eslint-plugin-react-hooks": "7.1.1",
|
||||||
"eslint-plugin-react-refresh": "0.4.24",
|
"eslint-plugin-react-refresh": "0.4.24",
|
||||||
"globals": "16.5.0",
|
"globals": "16.5.0",
|
||||||
"jsdom": "^25.0.1",
|
"jsdom": "^25.0.1",
|
||||||
@@ -44,6 +46,9 @@
|
|||||||
"typescript-eslint": "8.46.0",
|
"typescript-eslint": "8.46.0",
|
||||||
"vite": "^5.3.4",
|
"vite": "^5.3.4",
|
||||||
"vitest": "^3.2.7"
|
"vitest": "^3.2.7"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "24.x"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@alloc/quick-lru": {
|
"node_modules/@alloc/quick-lru": {
|
||||||
@@ -2909,41 +2914,33 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "26.1.1",
|
"version": "24.13.6",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.6.tgz",
|
||||||
"integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==",
|
"integrity": "sha512-SGrw/h3KPFshy3OE6ZL53LMBG5vGQQ8/gIpiqz/kRZhPJ7HgwCEs8LBuNtWLa8dvGZVpSF7+Bf+c11HUrCb/yg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~8.3.0"
|
"undici-types": "~7.18.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/prop-types": {
|
|
||||||
"version": "15.7.15",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
|
||||||
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@types/react": {
|
"node_modules/@types/react": {
|
||||||
"version": "18.3.31",
|
"version": "19.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz",
|
||||||
"integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==",
|
"integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/prop-types": "*",
|
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/react-dom": {
|
"node_modules/@types/react-dom": {
|
||||||
"version": "18.3.7",
|
"version": "19.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz",
|
||||||
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
|
"integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/react": "^18.0.0"
|
"@types/react": "^19.3.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||||
@@ -3465,6 +3462,16 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/babel-plugin-react-compiler": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/babel-plugin-react-compiler/-/babel-plugin-react-compiler-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/types": "^7.26.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/balanced-match": {
|
"node_modules/balanced-match": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||||
@@ -4080,16 +4087,23 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/eslint-plugin-react-hooks": {
|
"node_modules/eslint-plugin-react-hooks": {
|
||||||
"version": "5.2.0",
|
"version": "7.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz",
|
||||||
"integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==",
|
"integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/core": "^7.24.4",
|
||||||
|
"@babel/parser": "^7.24.4",
|
||||||
|
"hermes-parser": "^0.25.1",
|
||||||
|
"zod": "^3.25.0 || ^4.0.0",
|
||||||
|
"zod-validation-error": "^3.5.0 || ^4.0.0"
|
||||||
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=10"
|
"node": ">=18"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0"
|
"eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/eslint-plugin-react-refresh": {
|
"node_modules/eslint-plugin-react-refresh": {
|
||||||
@@ -4582,6 +4596,23 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/hermes-estree": {
|
||||||
|
"version": "0.25.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz",
|
||||||
|
"integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/hermes-parser": {
|
||||||
|
"version": "0.25.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz",
|
||||||
|
"integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"hermes-estree": "0.25.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/hls.js": {
|
"node_modules/hls.js": {
|
||||||
"version": "1.6.16",
|
"version": "1.6.16",
|
||||||
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.16.tgz",
|
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.16.tgz",
|
||||||
@@ -4772,6 +4803,7 @@
|
|||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||||
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/js-yaml": {
|
"node_modules/js-yaml": {
|
||||||
@@ -5275,18 +5307,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/loose-envify": {
|
|
||||||
"version": "1.4.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
|
||||||
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"js-tokens": "^3.0.0 || ^4.0.0"
|
|
||||||
},
|
|
||||||
"bin": {
|
|
||||||
"loose-envify": "cli.js"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/loupe": {
|
"node_modules/loupe": {
|
||||||
"version": "3.2.1",
|
"version": "3.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
|
||||||
@@ -5820,42 +5840,39 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/react": {
|
"node_modules/react": {
|
||||||
"version": "18.3.1",
|
"version": "19.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz",
|
||||||
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
"integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
|
||||||
"loose-envify": "^1.1.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-dom": {
|
"node_modules/react-dom": {
|
||||||
"version": "18.3.1",
|
"version": "19.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz",
|
||||||
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
"integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"loose-envify": "^1.1.0",
|
"scheduler": "^0.28.0"
|
||||||
"scheduler": "^0.23.2"
|
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": "^18.3.1"
|
"react": "^19.3.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-i18next": {
|
"node_modules/react-i18next": {
|
||||||
"version": "14.1.3",
|
"version": "15.7.4",
|
||||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-14.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-15.7.4.tgz",
|
||||||
"integrity": "sha512-wZnpfunU6UIAiJ+bxwOiTmBOAaB14ha97MjOEnLGac2RJ+h/maIYXZuTHlmyqQVX1UVHmU1YDTQ5vxLmwfXTjw==",
|
"integrity": "sha512-nyU8iKNrI5uDJch0z9+Y5XEr34b0wkyYj3Rp+tfbahxtlswxSCjcUL9H0nqXo9IR3/t5Y5PKIA3fx3MfUyR9Xw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.23.9",
|
"@babel/runtime": "^7.27.6",
|
||||||
"html-parse-stringify": "^3.0.1"
|
"html-parse-stringify": "^3.0.1"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"i18next": ">= 23.2.3",
|
"i18next": ">= 23.4.0",
|
||||||
"react": ">= 16.8.0"
|
"react": ">= 16.8.0",
|
||||||
|
"typescript": "^5"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
"react-dom": {
|
"react-dom": {
|
||||||
@@ -5863,6 +5880,9 @@
|
|||||||
},
|
},
|
||||||
"react-native": {
|
"react-native": {
|
||||||
"optional": true
|
"optional": true
|
||||||
|
},
|
||||||
|
"typescript": {
|
||||||
|
"optional": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -6012,13 +6032,10 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/scheduler": {
|
"node_modules/scheduler": {
|
||||||
"version": "0.23.2",
|
"version": "0.28.0",
|
||||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
|
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz",
|
||||||
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
|
"integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==",
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"dependencies": {
|
|
||||||
"loose-envify": "^1.1.0"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"node_modules/semver": {
|
"node_modules/semver": {
|
||||||
"version": "6.3.1",
|
"version": "6.3.1",
|
||||||
@@ -6364,7 +6381,7 @@
|
|||||||
"version": "5.9.3",
|
"version": "5.9.3",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
@@ -6409,9 +6426,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/undici-types": {
|
"node_modules/undici-types": {
|
||||||
"version": "8.3.0",
|
"version": "7.18.2",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
|
||||||
"integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
|
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
@@ -6832,6 +6849,19 @@
|
|||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"node_modules/zod-validation-error": {
|
||||||
|
"version": "4.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz",
|
||||||
|
"integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"zod": "^3.25.0 || ^4.0.0"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,9 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"license": "AGPL-3.0-only",
|
"license": "AGPL-3.0-only",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"engines": {
|
||||||
|
"node": "24.x"
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"typecheck": "npm run typecheck:app && npm run typecheck:node && npm run typecheck:e2e",
|
"typecheck": "npm run typecheck:app && npm run typecheck:node && npm run typecheck:e2e",
|
||||||
@@ -31,22 +34,23 @@
|
|||||||
"hls.js": "^1.6.16",
|
"hls.js": "^1.6.16",
|
||||||
"i18next": "^23.11.5",
|
"i18next": "^23.11.5",
|
||||||
"lucide-react": "^0.408.0",
|
"lucide-react": "^0.408.0",
|
||||||
"react": "^18.3.1",
|
"react": "19.3.0",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "19.3.0",
|
||||||
"react-i18next": "^14.1.2"
|
"react-i18next": "^15.7.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "9.39.1",
|
"@eslint/js": "9.39.1",
|
||||||
"@playwright/test": "^1.61.1",
|
"@playwright/test": "^1.61.1",
|
||||||
"@tailwindcss/postcss": "^4.3.3",
|
"@tailwindcss/postcss": "^4.3.3",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@types/node": "^26.1.1",
|
"@types/node": "^24.13.6",
|
||||||
"@types/react": "^18.3.3",
|
"@types/react": "19.3.0",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "19.3.0",
|
||||||
"@vitejs/plugin-react": "^4.3.1",
|
"@vitejs/plugin-react": "^4.3.1",
|
||||||
|
"babel-plugin-react-compiler": "1.0.0",
|
||||||
"eslint": "9.39.1",
|
"eslint": "9.39.1",
|
||||||
"eslint-config-prettier": "10.1.8",
|
"eslint-config-prettier": "10.1.8",
|
||||||
"eslint-plugin-react-hooks": "5.2.0",
|
"eslint-plugin-react-hooks": "7.1.1",
|
||||||
"eslint-plugin-react-refresh": "0.4.24",
|
"eslint-plugin-react-refresh": "0.4.24",
|
||||||
"globals": "16.5.0",
|
"globals": "16.5.0",
|
||||||
"jsdom": "^25.0.1",
|
"jsdom": "^25.0.1",
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 1000" preserveAspectRatio="xMidYMid slice">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 1000" preserveAspectRatio="xMidYMid slice">
|
||||||
<defs>
|
<defs>
|
||||||
<radialGradient id="a" cx="20%" cy="18%" r="55%"><stop offset="0%" stop-color="#7a1620" stop-opacity=".9"/><stop offset="100%" stop-color="#7a1620" stop-opacity="0"/></radialGradient>
|
<radialGradient id="a" cx="20%" cy="18%" r="55%"><stop offset="0%" stop-color="#1d4a66" stop-opacity=".95"/><stop offset="100%" stop-color="#1d4a66" stop-opacity="0"/></radialGradient>
|
||||||
<radialGradient id="b" cx="86%" cy="26%" r="52%"><stop offset="0%" stop-color="#ff3b46" stop-opacity=".4"/><stop offset="100%" stop-color="#ff3b46" stop-opacity="0"/></radialGradient>
|
<radialGradient id="b" cx="86%" cy="26%" r="52%"><stop offset="0%" stop-color="#337599" stop-opacity=".5"/><stop offset="100%" stop-color="#337599" stop-opacity="0"/></radialGradient>
|
||||||
<radialGradient id="c" cx="68%" cy="92%" r="60%"><stop offset="0%" stop-color="#3a0d24" stop-opacity=".9"/><stop offset="100%" stop-color="#3a0d24" stop-opacity="0"/></radialGradient>
|
<radialGradient id="c" cx="68%" cy="92%" r="60%"><stop offset="0%" stop-color="#1f2d57" stop-opacity=".9"/><stop offset="100%" stop-color="#1f2d57" stop-opacity="0"/></radialGradient>
|
||||||
<radialGradient id="d" cx="8%" cy="88%" r="55%"><stop offset="0%" stop-color="#2a1010" stop-opacity=".85"/><stop offset="100%" stop-color="#2a1010" stop-opacity="0"/></radialGradient>
|
<radialGradient id="d" cx="8%" cy="88%" r="55%"><stop offset="0%" stop-color="#173c52" stop-opacity=".85"/><stop offset="100%" stop-color="#173c52" stop-opacity="0"/></radialGradient>
|
||||||
<filter id="s"><feGaussianBlur stdDeviation="60"/></filter>
|
<filter id="s"><feGaussianBlur stdDeviation="60"/></filter>
|
||||||
<filter id="g"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" stitchTiles="stitch"/><feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .5 0"/></filter>
|
<filter id="g"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" stitchTiles="stitch"/><feColorMatrix type="saturate" values="0"/></filter>
|
||||||
</defs>
|
</defs>
|
||||||
<rect width="1600" height="1000" fill="#0d0708"/>
|
<rect width="1600" height="1000" fill="#080d1a"/>
|
||||||
<g filter="url(#s)">
|
<g filter="url(#s)">
|
||||||
<rect width="1600" height="1000" fill="url(#a)"/>
|
<rect width="1600" height="1000" fill="url(#a)"/>
|
||||||
<rect width="1600" height="1000" fill="url(#c)"/>
|
<rect width="1600" height="1000" fill="url(#c)"/>
|
||||||
<rect width="1600" height="1000" fill="url(#d)"/>
|
<rect width="1600" height="1000" fill="url(#d)"/>
|
||||||
<rect width="1600" height="1000" fill="url(#b)"/>
|
<rect width="1600" height="1000" fill="url(#b)"/>
|
||||||
</g>
|
</g>
|
||||||
<rect width="1600" height="1000" filter="url(#g)" opacity="0.05"/>
|
<rect width="1600" height="1000" filter="url(#g)" opacity="0.35" style="mix-blend-mode:overlay"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
@@ -1,11 +1,11 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 1000" preserveAspectRatio="xMidYMid slice">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 1000" preserveAspectRatio="xMidYMid slice">
|
||||||
<defs>
|
<defs>
|
||||||
<radialGradient id="a" cx="20%" cy="18%" r="55%"><stop offset="0%" stop-color="#0f5245" stop-opacity=".95"/><stop offset="100%" stop-color="#0f5245" stop-opacity="0"/></radialGradient>
|
<radialGradient id="a" cx="20%" cy="18%" r="55%"><stop offset="0%" stop-color="#0e4a5a" stop-opacity=".95"/><stop offset="100%" stop-color="#0e4a5a" stop-opacity="0"/></radialGradient>
|
||||||
<radialGradient id="b" cx="86%" cy="26%" r="52%"><stop offset="0%" stop-color="#2dd4bf" stop-opacity=".5"/><stop offset="100%" stop-color="#2dd4bf" stop-opacity="0"/></radialGradient>
|
<radialGradient id="b" cx="86%" cy="26%" r="52%"><stop offset="0%" stop-color="#22d3ee" stop-opacity=".5"/><stop offset="100%" stop-color="#22d3ee" stop-opacity="0"/></radialGradient>
|
||||||
<radialGradient id="c" cx="68%" cy="92%" r="60%"><stop offset="0%" stop-color="#134a2c" stop-opacity=".9"/><stop offset="100%" stop-color="#134a2c" stop-opacity="0"/></radialGradient>
|
<radialGradient id="c" cx="68%" cy="92%" r="60%"><stop offset="0%" stop-color="#134a2c" stop-opacity=".9"/><stop offset="100%" stop-color="#134a2c" stop-opacity="0"/></radialGradient>
|
||||||
<radialGradient id="d" cx="8%" cy="88%" r="55%"><stop offset="0%" stop-color="#0d3a52" stop-opacity=".85"/><stop offset="100%" stop-color="#0d3a52" stop-opacity="0"/></radialGradient>
|
<radialGradient id="d" cx="8%" cy="88%" r="55%"><stop offset="0%" stop-color="#0d3a52" stop-opacity=".85"/><stop offset="100%" stop-color="#0d3a52" stop-opacity="0"/></radialGradient>
|
||||||
<filter id="s"><feGaussianBlur stdDeviation="60"/></filter>
|
<filter id="s"><feGaussianBlur stdDeviation="60"/></filter>
|
||||||
<filter id="g"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" stitchTiles="stitch"/><feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .5 0"/></filter>
|
<filter id="g"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" stitchTiles="stitch"/><feColorMatrix type="saturate" values="0"/></filter>
|
||||||
</defs>
|
</defs>
|
||||||
<rect width="1600" height="1000" fill="#06120e"/>
|
<rect width="1600" height="1000" fill="#06120e"/>
|
||||||
<g filter="url(#s)">
|
<g filter="url(#s)">
|
||||||
@@ -14,5 +14,5 @@
|
|||||||
<rect width="1600" height="1000" fill="url(#d)"/>
|
<rect width="1600" height="1000" fill="url(#d)"/>
|
||||||
<rect width="1600" height="1000" fill="url(#b)"/>
|
<rect width="1600" height="1000" fill="url(#b)"/>
|
||||||
</g>
|
</g>
|
||||||
<rect width="1600" height="1000" filter="url(#g)" opacity="0.05"/>
|
<rect width="1600" height="1000" filter="url(#g)" opacity="0.35" style="mix-blend-mode:overlay"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
@@ -1,18 +1,18 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 1000" preserveAspectRatio="xMidYMid slice">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 1000" preserveAspectRatio="xMidYMid slice">
|
||||||
<defs>
|
<defs>
|
||||||
<radialGradient id="a" cx="20%" cy="18%" r="55%"><stop offset="0%" stop-color="#f0a3aa" stop-opacity=".85"/><stop offset="100%" stop-color="#f0a3aa" stop-opacity="0"/></radialGradient>
|
<radialGradient id="a" cx="20%" cy="18%" r="55%"><stop offset="0%" stop-color="#a8c6dc" stop-opacity=".9"/><stop offset="100%" stop-color="#a8c6dc" stop-opacity="0"/></radialGradient>
|
||||||
<radialGradient id="b" cx="86%" cy="26%" r="52%"><stop offset="0%" stop-color="#ff5b64" stop-opacity=".4"/><stop offset="100%" stop-color="#ff5b64" stop-opacity="0"/></radialGradient>
|
<radialGradient id="b" cx="86%" cy="26%" r="52%"><stop offset="0%" stop-color="#5aa1cb" stop-opacity=".5"/><stop offset="100%" stop-color="#5aa1cb" stop-opacity="0"/></radialGradient>
|
||||||
<radialGradient id="c" cx="68%" cy="92%" r="60%"><stop offset="0%" stop-color="#e096a8" stop-opacity=".8"/><stop offset="100%" stop-color="#e096a8" stop-opacity="0"/></radialGradient>
|
<radialGradient id="c" cx="68%" cy="92%" r="60%"><stop offset="0%" stop-color="#b9c6e0" stop-opacity=".85"/><stop offset="100%" stop-color="#b9c6e0" stop-opacity="0"/></radialGradient>
|
||||||
<radialGradient id="d" cx="8%" cy="88%" r="55%"><stop offset="0%" stop-color="#eec2c2" stop-opacity=".8"/><stop offset="100%" stop-color="#eec2c2" stop-opacity="0"/></radialGradient>
|
<radialGradient id="d" cx="8%" cy="88%" r="55%"><stop offset="0%" stop-color="#a9d0dc" stop-opacity=".8"/><stop offset="100%" stop-color="#a9d0dc" stop-opacity="0"/></radialGradient>
|
||||||
<filter id="s"><feGaussianBlur stdDeviation="60"/></filter>
|
<filter id="s"><feGaussianBlur stdDeviation="60"/></filter>
|
||||||
<filter id="g"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" stitchTiles="stitch"/><feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .35 0"/></filter>
|
<filter id="g"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" stitchTiles="stitch"/><feColorMatrix type="saturate" values="0"/></filter>
|
||||||
</defs>
|
</defs>
|
||||||
<rect width="1600" height="1000" fill="#faeef0"/>
|
<rect width="1600" height="1000" fill="#eaf0f6"/>
|
||||||
<g filter="url(#s)">
|
<g filter="url(#s)">
|
||||||
<rect width="1600" height="1000" fill="url(#a)"/>
|
<rect width="1600" height="1000" fill="url(#a)"/>
|
||||||
<rect width="1600" height="1000" fill="url(#c)"/>
|
<rect width="1600" height="1000" fill="url(#c)"/>
|
||||||
<rect width="1600" height="1000" fill="url(#d)"/>
|
<rect width="1600" height="1000" fill="url(#d)"/>
|
||||||
<rect width="1600" height="1000" fill="url(#b)"/>
|
<rect width="1600" height="1000" fill="url(#b)"/>
|
||||||
</g>
|
</g>
|
||||||
<rect width="1600" height="1000" filter="url(#g)" opacity="0.04"/>
|
<rect width="1600" height="1000" filter="url(#g)" opacity="0.35" style="mix-blend-mode:overlay"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
@@ -1,11 +1,11 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 1000" preserveAspectRatio="xMidYMid slice">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 1000" preserveAspectRatio="xMidYMid slice">
|
||||||
<defs>
|
<defs>
|
||||||
<radialGradient id="a" cx="20%" cy="18%" r="55%"><stop offset="0%" stop-color="#7fd0bf" stop-opacity=".9"/><stop offset="100%" stop-color="#7fd0bf" stop-opacity="0"/></radialGradient>
|
<radialGradient id="a" cx="20%" cy="18%" r="55%"><stop offset="0%" stop-color="#8fd3e0" stop-opacity=".9"/><stop offset="100%" stop-color="#8fd3e0" stop-opacity="0"/></radialGradient>
|
||||||
<radialGradient id="b" cx="86%" cy="26%" r="52%"><stop offset="0%" stop-color="#2dd4bf" stop-opacity=".5"/><stop offset="100%" stop-color="#2dd4bf" stop-opacity="0"/></radialGradient>
|
<radialGradient id="b" cx="86%" cy="26%" r="52%"><stop offset="0%" stop-color="#22d3ee" stop-opacity=".5"/><stop offset="100%" stop-color="#22d3ee" stop-opacity="0"/></radialGradient>
|
||||||
<radialGradient id="c" cx="68%" cy="92%" r="60%"><stop offset="0%" stop-color="#8fc9a5" stop-opacity=".85"/><stop offset="100%" stop-color="#8fc9a5" stop-opacity="0"/></radialGradient>
|
<radialGradient id="c" cx="68%" cy="92%" r="60%"><stop offset="0%" stop-color="#8fc9a5" stop-opacity=".85"/><stop offset="100%" stop-color="#8fc9a5" stop-opacity="0"/></radialGradient>
|
||||||
<radialGradient id="d" cx="8%" cy="88%" r="55%"><stop offset="0%" stop-color="#88c2d6" stop-opacity=".8"/><stop offset="100%" stop-color="#88c2d6" stop-opacity="0"/></radialGradient>
|
<radialGradient id="d" cx="8%" cy="88%" r="55%"><stop offset="0%" stop-color="#88c2d6" stop-opacity=".8"/><stop offset="100%" stop-color="#88c2d6" stop-opacity="0"/></radialGradient>
|
||||||
<filter id="s"><feGaussianBlur stdDeviation="60"/></filter>
|
<filter id="s"><feGaussianBlur stdDeviation="60"/></filter>
|
||||||
<filter id="g"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" stitchTiles="stitch"/><feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .35 0"/></filter>
|
<filter id="g"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" stitchTiles="stitch"/><feColorMatrix type="saturate" values="0"/></filter>
|
||||||
</defs>
|
</defs>
|
||||||
<rect width="1600" height="1000" fill="#e9f5f0"/>
|
<rect width="1600" height="1000" fill="#e9f5f0"/>
|
||||||
<g filter="url(#s)">
|
<g filter="url(#s)">
|
||||||
@@ -14,5 +14,5 @@
|
|||||||
<rect width="1600" height="1000" fill="url(#d)"/>
|
<rect width="1600" height="1000" fill="url(#d)"/>
|
||||||
<rect width="1600" height="1000" fill="url(#b)"/>
|
<rect width="1600" height="1000" fill="url(#b)"/>
|
||||||
</g>
|
</g>
|
||||||
<rect width="1600" height="1000" filter="url(#g)" opacity="0.04"/>
|
<rect width="1600" height="1000" filter="url(#g)" opacity="0.35" style="mix-blend-mode:overlay"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
@@ -1,18 +1,18 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 1000" preserveAspectRatio="xMidYMid slice">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 1000" preserveAspectRatio="xMidYMid slice">
|
||||||
<defs>
|
<defs>
|
||||||
<radialGradient id="a" cx="20%" cy="18%" r="55%"><stop offset="0%" stop-color="#8fd8ae" stop-opacity=".9"/><stop offset="100%" stop-color="#8fd8ae" stop-opacity="0"/></radialGradient>
|
<radialGradient id="a" cx="20%" cy="18%" r="55%"><stop offset="0%" stop-color="#d9e2c4" stop-opacity=".9"/><stop offset="100%" stop-color="#d9e2c4" stop-opacity="0"/></radialGradient>
|
||||||
<radialGradient id="b" cx="86%" cy="26%" r="52%"><stop offset="0%" stop-color="#46b87c" stop-opacity=".45"/><stop offset="100%" stop-color="#46b87c" stop-opacity="0"/></radialGradient>
|
<radialGradient id="b" cx="86%" cy="26%" r="52%"><stop offset="0%" stop-color="#86a05a" stop-opacity=".35"/><stop offset="100%" stop-color="#86a05a" stop-opacity="0"/></radialGradient>
|
||||||
<radialGradient id="c" cx="68%" cy="92%" r="60%"><stop offset="0%" stop-color="#7fc9c9" stop-opacity=".85"/><stop offset="100%" stop-color="#7fc9c9" stop-opacity="0"/></radialGradient>
|
<radialGradient id="c" cx="68%" cy="92%" r="60%"><stop offset="0%" stop-color="#cdd6bf" stop-opacity=".85"/><stop offset="100%" stop-color="#cdd6bf" stop-opacity="0"/></radialGradient>
|
||||||
<radialGradient id="d" cx="8%" cy="88%" r="55%"><stop offset="0%" stop-color="#96d69c" stop-opacity=".8"/><stop offset="100%" stop-color="#96d69c" stop-opacity="0"/></radialGradient>
|
<radialGradient id="d" cx="8%" cy="88%" r="55%"><stop offset="0%" stop-color="#c3cfb3" stop-opacity=".8"/><stop offset="100%" stop-color="#c3cfb3" stop-opacity="0"/></radialGradient>
|
||||||
<filter id="s"><feGaussianBlur stdDeviation="60"/></filter>
|
<filter id="s"><feGaussianBlur stdDeviation="60"/></filter>
|
||||||
<filter id="g"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" stitchTiles="stitch"/><feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .35 0"/></filter>
|
<filter id="g"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" stitchTiles="stitch"/><feColorMatrix type="saturate" values="0"/></filter>
|
||||||
</defs>
|
</defs>
|
||||||
<rect width="1600" height="1000" fill="#eaf5ec"/>
|
<rect width="1600" height="1000" fill="#eef0e8"/>
|
||||||
<g filter="url(#s)">
|
<g filter="url(#s)">
|
||||||
<rect width="1600" height="1000" fill="url(#a)"/>
|
<rect width="1600" height="1000" fill="url(#a)"/>
|
||||||
<rect width="1600" height="1000" fill="url(#c)"/>
|
<rect width="1600" height="1000" fill="url(#c)"/>
|
||||||
<rect width="1600" height="1000" fill="url(#d)"/>
|
<rect width="1600" height="1000" fill="url(#d)"/>
|
||||||
<rect width="1600" height="1000" fill="url(#b)"/>
|
<rect width="1600" height="1000" fill="url(#b)"/>
|
||||||
</g>
|
</g>
|
||||||
<rect width="1600" height="1000" filter="url(#g)" opacity="0.04"/>
|
<rect width="1600" height="1000" filter="url(#g)" opacity="0.35" style="mix-blend-mode:overlay"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
@@ -5,7 +5,7 @@
|
|||||||
<radialGradient id="c" cx="68%" cy="92%" r="60%"><stop offset="0%" stop-color="#b3a8ec" stop-opacity=".85"/><stop offset="100%" stop-color="#b3a8ec" stop-opacity="0"/></radialGradient>
|
<radialGradient id="c" cx="68%" cy="92%" r="60%"><stop offset="0%" stop-color="#b3a8ec" stop-opacity=".85"/><stop offset="100%" stop-color="#b3a8ec" stop-opacity="0"/></radialGradient>
|
||||||
<radialGradient id="d" cx="8%" cy="88%" r="55%"><stop offset="0%" stop-color="#9fd6e0" stop-opacity=".8"/><stop offset="100%" stop-color="#9fd6e0" stop-opacity="0"/></radialGradient>
|
<radialGradient id="d" cx="8%" cy="88%" r="55%"><stop offset="0%" stop-color="#9fd6e0" stop-opacity=".8"/><stop offset="100%" stop-color="#9fd6e0" stop-opacity="0"/></radialGradient>
|
||||||
<filter id="s"><feGaussianBlur stdDeviation="60"/></filter>
|
<filter id="s"><feGaussianBlur stdDeviation="60"/></filter>
|
||||||
<filter id="g"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" stitchTiles="stitch"/><feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .35 0"/></filter>
|
<filter id="g"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" stitchTiles="stitch"/><feColorMatrix type="saturate" values="0"/></filter>
|
||||||
</defs>
|
</defs>
|
||||||
<rect width="1600" height="1000" fill="#edf1fc"/>
|
<rect width="1600" height="1000" fill="#edf1fc"/>
|
||||||
<g filter="url(#s)">
|
<g filter="url(#s)">
|
||||||
@@ -14,5 +14,5 @@
|
|||||||
<rect width="1600" height="1000" fill="url(#d)"/>
|
<rect width="1600" height="1000" fill="url(#d)"/>
|
||||||
<rect width="1600" height="1000" fill="url(#b)"/>
|
<rect width="1600" height="1000" fill="url(#b)"/>
|
||||||
</g>
|
</g>
|
||||||
<rect width="1600" height="1000" filter="url(#g)" opacity="0.04"/>
|
<rect width="1600" height="1000" filter="url(#g)" opacity="0.35" style="mix-blend-mode:overlay"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
@@ -1,18 +1,18 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 1000" preserveAspectRatio="xMidYMid slice">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 1000" preserveAspectRatio="xMidYMid slice">
|
||||||
<defs>
|
<defs>
|
||||||
<radialGradient id="a" cx="20%" cy="18%" r="55%"><stop offset="0%" stop-color="#e6c393" stop-opacity=".9"/><stop offset="100%" stop-color="#e6c393" stop-opacity="0"/></radialGradient>
|
<radialGradient id="a" cx="20%" cy="18%" r="55%"><stop offset="0%" stop-color="#e0c2ea" stop-opacity=".9"/><stop offset="100%" stop-color="#e0c2ea" stop-opacity="0"/></radialGradient>
|
||||||
<radialGradient id="b" cx="86%" cy="26%" r="52%"><stop offset="0%" stop-color="#e8913c" stop-opacity=".45"/><stop offset="100%" stop-color="#e8913c" stop-opacity="0"/></radialGradient>
|
<radialGradient id="b" cx="86%" cy="26%" r="52%"><stop offset="0%" stop-color="#e879f9" stop-opacity=".45"/><stop offset="100%" stop-color="#e879f9" stop-opacity="0"/></radialGradient>
|
||||||
<radialGradient id="c" cx="68%" cy="92%" r="60%"><stop offset="0%" stop-color="#dba98c" stop-opacity=".85"/><stop offset="100%" stop-color="#dba98c" stop-opacity="0"/></radialGradient>
|
<radialGradient id="c" cx="68%" cy="92%" r="60%"><stop offset="0%" stop-color="#d6c0dc" stop-opacity=".85"/><stop offset="100%" stop-color="#d6c0dc" stop-opacity="0"/></radialGradient>
|
||||||
<radialGradient id="d" cx="8%" cy="88%" r="55%"><stop offset="0%" stop-color="#cabf9e" stop-opacity=".8"/><stop offset="100%" stop-color="#cabf9e" stop-opacity="0"/></radialGradient>
|
<radialGradient id="d" cx="8%" cy="88%" r="55%"><stop offset="0%" stop-color="#c9ccd6" stop-opacity=".8"/><stop offset="100%" stop-color="#c9ccd6" stop-opacity="0"/></radialGradient>
|
||||||
<filter id="s"><feGaussianBlur stdDeviation="60"/></filter>
|
<filter id="s"><feGaussianBlur stdDeviation="60"/></filter>
|
||||||
<filter id="g"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" stitchTiles="stitch"/><feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .35 0"/></filter>
|
<filter id="g"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" stitchTiles="stitch"/><feColorMatrix type="saturate" values="0"/></filter>
|
||||||
</defs>
|
</defs>
|
||||||
<rect width="1600" height="1000" fill="#f4efe6"/>
|
<rect width="1600" height="1000" fill="#f3f1f5"/>
|
||||||
<g filter="url(#s)">
|
<g filter="url(#s)">
|
||||||
<rect width="1600" height="1000" fill="url(#a)"/>
|
<rect width="1600" height="1000" fill="url(#a)"/>
|
||||||
<rect width="1600" height="1000" fill="url(#c)"/>
|
<rect width="1600" height="1000" fill="url(#c)"/>
|
||||||
<rect width="1600" height="1000" fill="url(#d)"/>
|
<rect width="1600" height="1000" fill="url(#d)"/>
|
||||||
<rect width="1600" height="1000" fill="url(#b)"/>
|
<rect width="1600" height="1000" fill="url(#b)"/>
|
||||||
</g>
|
</g>
|
||||||
<rect width="1600" height="1000" filter="url(#g)" opacity="0.04"/>
|
<rect width="1600" height="1000" filter="url(#g)" opacity="0.35" style="mix-blend-mode:overlay"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
@@ -1,18 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 1000" preserveAspectRatio="xMidYMid slice">
|
|
||||||
<defs>
|
|
||||||
<radialGradient id="a" cx="20%" cy="18%" r="55%"><stop offset="0%" stop-color="#8fb6e8" stop-opacity=".9"/><stop offset="100%" stop-color="#8fb6e8" stop-opacity="0"/></radialGradient>
|
|
||||||
<radialGradient id="b" cx="86%" cy="26%" r="52%"><stop offset="0%" stop-color="#38b6ff" stop-opacity=".45"/><stop offset="100%" stop-color="#38b6ff" stop-opacity="0"/></radialGradient>
|
|
||||||
<radialGradient id="c" cx="68%" cy="92%" r="60%"><stop offset="0%" stop-color="#94a0e0" stop-opacity=".85"/><stop offset="100%" stop-color="#94a0e0" stop-opacity="0"/></radialGradient>
|
|
||||||
<radialGradient id="d" cx="8%" cy="88%" r="55%"><stop offset="0%" stop-color="#8fc3d2" stop-opacity=".8"/><stop offset="100%" stop-color="#8fc3d2" stop-opacity="0"/></radialGradient>
|
|
||||||
<filter id="s"><feGaussianBlur stdDeviation="60"/></filter>
|
|
||||||
<filter id="g"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" stitchTiles="stitch"/><feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .35 0"/></filter>
|
|
||||||
</defs>
|
|
||||||
<rect width="1600" height="1000" fill="#e9f1fc"/>
|
|
||||||
<g filter="url(#s)">
|
|
||||||
<rect width="1600" height="1000" fill="url(#a)"/>
|
|
||||||
<rect width="1600" height="1000" fill="url(#c)"/>
|
|
||||||
<rect width="1600" height="1000" fill="url(#d)"/>
|
|
||||||
<rect width="1600" height="1000" fill="url(#b)"/>
|
|
||||||
</g>
|
|
||||||
<rect width="1600" height="1000" filter="url(#g)" opacity="0.04"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.5 KiB |
@@ -1,18 +1,18 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 1000" preserveAspectRatio="xMidYMid slice">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 1000" preserveAspectRatio="xMidYMid slice">
|
||||||
<defs>
|
<defs>
|
||||||
<radialGradient id="a" cx="20%" cy="18%" r="55%"><stop offset="0%" stop-color="#0f4a30" stop-opacity=".95"/><stop offset="100%" stop-color="#0f4a30" stop-opacity="0"/></radialGradient>
|
<radialGradient id="a" cx="20%" cy="18%" r="55%"><stop offset="0%" stop-color="#34431f" stop-opacity=".95"/><stop offset="100%" stop-color="#34431f" stop-opacity="0"/></radialGradient>
|
||||||
<radialGradient id="b" cx="86%" cy="26%" r="52%"><stop offset="0%" stop-color="#46b87c" stop-opacity=".45"/><stop offset="100%" stop-color="#46b87c" stop-opacity="0"/></radialGradient>
|
<radialGradient id="b" cx="86%" cy="26%" r="52%"><stop offset="0%" stop-color="#86a05a" stop-opacity=".35"/><stop offset="100%" stop-color="#86a05a" stop-opacity="0"/></radialGradient>
|
||||||
<radialGradient id="c" cx="68%" cy="92%" r="60%"><stop offset="0%" stop-color="#0c3a3a" stop-opacity=".9"/><stop offset="100%" stop-color="#0c3a3a" stop-opacity="0"/></radialGradient>
|
<radialGradient id="c" cx="68%" cy="92%" r="60%"><stop offset="0%" stop-color="#1d2a18" stop-opacity=".9"/><stop offset="100%" stop-color="#1d2a18" stop-opacity="0"/></radialGradient>
|
||||||
<radialGradient id="d" cx="8%" cy="88%" r="55%"><stop offset="0%" stop-color="#164a26" stop-opacity=".85"/><stop offset="100%" stop-color="#164a26" stop-opacity="0"/></radialGradient>
|
<radialGradient id="d" cx="8%" cy="88%" r="55%"><stop offset="0%" stop-color="#22301c" stop-opacity=".85"/><stop offset="100%" stop-color="#22301c" stop-opacity="0"/></radialGradient>
|
||||||
<filter id="s"><feGaussianBlur stdDeviation="60"/></filter>
|
<filter id="s"><feGaussianBlur stdDeviation="60"/></filter>
|
||||||
<filter id="g"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" stitchTiles="stitch"/><feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .5 0"/></filter>
|
<filter id="g"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" stitchTiles="stitch"/><feColorMatrix type="saturate" values="0"/></filter>
|
||||||
</defs>
|
</defs>
|
||||||
<rect width="1600" height="1000" fill="#020805"/>
|
<rect width="1600" height="1000" fill="#0a0c09"/>
|
||||||
<g filter="url(#s)">
|
<g filter="url(#s)">
|
||||||
<rect width="1600" height="1000" fill="url(#a)"/>
|
<rect width="1600" height="1000" fill="url(#a)"/>
|
||||||
<rect width="1600" height="1000" fill="url(#c)"/>
|
<rect width="1600" height="1000" fill="url(#c)"/>
|
||||||
<rect width="1600" height="1000" fill="url(#d)"/>
|
<rect width="1600" height="1000" fill="url(#d)"/>
|
||||||
<rect width="1600" height="1000" fill="url(#b)"/>
|
<rect width="1600" height="1000" fill="url(#b)"/>
|
||||||
</g>
|
</g>
|
||||||
<rect width="1600" height="1000" filter="url(#g)" opacity="0.05"/>
|
<rect width="1600" height="1000" filter="url(#g)" opacity="0.35" style="mix-blend-mode:overlay"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
@@ -5,7 +5,7 @@
|
|||||||
<radialGradient id="c" cx="68%" cy="92%" r="60%"><stop offset="0%" stop-color="#3b2f80" stop-opacity=".9"/><stop offset="100%" stop-color="#3b2f80" stop-opacity="0"/></radialGradient>
|
<radialGradient id="c" cx="68%" cy="92%" r="60%"><stop offset="0%" stop-color="#3b2f80" stop-opacity=".9"/><stop offset="100%" stop-color="#3b2f80" stop-opacity="0"/></radialGradient>
|
||||||
<radialGradient id="d" cx="8%" cy="88%" r="55%"><stop offset="0%" stop-color="#145e6b" stop-opacity=".85"/><stop offset="100%" stop-color="#145e6b" stop-opacity="0"/></radialGradient>
|
<radialGradient id="d" cx="8%" cy="88%" r="55%"><stop offset="0%" stop-color="#145e6b" stop-opacity=".85"/><stop offset="100%" stop-color="#145e6b" stop-opacity="0"/></radialGradient>
|
||||||
<filter id="s"><feGaussianBlur stdDeviation="60"/></filter>
|
<filter id="s"><feGaussianBlur stdDeviation="60"/></filter>
|
||||||
<filter id="g"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" stitchTiles="stitch"/><feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .5 0"/></filter>
|
<filter id="g"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" stitchTiles="stitch"/><feColorMatrix type="saturate" values="0"/></filter>
|
||||||
</defs>
|
</defs>
|
||||||
<rect width="1600" height="1000" fill="#070b16"/>
|
<rect width="1600" height="1000" fill="#070b16"/>
|
||||||
<g filter="url(#s)">
|
<g filter="url(#s)">
|
||||||
@@ -14,5 +14,5 @@
|
|||||||
<rect width="1600" height="1000" fill="url(#d)"/>
|
<rect width="1600" height="1000" fill="url(#d)"/>
|
||||||
<rect width="1600" height="1000" fill="url(#b)"/>
|
<rect width="1600" height="1000" fill="url(#b)"/>
|
||||||
</g>
|
</g>
|
||||||
<rect width="1600" height="1000" filter="url(#g)" opacity="0.05"/>
|
<rect width="1600" height="1000" filter="url(#g)" opacity="0.35" style="mix-blend-mode:overlay"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
@@ -1,18 +1,18 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 1000" preserveAspectRatio="xMidYMid slice">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 1000" preserveAspectRatio="xMidYMid slice">
|
||||||
<defs>
|
<defs>
|
||||||
<radialGradient id="a" cx="20%" cy="18%" r="55%"><stop offset="0%" stop-color="#7a4d18" stop-opacity=".95"/><stop offset="100%" stop-color="#7a4d18" stop-opacity="0"/></radialGradient>
|
<radialGradient id="a" cx="20%" cy="18%" r="55%"><stop offset="0%" stop-color="#5a2a6a" stop-opacity=".95"/><stop offset="100%" stop-color="#5a2a6a" stop-opacity="0"/></radialGradient>
|
||||||
<radialGradient id="b" cx="86%" cy="26%" r="52%"><stop offset="0%" stop-color="#e8913c" stop-opacity=".45"/><stop offset="100%" stop-color="#e8913c" stop-opacity="0"/></radialGradient>
|
<radialGradient id="b" cx="86%" cy="26%" r="52%"><stop offset="0%" stop-color="#e879f9" stop-opacity=".45"/><stop offset="100%" stop-color="#e879f9" stop-opacity="0"/></radialGradient>
|
||||||
<radialGradient id="c" cx="68%" cy="92%" r="60%"><stop offset="0%" stop-color="#5a2f14" stop-opacity=".9"/><stop offset="100%" stop-color="#5a2f14" stop-opacity="0"/></radialGradient>
|
<radialGradient id="c" cx="68%" cy="92%" r="60%"><stop offset="0%" stop-color="#3a2050" stop-opacity=".9"/><stop offset="100%" stop-color="#3a2050" stop-opacity="0"/></radialGradient>
|
||||||
<radialGradient id="d" cx="8%" cy="88%" r="55%"><stop offset="0%" stop-color="#403820" stop-opacity=".85"/><stop offset="100%" stop-color="#403820" stop-opacity="0"/></radialGradient>
|
<radialGradient id="d" cx="8%" cy="88%" r="55%"><stop offset="0%" stop-color="#2c3040" stop-opacity=".85"/><stop offset="100%" stop-color="#2c3040" stop-opacity="0"/></radialGradient>
|
||||||
<filter id="s"><feGaussianBlur stdDeviation="60"/></filter>
|
<filter id="s"><feGaussianBlur stdDeviation="60"/></filter>
|
||||||
<filter id="g"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" stitchTiles="stitch"/><feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .5 0"/></filter>
|
<filter id="g"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" stitchTiles="stitch"/><feColorMatrix type="saturate" values="0"/></filter>
|
||||||
</defs>
|
</defs>
|
||||||
<rect width="1600" height="1000" fill="#141009"/>
|
<rect width="1600" height="1000" fill="#111217"/>
|
||||||
<g filter="url(#s)">
|
<g filter="url(#s)">
|
||||||
<rect width="1600" height="1000" fill="url(#a)"/>
|
<rect width="1600" height="1000" fill="url(#a)"/>
|
||||||
<rect width="1600" height="1000" fill="url(#c)"/>
|
<rect width="1600" height="1000" fill="url(#c)"/>
|
||||||
<rect width="1600" height="1000" fill="url(#d)"/>
|
<rect width="1600" height="1000" fill="url(#d)"/>
|
||||||
<rect width="1600" height="1000" fill="url(#b)"/>
|
<rect width="1600" height="1000" fill="url(#b)"/>
|
||||||
</g>
|
</g>
|
||||||
<rect width="1600" height="1000" filter="url(#g)" opacity="0.05"/>
|
<rect width="1600" height="1000" filter="url(#g)" opacity="0.35" style="mix-blend-mode:overlay"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
@@ -1,18 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 1000" preserveAspectRatio="xMidYMid slice">
|
|
||||||
<defs>
|
|
||||||
<radialGradient id="a" cx="20%" cy="18%" r="55%"><stop offset="0%" stop-color="#123b6b" stop-opacity=".95"/><stop offset="100%" stop-color="#123b6b" stop-opacity="0"/></radialGradient>
|
|
||||||
<radialGradient id="b" cx="86%" cy="26%" r="52%"><stop offset="0%" stop-color="#38b6ff" stop-opacity=".45"/><stop offset="100%" stop-color="#38b6ff" stop-opacity="0"/></radialGradient>
|
|
||||||
<radialGradient id="c" cx="68%" cy="92%" r="60%"><stop offset="0%" stop-color="#1f2a72" stop-opacity=".9"/><stop offset="100%" stop-color="#1f2a72" stop-opacity="0"/></radialGradient>
|
|
||||||
<radialGradient id="d" cx="8%" cy="88%" r="55%"><stop offset="0%" stop-color="#0f5566" stop-opacity=".85"/><stop offset="100%" stop-color="#0f5566" stop-opacity="0"/></radialGradient>
|
|
||||||
<filter id="s"><feGaussianBlur stdDeviation="60"/></filter>
|
|
||||||
<filter id="g"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" stitchTiles="stitch"/><feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .5 0"/></filter>
|
|
||||||
</defs>
|
|
||||||
<rect width="1600" height="1000" fill="#040914"/>
|
|
||||||
<g filter="url(#s)">
|
|
||||||
<rect width="1600" height="1000" fill="url(#a)"/>
|
|
||||||
<rect width="1600" height="1000" fill="url(#c)"/>
|
|
||||||
<rect width="1600" height="1000" fill="url(#d)"/>
|
|
||||||
<rect width="1600" height="1000" fill="url(#b)"/>
|
|
||||||
</g>
|
|
||||||
<rect width="1600" height="1000" filter="url(#g)" opacity="0.05"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,34 @@
|
|||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
import reactCompiler from "babel-plugin-react-compiler";
|
||||||
|
|
||||||
|
// ONE React transform, shared by vite.config.ts and vitest.config.ts (E27 S1).
|
||||||
|
//
|
||||||
|
// It is a shared factory rather than two `react()` calls because the two configs must not drift:
|
||||||
|
// if the compiler ran only in the production build, every vitest run would be exercising code the
|
||||||
|
// user never receives, and a compiler-induced behaviour change would ship green. That is precisely
|
||||||
|
// the defect `0.93.2` was released to fix — a gate and a build image that had quietly stopped being
|
||||||
|
// the same thing — so the rule here is that the gate compiles the code the same way the build does.
|
||||||
|
//
|
||||||
|
// The React Compiler auto-memoises: it infers what `useCallback`/`useMemo` were being hand-written
|
||||||
|
// for and applies the equivalent itself. It is enabled repo-wide with no opt-out list because it
|
||||||
|
// bails out on its own for anything it cannot prove safe (its rules are also linted, via
|
||||||
|
// eslint-plugin-react-hooks). `target` follows the installed React major and must move with it.
|
||||||
|
//
|
||||||
|
// The plugin is IMPORTED rather than named by the usual `"babel-plugin-react-compiler"` string. Two
|
||||||
|
// reasons, both about the gate: a string is invisible to knip, which then reports the dependency as
|
||||||
|
// unused, and — worse — a typo in that string is a silent no-op that ships an uncompiled bundle,
|
||||||
|
// while a bad import is a resolve error on the tsc lane.
|
||||||
|
//
|
||||||
|
// The price of the import: plugin-react finds the compiler only by that string, so it doesn't know the
|
||||||
|
// compiler is there. Its one compiler-specific switch is Babel's `retainLines`, which it turns on for
|
||||||
|
// dev (and so for vitest) unless the compiler is present. With the compiler's re-printed JSX that eats
|
||||||
|
// the space at the end of a text line ("for example <code>" rendered "for example/api/feed"). The
|
||||||
|
// production build never had it on. `generatorOpts` wins over the top-level option, so it is switched
|
||||||
|
// off here, for every mode. Pinned by src/jsxWhitespace.dom.test.tsx.
|
||||||
|
export const reactWithCompiler = () =>
|
||||||
|
react({
|
||||||
|
babel: {
|
||||||
|
plugins: [[reactCompiler, { target: "19" }]],
|
||||||
|
generatorOpts: { retainLines: false },
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { lazy, Suspense, useEffect, useState } from "react";
|
import { lazy, Suspense, useEffect, useRef, useState } from "react";
|
||||||
import { qk } from "./lib/queryKeys";
|
import { qk } from "./lib/queryKeys";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
@@ -32,8 +32,10 @@ import Header from "./components/Header";
|
|||||||
import NavSidebar from "./components/NavSidebar";
|
import NavSidebar from "./components/NavSidebar";
|
||||||
import BackToTop from "./components/ui/BackToTop";
|
import BackToTop from "./components/ui/BackToTop";
|
||||||
import PageShell from "./components/ui/PageShell";
|
import PageShell from "./components/ui/PageShell";
|
||||||
import GlassTuner from "./components/GlassTuner";
|
import { SkipLink } from "./components/ui/SkipLink";
|
||||||
|
import { pageTitleKey } from "./lib/pageMeta";
|
||||||
import ChatDock from "./components/ChatDock";
|
import ChatDock from "./components/ChatDock";
|
||||||
|
import DebugRecorder from "./components/DebugRecorder";
|
||||||
import Toaster from "./components/ui/Toaster";
|
import Toaster from "./components/ui/Toaster";
|
||||||
import KeyboardHelp from "./components/ui/KeyboardHelp";
|
import KeyboardHelp from "./components/ui/KeyboardHelp";
|
||||||
import AppCommandPalette from "./components/AppCommandPalette";
|
import AppCommandPalette from "./components/AppCommandPalette";
|
||||||
@@ -44,8 +46,8 @@ import DemoBanner from "./components/DemoBanner";
|
|||||||
import VerifyBanner from "./components/VerifyBanner";
|
import VerifyBanner from "./components/VerifyBanner";
|
||||||
import { focusAccessRequestsTab } from "./lib/adminUsersTab";
|
import { focusAccessRequestsTab } from "./lib/adminUsersTab";
|
||||||
// The built app version, NOT imported from releaseNotes — importing anything from that module
|
// The built app version, NOT imported from releaseNotes — importing anything from that module
|
||||||
// pulls its whole ~900-line changelog into the eager App chunk and defeats the lazy ReleaseNotes
|
// pulls the release-notes loader into the eager App chunk and defeats the lazy ReleaseNotes
|
||||||
// split. FRONTEND_VERSION equals RELEASE_NOTES[0].version by release convention (both bump together).
|
// split. FRONTEND_VERSION equals the newest release note's version by release convention (both bump together).
|
||||||
import { FRONTEND_VERSION } from "./lib/version";
|
import { FRONTEND_VERSION } from "./lib/version";
|
||||||
import { applyDiagnostics } from "./lib/diagnostics";
|
import { applyDiagnostics } from "./lib/diagnostics";
|
||||||
|
|
||||||
@@ -130,6 +132,9 @@ export default function App() {
|
|||||||
});
|
});
|
||||||
const needsSetup = setupQuery.data?.configured === false;
|
const needsSetup = setupQuery.data?.configured === false;
|
||||||
const meQuery = useQuery({ queryKey: qk.me(), queryFn: api.me, enabled: false });
|
const meQuery = useQuery({ queryKey: qk.me(), queryFn: api.me, enabled: false });
|
||||||
|
// The signed-in account, or null/undefined while signed out. Hoisted here because half a dozen
|
||||||
|
// effects below depend on it, and a dep list is only honest if it names the value the body reads.
|
||||||
|
const me = meQuery.data;
|
||||||
|
|
||||||
// The `global` key scope: `h` opens the cheat sheet, Mod+K the jump-to palette, Shift+W jumps to
|
// The `global` key scope: `h` opens the cheat sheet, Mod+K the jump-to palette, Shift+W jumps to
|
||||||
// Home ("mit nézzek?"). Bound only once signed in, because that is where the overlays are mounted
|
// Home ("mit nézzek?"). Bound only once signed in, because that is where the overlays are mounted
|
||||||
@@ -140,26 +145,40 @@ export default function App() {
|
|||||||
// page underneath (owner decision 2026-09-09: the key always ends with the answer on screen).
|
// page underneath (owner decision 2026-09-09: the key always ends with the answer on screen).
|
||||||
// The `page` scope rides the same binding: the `g` chords jump to a module, but only to one this
|
// The `page` scope rides the same binding: the `g` chords jump to a module, but only to one this
|
||||||
// account can reach (the nav rail's own list, plus Settings, which is not in the rail's order).
|
// account can reach (the nav rail's own list, plus Settings, which is not in the rail's order).
|
||||||
useGlobalKeymap(
|
useGlobalKeymap(!!me, setPage, me ? [...moduleOrder(me), "settings"] : []);
|
||||||
!!meQuery.data,
|
|
||||||
setPage,
|
|
||||||
meQuery.data ? [...moduleOrder(meQuery.data), "settings"] : [],
|
|
||||||
);
|
|
||||||
|
|
||||||
// The account's consented diagnostics level (E22 S5), applied to this tab's log: raised, the log is
|
// The account's consented diagnostics level (E22 S5), applied to this tab's log: raised, the log is
|
||||||
// kept in more detail and streamed to the server until the level lapses. A lapse (its own timer, or
|
// kept in more detail and streamed to the server until the level lapses. A debug session that is
|
||||||
// the server saying it is off) refetches `me`, so Settings shows the base level again.
|
// recording (E16) raises it for its own run and tags what it keeps. A lapse (its own timer, or the
|
||||||
const diagnostics = meQuery.data?.diagnostics;
|
// server saying it is off) refetches `me`, so Settings and the recorder show what is in force.
|
||||||
|
// Re-running this on every `me` refetch is FREE and therefore the deps are the honest ones:
|
||||||
|
// applyDiagnostics computes an effective-level key and returns early when it has not moved
|
||||||
|
// (lib/diagnostics.ts), so an unchanged level is a no-op. It does refresh the `lapsed` callback
|
||||||
|
// before that early return, which is the half we WANT re-run. The old dep list picked out four
|
||||||
|
// primitive fields to dodge the fresh object identity a refetch produces; that is the checker's
|
||||||
|
// job to verify, not a comment's to assert, and it silently missed any other field the module
|
||||||
|
// may come to read.
|
||||||
|
const diagnostics = me?.diagnostics;
|
||||||
|
const debugSession = me?.debug_session;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
applyDiagnostics(diagnostics, () => void queryClient.invalidateQueries({ queryKey: qk.me() }));
|
applyDiagnostics(
|
||||||
}, [diagnostics?.level, diagnostics?.until]); // eslint-disable-line react-hooks/exhaustive-deps
|
diagnostics,
|
||||||
|
debugSession,
|
||||||
|
() => void queryClient.invalidateQueries({ queryKey: qk.me() }),
|
||||||
|
);
|
||||||
|
}, [diagnostics, debugSession, queryClient]);
|
||||||
|
|
||||||
// Fetch the code of every module this account can open — and the channel page — once the first page
|
// Fetch the code of every module this account can open — and the channel page — once the first page
|
||||||
// is on screen, so a module's first visit renders at once instead of flashing the chunk fallback.
|
// is on screen, so a module's first visit renders at once instead of flashing the chunk fallback.
|
||||||
// Idle-scheduled so it never competes with the first page's own requests (Safari has no
|
// Idle-scheduled so it never competes with the first page's own requests (Safari has no
|
||||||
// requestIdleCallback, hence the timer).
|
// requestIdleCallback, hence the timer).
|
||||||
|
// Depending on the whole `me` rather than its id is both honest and MORE correct: the module set
|
||||||
|
// is derived from `me` through each module's own gate (lib/modules moduleOrder), so a permission
|
||||||
|
// change — an admin grant, a Plex server appearing — alters it without the id moving, and the old
|
||||||
|
// `[me.id]` dep never re-preloaded for those. Re-running costs nothing: `preload()` is idempotent
|
||||||
|
// (lazyWithPreload memoises the pending promise), so a refetch that changes nothing fetches
|
||||||
|
// nothing; only the idle callback is re-scheduled.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const me = meQuery.data;
|
|
||||||
if (!me) return;
|
if (!me) return;
|
||||||
const run = () => {
|
const run = () => {
|
||||||
preloadPages([...moduleOrder(me), "settings"]);
|
preloadPages([...moduleOrder(me), "settings"]);
|
||||||
@@ -172,7 +191,7 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
const id = window.setTimeout(run, 1000);
|
const id = window.setTimeout(run, 1000);
|
||||||
return () => window.clearTimeout(id);
|
return () => window.clearTimeout(id);
|
||||||
}, [meQuery.data?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [me]);
|
||||||
|
|
||||||
// Measure the scrollbar's gutter width once and publish it as --sbw, so the floating header and the
|
// Measure the scrollbar's gutter width once and publish it as --sbw, so the floating header and the
|
||||||
// toolbar band can pad their right edge to line up with the scrolled cards (the app scroller reserves
|
// toolbar band can pad their right edge to line up with the scrolled cards (the app scroller reserves
|
||||||
@@ -190,9 +209,13 @@ export default function App() {
|
|||||||
// Once signed in, drop any leftover ONE-TIME query params from the pre-login flow (?access, ?verify,
|
// Once signed in, drop any leftover ONE-TIME query params from the pre-login flow (?access, ?verify,
|
||||||
// ?reset) — but NOT the nav/filter params, which the logged-in app now reads from and keeps in the
|
// ?reset) — but NOT the nav/filter params, which the logged-in app now reads from and keeps in the
|
||||||
// URL. Gated on auth so the logged-out Welcome page still reads its params first.
|
// URL. Gated on auth so the logged-out Welcome page still reads its params first.
|
||||||
|
// Keyed on "is there a session at all", which is the whole condition the body tests. Re-running
|
||||||
|
// on an account switch was never the point — by then the params are already gone and a second
|
||||||
|
// clearParams is a no-op.
|
||||||
|
const signedIn = !!me;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (meQuery.data) clearParams("access", "verify", "reset");
|
if (signedIn) clearParams("access", "verify", "reset");
|
||||||
}, [meQuery.data?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [signedIn]);
|
||||||
|
|
||||||
// Any request that 401s means the session ended server-side. If we were signed in (cached
|
// Any request that 401s means the session ended server-side. If we were signed in (cached
|
||||||
// `me` is a user object), reload to the login page at once (also covers any click that hits
|
// `me` is a user object), reload to the login page at once (also covers any click that hits
|
||||||
@@ -211,12 +234,20 @@ export default function App() {
|
|||||||
// Returning from an in-app Google link/upgrade round-trip (auth.py redirects to ?link=…).
|
// Returning from an in-app Google link/upgrade round-trip (auth.py redirects to ?link=…).
|
||||||
// Surface the outcome, refresh `me` (can_read/can_write/has_google may have flipped), land on
|
// Surface the outcome, refresh `me` (can_read/can_write/has_google may have flipped), land on
|
||||||
// Settings → Account so the new state is visible, then strip the param for a clean URL.
|
// Settings → Account so the new state is visible, then strip the param for a clean URL.
|
||||||
|
// "Exactly once per mount" is now ENFORCED by the ref rather than asserted by an empty dep list:
|
||||||
|
// `t` changes identity on a language switch, so the honest dep list would otherwise re-fire the
|
||||||
|
// toast when the user changes language. (The old `[]` got away with it only because clearParams
|
||||||
|
// had already removed the param — an invariant held by a side effect two lines further down.)
|
||||||
|
const linkAnswered = useRef(false);
|
||||||
|
const { refetch: refetchMe } = meQuery;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (linkAnswered.current) return;
|
||||||
const result = new URLSearchParams(window.location.search).get("link");
|
const result = new URLSearchParams(window.location.search).get("link");
|
||||||
if (!result) return;
|
if (!result) return;
|
||||||
|
linkAnswered.current = true;
|
||||||
if (result === "ok") {
|
if (result === "ok") {
|
||||||
notify({ level: "success", message: t("settings.account.googleLink.linked") });
|
notify({ level: "success", message: t("settings.account.googleLink.linked") });
|
||||||
void meQuery.refetch();
|
void refetchMe();
|
||||||
setAccountRaw(LS.settingsTab, "account");
|
setAccountRaw(LS.settingsTab, "account");
|
||||||
setPage("settings");
|
setPage("settings");
|
||||||
} else {
|
} else {
|
||||||
@@ -224,42 +255,59 @@ export default function App() {
|
|||||||
notify({ level: "error", message: t(`settings.account.googleLink.${key}`) });
|
notify({ level: "error", message: t(`settings.account.googleLink.${key}`) });
|
||||||
}
|
}
|
||||||
clearParams("link");
|
clearParams("link");
|
||||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [t, setPage, refetchMe]);
|
||||||
|
|
||||||
// A Google OAuth flow started while already signed in (add-another-account, link, or upgrade) was
|
// A Google OAuth flow started while already signed in (add-another-account, link, or upgrade) was
|
||||||
// cancelled at Google's consent screen. The backend only emits this for a live session, so a
|
// cancelled at Google's consent screen. The backend only emits this for a live session, so a
|
||||||
// neutral toast is the right feedback — the pre-auth login banner would never render here.
|
// neutral toast is the right feedback — the pre-auth login banner would never render here.
|
||||||
|
// Same one-shot shape as the `?link=` handler above, same reason for the ref.
|
||||||
|
const oauthAnswered = useRef(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (oauthAnswered.current) return;
|
||||||
if (new URLSearchParams(window.location.search).get("oauth") !== "cancelled") return;
|
if (new URLSearchParams(window.location.search).get("oauth") !== "cancelled") return;
|
||||||
|
oauthAnswered.current = true;
|
||||||
notify({ level: "info", message: t("settings.account.googleCancelled") });
|
notify({ level: "info", message: t("settings.account.googleCancelled") });
|
||||||
clearParams("oauth");
|
clearParams("oauth");
|
||||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [t]);
|
||||||
|
|
||||||
// Deep-link from the "new access request" admin email (?admin=access-requests) → jump straight to
|
// Deep-link from the "new access request" admin email (?admin=access-requests) → jump straight to
|
||||||
// the admin Users → Access requests view. Snapshot the param up front (read once at mount); act once
|
// the admin Users → Access requests view. Snapshot the param up front (read once at mount); act once
|
||||||
// `me` is known, and only for an admin (others just land on their default page).
|
// `me` is known, and only for an admin (others just land on their default page).
|
||||||
const [adminDeepLink] = useState(() => new URLSearchParams(window.location.search).get("admin"));
|
const [adminDeepLink] = useState(() => new URLSearchParams(window.location.search).get("admin"));
|
||||||
|
// Ref-guarded like the two above, and here it closes a real hole rather than only satisfying the
|
||||||
|
// checker: the snapshot lives for the whole session, so with honest deps a later role change —
|
||||||
|
// or any `me` refetch, under the old id-only key — would yank the user to Users long after the
|
||||||
|
// deep link was answered.
|
||||||
|
const adminDeepLinkAnswered = useRef(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (adminDeepLink !== "access-requests" || !meQuery.data) return;
|
if (adminDeepLinkAnswered.current) return;
|
||||||
if (meQuery.data.role === "admin") {
|
if (adminDeepLink !== "access-requests" || !me) return;
|
||||||
|
adminDeepLinkAnswered.current = true;
|
||||||
|
if (me.role === "admin") {
|
||||||
focusAccessRequestsTab();
|
focusAccessRequestsTab();
|
||||||
setPage("users");
|
setPage("users");
|
||||||
}
|
}
|
||||||
clearParams("admin");
|
clearParams("admin");
|
||||||
}, [adminDeepLink, meQuery.data?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [adminDeepLink, me, setPage]);
|
||||||
|
|
||||||
// Nudge admins when access requests are waiting (in lieu of a server-push bell). Keyed on the
|
// Nudge admins when access requests are waiting (in lieu of a server-push bell). Keyed on the
|
||||||
// pending COUNT so it re-resolves the moment an approval/denial changes it — not only on a
|
// pending COUNT so it re-resolves the moment an approval/denial changes it — not only on a
|
||||||
// fresh load (which is why the notice used to linger until F5 after an approval).
|
// fresh load (which is why the notice used to linger until F5 after an approval).
|
||||||
|
// Honest deps, and `t` among them is a fix rather than a concession: the nudge is a persisted
|
||||||
|
// toast whose text is translated at issue time, so a language switch must re-issue it. It did not
|
||||||
|
// before — the notice stayed in the old language until the count changed.
|
||||||
|
const role = me?.role;
|
||||||
|
const pendingInvites = me?.pending_invites ?? 0;
|
||||||
|
const pendingActivations = me?.pending_activations ?? 0;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (meQuery.data?.role !== "admin") return;
|
if (role !== "admin") return;
|
||||||
// Keep a single, current nudge: drop any prior one (persisted copies reload as dismissed
|
// Keep a single, current nudge: drop any prior one (persisted copies reload as dismissed
|
||||||
// history, so they'd otherwise pile up one-per-reload) before re-issuing the live notice.
|
// history, so they'd otherwise pile up one-per-reload) before re-issuing the live notice.
|
||||||
removeByMetaKind("access-requests");
|
removeByMetaKind("access-requests");
|
||||||
// Both kinds of "someone is waiting on you": a request needing a decision, and a sign-up whose
|
// Both kinds of "someone is waiting on you": a request needing a decision, and a sign-up whose
|
||||||
// address was approved before they registered. The second used to produce NO signal at all —
|
// address was approved before they registered. The second used to produce NO signal at all —
|
||||||
// nothing lands in the queue for it — which is how the 2026-09-03 registrant went unnoticed.
|
// nothing lands in the queue for it — which is how the 2026-09-03 registrant went unnoticed.
|
||||||
const pending = (meQuery.data.pending_invites ?? 0) + (meQuery.data.pending_activations ?? 0);
|
const pending = pendingInvites + pendingActivations;
|
||||||
if (pending > 0) {
|
if (pending > 0) {
|
||||||
notify({
|
notify({
|
||||||
level: "warning",
|
level: "warning",
|
||||||
@@ -278,8 +326,7 @@ export default function App() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
}, [role, pendingInvites, pendingActivations, t, setPage]);
|
||||||
}, [meQuery.data?.role, meQuery.data?.pending_invites, meQuery.data?.pending_activations]);
|
|
||||||
|
|
||||||
// Language is edited outside the Settings page (sidebar/login), so it stays instant.
|
// Language is edited outside the Settings page (sidebar/login), so it stays instant.
|
||||||
function changeLanguage(code: LangCode) {
|
function changeLanguage(code: LangCode) {
|
||||||
@@ -295,7 +342,7 @@ export default function App() {
|
|||||||
if (needsSetup) return <SetupWizard />;
|
if (needsSetup) return <SetupWizard />;
|
||||||
// /api/me answers 200 always (null body = logged out), and BootGate has already handled a failed
|
// /api/me answers 200 always (null body = logged out), and BootGate has already handled a failed
|
||||||
// probe — so no data here means "not signed in" → the public landing.
|
// probe — so no data here means "not signed in" → the public landing.
|
||||||
if (!meQuery.data) return <Welcome verify={verifyEntry} />;
|
if (!me) return <Welcome verify={verifyEntry} />;
|
||||||
|
|
||||||
// Shown while a page's chunk is still on its way: only if its visit beats the preload above (or the
|
// Shown while a page's chunk is still on its way: only if its visit beats the preload above (or the
|
||||||
// preload failed), and like every loading state it stays invisible for a load that is quick anyway.
|
// preload failed), and like every loading state it stays invisible for a load that is quick anyway.
|
||||||
@@ -316,16 +363,16 @@ export default function App() {
|
|||||||
// page the account can't reach (a gated module via stale nav / a deep link) falls back to the feed,
|
// page the account can't reach (a gated module via stale nav / a deep link) falls back to the feed,
|
||||||
// exactly as the old ternary's fallthrough did. A channel page overlays the content column, so no
|
// exactly as the old ternary's fallthrough did. A channel page overlays the content column, so no
|
||||||
// side rail shows behind it.
|
// side rail shows behind it.
|
||||||
const me = meQuery.data!;
|
|
||||||
const pageGate = moduleDef(page).gate;
|
const pageGate = moduleDef(page).gate;
|
||||||
const activePage: Page = !pageGate || pageGate(me) ? page : "feed";
|
const activePage: Page = !pageGate || pageGate(me!) ? page : "feed";
|
||||||
const { Component: PageContent, maxWidth } = PAGE_CONTENT[activePage];
|
const { Component: PageContent, maxWidth } = PAGE_CONTENT[activePage];
|
||||||
const railDef = channelView ? undefined : PAGE_RAIL[activePage];
|
const railDef = channelView ? undefined : PAGE_RAIL[activePage];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-screen flex text-fg">
|
<div className="h-dvh flex text-fg">
|
||||||
|
<SkipLink label={t("nav.skipToContent")} />
|
||||||
<NavSidebar
|
<NavSidebar
|
||||||
me={meQuery.data!}
|
me={me!}
|
||||||
onOpenAbout={() => setAboutOpen(true)}
|
onOpenAbout={() => setAboutOpen(true)}
|
||||||
onChangeLanguage={changeLanguage}
|
onChangeLanguage={changeLanguage}
|
||||||
language={i18n.language as LangCode}
|
language={i18n.language as LangCode}
|
||||||
@@ -361,7 +408,7 @@ export default function App() {
|
|||||||
key={channelView.id}
|
key={channelView.id}
|
||||||
channelId={channelView.id}
|
channelId={channelView.id}
|
||||||
initialName={channelView.name}
|
initialName={channelView.name}
|
||||||
me={meQuery.data!}
|
me={me!}
|
||||||
view={view}
|
view={view}
|
||||||
setView={setView}
|
setView={setView}
|
||||||
onShowInFeed={() => {
|
onShowInFeed={() => {
|
||||||
@@ -384,13 +431,14 @@ export default function App() {
|
|||||||
</PageShell>
|
</PageShell>
|
||||||
) : (
|
) : (
|
||||||
<PageShell
|
<PageShell
|
||||||
|
title={t(pageTitleKey(activePage))}
|
||||||
scrollKey={normalScrollKey}
|
scrollKey={normalScrollKey}
|
||||||
maxWidth={maxWidth}
|
maxWidth={maxWidth}
|
||||||
header={<Header me={meQuery.data!} onYtSearch={enterYtSearch} />}
|
header={<Header me={me!} onYtSearch={enterYtSearch} />}
|
||||||
banners={
|
banners={
|
||||||
<>
|
<>
|
||||||
<VerifyBanner entry={verifyEntry} />
|
<VerifyBanner entry={verifyEntry} />
|
||||||
{meQuery.data!.is_demo && <DemoBanner />}
|
{me!.is_demo && <DemoBanner />}
|
||||||
<VersionBanner onOpen={() => openReleaseNotes(FRONTEND_VERSION)} />
|
<VersionBanner onOpen={() => openReleaseNotes(FRONTEND_VERSION)} />
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
@@ -410,7 +458,9 @@ export default function App() {
|
|||||||
"take me to X" the same way. Their keys are bound by useGlobalKeymap above. */}
|
"take me to X" the same way. Their keys are bound by useGlobalKeymap above. */}
|
||||||
<KeyboardHelp />
|
<KeyboardHelp />
|
||||||
<AppCommandPalette />
|
<AppCommandPalette />
|
||||||
{meQuery.data && !meQuery.data.is_demo && <ChatDock meId={meQuery.data.id} />}
|
{me && !me.is_demo && <ChatDock meId={me.id} />}
|
||||||
|
{/* The debug recorder (E16): its start dialog, and its pill docked under the toasts. */}
|
||||||
|
{me && !me.is_demo && <DebugRecorder />}
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
{aboutOpen && (
|
{aboutOpen && (
|
||||||
<About
|
<About
|
||||||
@@ -428,8 +478,6 @@ export default function App() {
|
|||||||
<ErrorDialog />
|
<ErrorDialog />
|
||||||
{/* Re-binds to the active page's scroll container on navigation (page or channel change). */}
|
{/* Re-binds to the active page's scroll container on navigation (page or channel change). */}
|
||||||
<BackToTop dep={channelView ? `chan:${channelView.id}` : page} />
|
<BackToTop dep={channelView ? `chan:${channelView.id}` : page} />
|
||||||
{/* Opt-in live appearance tuner (Settings → Appearance; off by default). */}
|
|
||||||
<GlassTuner />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ export default function About({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
|
initialFocus="close"
|
||||||
title={
|
title={
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<Info className="w-5 h-5 text-accent" /> {t("about.title")}
|
<Info className="w-5 h-5 text-accent" /> {t("about.title")}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { api } from "../lib/api";
|
|||||||
import ChannelAboutContent from "./ChannelAbout";
|
import ChannelAboutContent from "./ChannelAbout";
|
||||||
import { PopoverSurface } from "./ui/AnchoredPopover";
|
import { PopoverSurface } from "./ui/AnchoredPopover";
|
||||||
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
|
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
|
||||||
|
import { SURFACE } from "./ui/surfaces";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A channel-description cell: one truncated line in the table, revealing the FULL channel "About"
|
* A channel-description cell: one truncated line in the table, revealing the FULL channel "About"
|
||||||
@@ -56,7 +57,7 @@ export default function AboutCell({ text, channelId }: { text: string | null; ch
|
|||||||
</span>
|
</span>
|
||||||
<PopoverSurface
|
<PopoverSurface
|
||||||
popover={pop}
|
popover={pop}
|
||||||
className="glass w-120 overflow-y-auto px-3.5 py-3 rounded-lg animate-[fadeIn_0.12s_ease]"
|
className={`${SURFACE.popover} w-120 overflow-y-auto px-3.5 py-3 animate-[fadeIn_0.12s_ease]`}
|
||||||
// min(design cap, available space): keeps the 28rem cap on a tall viewport, shrinks to fit on a
|
// min(design cap, available space): keeps the 28rem cap on a tall viewport, shrinks to fit on a
|
||||||
// short one (high zoom). --fit-max-h is set by the fitHeight `size` middleware once positioned.
|
// short one (high zoom). --fit-max-h is set by the fitHeight `size` middleware once positioned.
|
||||||
style={{ maxHeight: "min(28rem, var(--fit-max-h, 28rem))" }}
|
style={{ maxHeight: "min(28rem, var(--fit-max-h, 28rem))" }}
|
||||||
|
|||||||
@@ -13,7 +13,9 @@ import { copyText } from "../lib/clipboard";
|
|||||||
import { formatDateTime } from "../lib/format";
|
import { formatDateTime } from "../lib/format";
|
||||||
import { invalidateUserAccess } from "../lib/adminUsers";
|
import { invalidateUserAccess } from "../lib/adminUsers";
|
||||||
import type { AdminUserAccess, AdminUserRow } from "../lib/api/admin";
|
import type { AdminUserAccess, AdminUserRow } from "../lib/api/admin";
|
||||||
import { FocusSafeButton } from "./ui/FocusSafeButton";
|
import { Button } from "./ui/Button";
|
||||||
|
import { SURFACE } from "./ui/surfaces";
|
||||||
|
import { SectionLabel } from "./ui/Headings";
|
||||||
|
|
||||||
// The admin's answer to "why can this person not get in?", and the place every recovery action for
|
// The admin's answer to "why can this person not get in?", and the place every recovery action for
|
||||||
// one account lives (E12 · S5, design option C).
|
// one account lives (E12 · S5, design option C).
|
||||||
@@ -103,8 +105,14 @@ export default function AccountPanel({
|
|||||||
const a: AdminUserAccess | undefined = q.data;
|
const a: AdminUserAccess | undefined = q.data;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal title={displayName(user)} onClose={onClose} placement="drawer" maxWidth="max-w-md">
|
<Modal
|
||||||
<div className="text-2xs text-muted truncate -mt-2 mb-4">{user.email}</div>
|
initialFocus="close"
|
||||||
|
title={displayName(user)}
|
||||||
|
onClose={onClose}
|
||||||
|
placement="drawer"
|
||||||
|
maxWidth="max-w-md"
|
||||||
|
>
|
||||||
|
<div className="text-2xs text-muted truncate mb-4">{user.email}</div>
|
||||||
|
|
||||||
{q.isError && !a ? (
|
{q.isError && !a ? (
|
||||||
<StateMessage tone="error" onRetry={() => q.refetch()}>
|
<StateMessage tone="error" onRetry={() => q.refetch()}>
|
||||||
@@ -114,10 +122,10 @@ export default function AccountPanel({
|
|||||||
<LoadingState />
|
<LoadingState />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="text-2xs uppercase tracking-wider text-muted mb-2">
|
<SectionLabel level={3} className="mb-2">
|
||||||
{t("users.account.access")}
|
{t("users.account.access")}
|
||||||
</div>
|
</SectionLabel>
|
||||||
<div className="glass-card rounded-xl p-3 space-y-2 mb-5">
|
<div className={`${SURFACE.card} p-3 space-y-2 mb-5`}>
|
||||||
<AccessRow
|
<AccessRow
|
||||||
icon={Mail}
|
icon={Mail}
|
||||||
label={t("users.account.email")}
|
label={t("users.account.email")}
|
||||||
@@ -191,29 +199,33 @@ export default function AccountPanel({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-2xs uppercase tracking-wider text-muted mb-2">
|
<SectionLabel level={3} className="mb-2">
|
||||||
{t("users.account.recovery")}
|
{t("users.account.recovery")}
|
||||||
</div>
|
</SectionLabel>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{a.can_activate && (
|
{a.can_activate && (
|
||||||
<FocusSafeButton
|
<Button
|
||||||
|
variant="glass"
|
||||||
|
fullWidth
|
||||||
|
icon={<Check />}
|
||||||
|
loading={activate.isPending}
|
||||||
onClick={() => activate.mutate()}
|
onClick={() => activate.mutate()}
|
||||||
disabled={activate.isPending}
|
className="border border-accent/40 text-accent"
|
||||||
className="w-full glass-card glass-hover flex items-center gap-2 px-3 py-2.5 rounded-lg text-xs border border-accent/40 text-accent disabled:opacity-50 transition"
|
|
||||||
>
|
>
|
||||||
<Check className="w-4 h-4 shrink-0" />
|
|
||||||
<span className="flex-1 text-left">{t("users.account.activate")}</span>
|
<span className="flex-1 text-left">{t("users.account.activate")}</span>
|
||||||
</FocusSafeButton>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<FocusSafeButton
|
<Button
|
||||||
|
variant="glass"
|
||||||
|
fullWidth
|
||||||
|
icon={<Send />}
|
||||||
|
loading={resend.isPending}
|
||||||
onClick={() => resend.mutate()}
|
onClick={() => resend.mutate()}
|
||||||
// The demo account's address is never confirmed, and the server refuses to mail it — as
|
// The demo account's address is never confirmed, and the server refuses to mail it — as
|
||||||
// it refuses it a set-password link below (deep-review E11 round 3).
|
// it refuses it a set-password link below (deep-review E11 round 3).
|
||||||
disabled={user.is_demo || !a.email_configured || a.email_verified || resend.isPending}
|
disabled={user.is_demo || !a.email_configured || a.email_verified}
|
||||||
className="w-full glass-card glass-hover flex items-center gap-2 px-3 py-2.5 rounded-lg text-xs disabled:opacity-45 transition"
|
|
||||||
>
|
>
|
||||||
<Send className="w-4 h-4 shrink-0" />
|
|
||||||
<span className="flex-1 text-left">{t("users.account.resend")}</span>
|
<span className="flex-1 text-left">{t("users.account.resend")}</span>
|
||||||
{/* Says WHY it is unavailable instead of just greying out — the no-mail case is the
|
{/* Says WHY it is unavailable instead of just greying out — the no-mail case is the
|
||||||
whole reason the link below exists, so naming it here is the explanation. */}
|
whole reason the link below exists, so naming it here is the explanation. */}
|
||||||
@@ -223,18 +235,21 @@ export default function AccountPanel({
|
|||||||
{a.email_configured && a.email_verified && (
|
{a.email_configured && a.email_verified && (
|
||||||
<span className="text-muted">{t("users.account.alreadyConfirmed")}</span>
|
<span className="text-muted">{t("users.account.alreadyConfirmed")}</span>
|
||||||
)}
|
)}
|
||||||
</FocusSafeButton>
|
</Button>
|
||||||
|
|
||||||
<FocusSafeButton
|
<Button
|
||||||
|
variant="glass"
|
||||||
|
fullWidth
|
||||||
|
icon={<KeyRound />}
|
||||||
|
loading={issue.isPending}
|
||||||
|
disabled={user.is_demo}
|
||||||
onClick={() => issue.mutate()}
|
onClick={() => issue.mutate()}
|
||||||
disabled={issue.isPending || user.is_demo}
|
className="border border-accent/40 text-accent"
|
||||||
className="w-full glass-card glass-hover flex items-center gap-2 px-3 py-2.5 rounded-lg text-xs border border-accent/40 text-accent disabled:opacity-50 transition"
|
|
||||||
>
|
>
|
||||||
<KeyRound className="w-4 h-4 shrink-0" />
|
|
||||||
<span className="flex-1 text-left">
|
<span className="flex-1 text-left">
|
||||||
{link ? t("users.account.setPasswordAgain") : t("users.account.setPassword")}
|
{link ? t("users.account.setPasswordAgain") : t("users.account.setPassword")}
|
||||||
</span>
|
</span>
|
||||||
</FocusSafeButton>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{link && (
|
{link && (
|
||||||
@@ -243,13 +258,14 @@ export default function AccountPanel({
|
|||||||
<div className="flex-1 min-w-0 bg-surface border border-border rounded-lg px-3 py-2 text-2xs font-mono truncate">
|
<div className="flex-1 min-w-0 bg-surface border border-border rounded-lg px-3 py-2 text-2xs font-mono truncate">
|
||||||
{link.url}
|
{link.url}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<Button
|
||||||
|
variant="glass"
|
||||||
|
icon={copied ? <Check /> : <Copy />}
|
||||||
onClick={copy}
|
onClick={copy}
|
||||||
className="shrink-0 glass-card glass-hover inline-flex items-center gap-1.5 px-3 py-2 rounded-lg text-xs border border-accent/40 text-accent transition"
|
className="border border-accent/40 text-accent"
|
||||||
>
|
>
|
||||||
{copied ? <Check className="w-3.5 h-3.5" /> : <Copy className="w-3.5 h-3.5" />}
|
|
||||||
{copied ? t("users.account.copied") : t("users.account.copy")}
|
{copied ? t("users.account.copied") : t("users.account.copy")}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-2xs text-muted leading-relaxed">{t("users.account.linkNote")}</p>
|
<p className="text-2xs text-muted leading-relaxed">{t("users.account.linkNote")}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ import { decideChipLayout } from "../lib/chipOverflow";
|
|||||||
import { chipLaneClass, useChipOverflowFit } from "../lib/useChipOverflowFit";
|
import { chipLaneClass, useChipOverflowFit } from "../lib/useChipOverflowFit";
|
||||||
import { PopoverItem, PopoverSurface } from "./ui/AnchoredPopover";
|
import { PopoverItem, PopoverSurface } from "./ui/AnchoredPopover";
|
||||||
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
|
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
|
||||||
|
import { Button } from "./ui/Button";
|
||||||
|
import { Chip } from "./ui/Chip";
|
||||||
|
import { chipClass } from "./ui/chipStyles";
|
||||||
|
import { SURFACE } from "./ui/surfaces";
|
||||||
|
|
||||||
// The applied-filter row above the feed: every narrowing currently in force, each removable on its
|
// The applied-filter row above the feed: every narrowing currently in force, each removable on its
|
||||||
// own. It exists because the filter panel now sits collapsed as a tab — without this you'd have to
|
// own. It exists because the filter panel now sits collapsed as a tab — without this you'd have to
|
||||||
@@ -22,23 +26,19 @@ import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
|
|||||||
// the "fit some + N more" treatment used on wider lanes.
|
// the "fit some + N more" treatment used on wider lanes.
|
||||||
const NARROW_PX = 420;
|
const NARROW_PX = 420;
|
||||||
|
|
||||||
const chipCls =
|
|
||||||
"chip group inline-flex items-center gap-1 pl-2.5 pr-1.5 py-1 text-sm whitespace-nowrap hover:border-accent hover:bg-accent/20 transition";
|
|
||||||
|
|
||||||
function ChipButton({ f, testId }: { f: ActiveFilter; testId?: string }) {
|
function ChipButton({ f, testId }: { f: ActiveFilter; testId?: string }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<button
|
<Chip
|
||||||
|
variant="token"
|
||||||
|
size="md"
|
||||||
data-testid={testId}
|
data-testid={testId}
|
||||||
data-filter-key={f.key}
|
data-filter-key={f.key}
|
||||||
onClick={f.remove}
|
onClick={f.remove}
|
||||||
title={t("feed.chips.remove", { name: f.label })}
|
removeLabel={t("feed.chips.remove", { name: f.label })}
|
||||||
aria-label={t("feed.chips.remove", { name: f.label })}
|
|
||||||
className={chipCls}
|
|
||||||
>
|
>
|
||||||
<span className="max-w-56 truncate">{f.label}</span>
|
<span className="max-w-56 truncate">{f.label}</span>
|
||||||
<X className="w-3 h-3 shrink-0 opacity-60 group-hover:opacity-100" />
|
</Chip>
|
||||||
</button>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,22 +69,20 @@ function OverflowPopover({
|
|||||||
const name = ariaLabel ?? label;
|
const name = ariaLabel ?? label;
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<button
|
<Chip
|
||||||
type="button"
|
variant="token"
|
||||||
|
size="md"
|
||||||
ref={pop.refs.setReference}
|
ref={pop.refs.setReference}
|
||||||
{...pop.getReferenceProps()}
|
{...pop.getReferenceProps()}
|
||||||
aria-label={name}
|
aria-label={name}
|
||||||
className={`chip inline-flex shrink-0 items-center gap-1 px-2.5 py-1 text-sm whitespace-nowrap transition ${
|
icon={icon ? <Filter className="size-3.5" aria-hidden="true" /> : undefined}
|
||||||
open ? "border-accent text-accent" : "hover:border-accent"
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{icon && <Filter className="w-3.5 h-3.5 shrink-0" aria-hidden="true" />}
|
|
||||||
{label}
|
{label}
|
||||||
</button>
|
</Chip>
|
||||||
<PopoverSurface
|
<PopoverSurface
|
||||||
popover={pop}
|
popover={pop}
|
||||||
ariaLabel={name}
|
ariaLabel={name}
|
||||||
className="glass-menu min-w-48 max-w-72 rounded-xl p-1.5 shadow-lg animate-[popIn_0.16s_ease]"
|
className={`${SURFACE.menu} min-w-48 max-w-72 animate-[popIn_0.16s_ease]`}
|
||||||
>
|
>
|
||||||
<div role="group" aria-label={name} className="max-h-72 overflow-y-auto no-scrollbar">
|
<div role="group" aria-label={name} className="max-h-72 overflow-y-auto no-scrollbar">
|
||||||
{chips.map((f) => (
|
{chips.map((f) => (
|
||||||
@@ -93,7 +91,7 @@ function OverflowPopover({
|
|||||||
role="menuitem"
|
role="menuitem"
|
||||||
onSelect={f.remove}
|
onSelect={f.remove}
|
||||||
label={f.label}
|
label={f.label}
|
||||||
className="group flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-fg transition"
|
className="group flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm text-fg transition"
|
||||||
>
|
>
|
||||||
<span className="flex-1 truncate">{f.label}</span>
|
<span className="flex-1 truncate">{f.label}</span>
|
||||||
<X className="w-3.5 h-3.5 shrink-0 opacity-60 group-hover:opacity-100" />
|
<X className="w-3.5 h-3.5 shrink-0 opacity-60 group-hover:opacity-100" />
|
||||||
@@ -101,17 +99,17 @@ function OverflowPopover({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 border-t border-border px-1 pt-1.5">
|
<div className="mt-1 border-t border-border px-1 pt-1.5">
|
||||||
<button
|
<Button
|
||||||
type="button"
|
variant="link-muted"
|
||||||
data-testid="active-filter-clear-all"
|
data-testid="active-filter-clear-all"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onClearAll();
|
onClearAll();
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
}}
|
}}
|
||||||
className="text-xs text-muted hover:text-fg underline underline-offset-2 transition"
|
className="text-xs underline underline-offset-2"
|
||||||
>
|
>
|
||||||
{t("sidebar.clearAll")}
|
{t("sidebar.clearAll")}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</PopoverSurface>
|
</PopoverSurface>
|
||||||
</>
|
</>
|
||||||
@@ -191,13 +189,13 @@ export default function ActiveFilterChips({
|
|||||||
ref={(el) => {
|
ref={(el) => {
|
||||||
chipRefs.current[i] = el;
|
chipRefs.current[i] = el;
|
||||||
}}
|
}}
|
||||||
className={chipCls}
|
className={chipClass("token", "md") + " pr-1.5"}
|
||||||
>
|
>
|
||||||
<span className="max-w-56 truncate">{f.label}</span>
|
<span className="max-w-56 truncate">{f.label}</span>
|
||||||
<X className="w-3 h-3 shrink-0" />
|
<X className="size-3" />
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
<span ref={plusRef} className="chip inline-flex items-center gap-1 px-2.5 py-1 text-sm">
|
<span ref={plusRef} className={chipClass("token", "md")}>
|
||||||
+{n}
|
+{n}
|
||||||
</span>
|
</span>
|
||||||
<span ref={clearRef} className="ml-0.5 text-sm underline underline-offset-2">
|
<span ref={clearRef} className="ml-0.5 text-sm underline underline-offset-2">
|
||||||
@@ -230,13 +228,14 @@ export default function ActiveFilterChips({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{layout.mode === "all" && n > 1 && (
|
{layout.mode === "all" && n > 1 && (
|
||||||
<button
|
<Button
|
||||||
|
variant="link-muted"
|
||||||
data-testid="active-filter-clear-all"
|
data-testid="active-filter-clear-all"
|
||||||
onClick={onClearAll}
|
onClick={onClearAll}
|
||||||
className="ml-0.5 shrink-0 text-sm text-muted hover:text-fg underline underline-offset-2 transition"
|
className="ml-0.5 text-sm underline underline-offset-2"
|
||||||
>
|
>
|
||||||
{t("sidebar.clearAll")}
|
{t("sidebar.clearAll")}
|
||||||
</button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
import { useState } from "react";
|
import { useLayoutEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { qk } from "../lib/queryKeys";
|
import { qk } from "../lib/queryKeys";
|
||||||
import { Check, ListPlus, Plus, Youtube } from "lucide-react";
|
import { ListPlus, Plus, Youtube } from "lucide-react";
|
||||||
|
import { MenuCheck } from "./ui/MenuCheck";
|
||||||
import { api, type Playlist } from "../lib/api";
|
import { api, type Playlist } from "../lib/api";
|
||||||
import { notifyActionError } from "../lib/actionErrors";
|
import { notifyActionError } from "../lib/actionErrors";
|
||||||
import { useScrollFade } from "../lib/useScrollFade";
|
import { useScrollFade } from "../lib/useScrollFade";
|
||||||
import { playlistName } from "../lib/playlistName";
|
import { playlistName } from "../lib/playlistName";
|
||||||
import { PopoverItem, PopoverSurface } from "./ui/AnchoredPopover";
|
import { PopoverItem, PopoverSurface } from "./ui/AnchoredPopover";
|
||||||
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
|
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
|
||||||
|
import { IconButton } from "./ui/IconButton";
|
||||||
|
import { Input } from "./ui/Input";
|
||||||
|
import { SURFACE } from "./ui/surfaces";
|
||||||
|
|
||||||
// A small anchored menu for toggling a video's membership in the user's playlists, with inline
|
// A small anchored menu for toggling a video's membership in the user's playlists, with inline
|
||||||
// "new playlist" creation. Same shape as the channel tag editor (R8 S1's pilot): radio-style
|
// "new playlist" creation. Same shape as the channel tag editor (R8 S1's pilot): radio-style
|
||||||
@@ -16,26 +20,82 @@ import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
|
|||||||
// the input swallowing non-Escape keys, since character and arrow keys belong to the text field.
|
// the input swallowing non-Escape keys, since character and arrow keys belong to the text field.
|
||||||
export default function AddToPlaylist({
|
export default function AddToPlaylist({
|
||||||
videoId,
|
videoId,
|
||||||
className,
|
variant = "ghost",
|
||||||
|
size = "sm",
|
||||||
}: {
|
}: {
|
||||||
videoId: string;
|
videoId: string;
|
||||||
className?: string;
|
variant?: "ghost" | "outline" | "overlay" | "media";
|
||||||
|
size?: "sm" | "md";
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const qc = useQueryClient();
|
|
||||||
const fade = useScrollFade();
|
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [newName, setNewName] = useState("");
|
const pop = useMembershipPopover(open, setOpen);
|
||||||
const [busy, setBusy] = useState(false);
|
return (
|
||||||
|
<>
|
||||||
|
<IconButton
|
||||||
|
ref={pop.refs.setReference}
|
||||||
|
variant={variant}
|
||||||
|
size={size}
|
||||||
|
{...pop.getReferenceProps({
|
||||||
|
// The trigger sits on a feed card that opens the player on click, so the toggle must not
|
||||||
|
// reach it.
|
||||||
|
onClick: (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
label={t("playlists.addToPlaylist")}
|
||||||
|
>
|
||||||
|
<ListPlus />
|
||||||
|
</IconButton>
|
||||||
|
<MembershipPanel videoId={videoId} pop={pop} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const pop = useAnchoredPopover({
|
/** The same panel, opened from an object's menu ("Add to playlist…", E28 C1) rather than its own
|
||||||
|
* button: anchored to the element the menu came from, and gone when it closes. */
|
||||||
|
export function AddToPlaylistAt({
|
||||||
|
videoId,
|
||||||
|
anchor,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
videoId: string;
|
||||||
|
anchor: Element;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const pop = useMembershipPopover(true, (next) => {
|
||||||
|
if (!next) onClose();
|
||||||
|
});
|
||||||
|
const { setPositionReference } = pop.refs;
|
||||||
|
useLayoutEffect(() => setPositionReference(anchor), [anchor, setPositionReference]);
|
||||||
|
return <MembershipPanel videoId={videoId} pop={pop} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function useMembershipPopover(open: boolean, onOpenChange: (open: boolean) => void) {
|
||||||
|
return useAnchoredPopover({
|
||||||
open,
|
open,
|
||||||
onOpenChange: setOpen,
|
onOpenChange,
|
||||||
offset: 6,
|
offset: 6,
|
||||||
// The menu holds a "new playlist" text field — keep typed chars and pointer-hover out of the
|
// The menu holds a "new playlist" text field — keep typed chars and pointer-hover out of the
|
||||||
// roving list so they don't steal the caret.
|
// roving list so they don't steal the caret.
|
||||||
hostsInput: true,
|
hostsInput: true,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function MembershipPanel({
|
||||||
|
videoId,
|
||||||
|
pop,
|
||||||
|
}: {
|
||||||
|
videoId: string;
|
||||||
|
pop: ReturnType<typeof useAnchoredPopover>;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const fade = useScrollFade();
|
||||||
|
const open = pop.open;
|
||||||
|
const [newName, setNewName] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
const membership = useQuery({
|
const membership = useQuery({
|
||||||
queryKey: qk.playlistMembership(videoId),
|
queryKey: qk.playlistMembership(videoId),
|
||||||
@@ -90,28 +150,13 @@ export default function AddToPlaylist({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<button
|
<PopoverSurface popover={pop} className={`${SURFACE.popover} w-60 p-2`}>
|
||||||
ref={pop.refs.setReference}
|
|
||||||
{...pop.getReferenceProps({
|
|
||||||
// The trigger sits on a feed card that opens the player on click, so the toggle must not
|
|
||||||
// reach it.
|
|
||||||
onClick: (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
},
|
|
||||||
})}
|
|
||||||
title={t("playlists.addToPlaylist")}
|
|
||||||
aria-label={t("playlists.addToPlaylist")}
|
|
||||||
className={className ?? "p-1.5 rounded-md hover:bg-surface text-muted hover:text-fg"}
|
|
||||||
>
|
|
||||||
<ListPlus className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<PopoverSurface popover={pop} className="glass-menu w-60 rounded-xl p-2">
|
|
||||||
{/* A React portal still propagates events up the REACT tree, so a click inside this panel
|
{/* A React portal still propagates events up the REACT tree, so a click inside this panel
|
||||||
would otherwise reach the feed card that hosts the trigger and start playback. */}
|
would otherwise reach the feed card that hosts the trigger and start playback. */}
|
||||||
<div onClick={(e) => e.stopPropagation()}>
|
<div onClick={(e) => e.stopPropagation()}>
|
||||||
<div className="text-xs text-muted px-1.5 pb-1.5">{t("playlists.addToPlaylist")}</div>
|
<div className="text-xs text-muted px-1.5 pb-1.5">
|
||||||
|
{t("playlists.addToPlaylistTitle")}
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
ref={fade.ref}
|
ref={fade.ref}
|
||||||
style={fade.style}
|
style={fade.style}
|
||||||
@@ -135,13 +180,7 @@ export default function AddToPlaylist({
|
|||||||
label={playlistName(pl, t)}
|
label={playlistName(pl, t)}
|
||||||
className="w-full flex items-center gap-2 text-sm px-1.5 py-1.5 rounded-lg text-fg outline-hidden transition"
|
className="w-full flex items-center gap-2 text-sm px-1.5 py-1.5 rounded-lg text-fg outline-hidden transition"
|
||||||
>
|
>
|
||||||
<span
|
<MenuCheck kind="many" on={!!pl.has_video} />
|
||||||
className={`grid place-items-center w-4 h-4 rounded border ${
|
|
||||||
pl.has_video ? "bg-accent border-accent text-accent-fg" : "border-border"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{pl.has_video && <Check className="w-3 h-3" />}
|
|
||||||
</span>
|
|
||||||
<span className="truncate flex-1 text-left">{playlistName(pl, t)}</span>
|
<span className="truncate flex-1 text-left">{playlistName(pl, t)}</span>
|
||||||
{(pl.source === "youtube" || pl.yt_playlist_id) && (
|
{(pl.source === "youtube" || pl.yt_playlist_id) && (
|
||||||
<Youtube className="w-3 h-3 shrink-0 text-muted" />
|
<Youtube className="w-3 h-3 shrink-0 text-muted" />
|
||||||
@@ -151,7 +190,9 @@ export default function AddToPlaylist({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5 border-t border-border mt-1.5 pt-1.5">
|
<div className="flex items-center gap-1.5 border-t border-border mt-1.5 pt-1.5">
|
||||||
<Plus className="w-3.5 h-3.5 text-muted shrink-0" />
|
<Plus className="w-3.5 h-3.5 text-muted shrink-0" />
|
||||||
<input
|
<Input
|
||||||
|
className="flex-1"
|
||||||
|
size="sm"
|
||||||
value={newName}
|
value={newName}
|
||||||
onChange={(e) => setNewName(e.target.value)}
|
onChange={(e) => setNewName(e.target.value)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
@@ -165,7 +206,6 @@ export default function AddToPlaylist({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
placeholder={t("playlists.newPlaylist")}
|
placeholder={t("playlists.newPlaylist")}
|
||||||
className="flex-1 min-w-0 bg-surface border border-border rounded-md px-2 py-1 text-xs focus:outline-hidden focus:ring-2 focus:ring-accent/40"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||