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 | ||
|
|
3987cd4260 | ||
|
|
95d9ed4aa6 | ||
|
|
8d0063d514 | ||
|
|
0ca0cac534 | ||
|
|
cf0c55ba80 | ||
|
|
fa4f823fc8 | ||
|
|
884060891e | ||
|
|
955c8c3445 | ||
|
|
99df44d7fb | ||
|
|
30fbdc52b2 | ||
|
|
046ff735cf | ||
|
|
7afaba0547 | ||
|
|
db8ed5a004 | ||
|
|
0ef25a8037 | ||
|
|
17a26894b0 | ||
|
|
946c8f26db | ||
|
|
8759b5860d | ||
|
|
dc902d2c0e | ||
|
|
cedcbb1434 | ||
|
|
4f72b193aa | ||
|
|
7b94e9a651 | ||
|
|
c6bf84a578 | ||
|
|
444f9b8166 | ||
|
|
7a47223a58 | ||
|
|
bc138e3b37 | ||
|
|
df7c3db085 | ||
|
|
6fda49369f | ||
|
|
8018cd4685 | ||
|
|
29875b7367 | ||
|
|
7ae5c57b6b | ||
|
|
dc4e8b9fcd | ||
|
|
6f9ba428d5 | ||
|
|
c60359bfed | ||
|
|
e6b76e156a | ||
|
|
e74986cc9a | ||
|
|
2df45b27d3 | ||
|
|
bacd6aec4f | ||
|
|
fad301e7a4 | ||
|
|
1cfb19bdc7 | ||
|
|
90c30d596f | ||
|
|
7ed6ccf171 | ||
|
|
ce30b99327 | ||
|
|
a7891d03c5 | ||
|
|
038da5374f | ||
|
|
92979fa920 | ||
|
|
2d5bd275fa | ||
|
|
49fe56b6e7 | ||
|
|
1fa6a0bea8 | ||
|
|
582f20fab6 | ||
|
|
6cb8231a9f | ||
|
|
6bbf532b70 | ||
|
|
2fa5c8c394 | ||
|
|
373415a2eb | ||
|
|
582c033e1c | ||
|
|
ef652ee5d8 | ||
|
|
250e3a9f90 | ||
|
|
6096dd6927 | ||
|
|
51e98bfc38 | ||
|
|
3ee00fb051 | ||
|
|
29094f5f51 | ||
|
|
16a44dec79 | ||
|
|
4e3e68d6e6 | ||
|
|
eb29f22eb7 | ||
|
|
8f6f89832f | ||
|
|
1c49870a7a | ||
|
|
142593083f | ||
|
|
8cfab498cf | ||
|
|
b5996a0e56 | ||
|
|
5e208353e3 | ||
|
|
66dbd0af81 | ||
|
|
59fd068711 | ||
|
|
8e12eee996 | ||
|
|
89c77ae6b5 | ||
|
|
0b672ed6e7 | ||
|
|
54d19cc386 | ||
|
|
04b58e5c6f | ||
|
|
6b0401b922 | ||
|
|
0cff964ff2 | ||
|
|
dc221338be |
@@ -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
|
||||||
+855
-1273
File diff suppressed because it is too large
Load Diff
@@ -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}`) | ad-hoc `{"unsubscribed"}`/`{"cleared": n}`/echo |
|
| User-facing error | structured `detail` (`{code, reason}`) | a bare English string under a bilingual UI |
|
||||||
| Transaction commit | the handler/job-wrapper commits; helpers `flush` | helpers that commit (only `quota` owns its session) |
|
| Mutation response | `{"ok": true, …}` (`{"deleted": id}`), test-held | a bare `{"unsubscribed"}`/`{"cleared": n}`, no `ok` |
|
||||||
| Admin knob storage | `sysconfig` (values) | env / `app_state` / `scheduler_settings` scatter |
|
| Transaction commit | the handler commits; helpers `flush` (test-held list) | helpers that commit (only `quota` owns its session) |
|
||||||
| Job status word | the `downloads/status.py` StrEnum | loose `"canceled"`/`"cancelled"` literals |
|
| Admin knob storage | `sysconfig` (values) | env / `app_state` / `scheduler_settings` scatter |
|
||||||
| Current time | inline `datetime.now(timezone.utc)` | new `now_utc`/`_now` helpers |
|
| Job status word | the `downloads/status.py` StrEnum | loose `"canceled"`/`"cancelled"` literals |
|
||||||
| FE per-account state | `usePref` | raw `readAccount`/`writeAccount` or bespoke hooks |
|
| Current time | inline `datetime.now(timezone.utc)` | new `now_utc`/`_now` helpers |
|
||||||
| FE action button | `ui/Button` — pick a `SIZES` rung | a hand-rolled `<button className="px-3 py-2 …">` |
|
| FE per-account state | `usePref` | raw `readAccount`/`writeAccount` or bespoke hooks |
|
||||||
| FE keyboard shortcut | a row in `lib/keymap.ts` + `dispatch()` | a private `keydown` listener with its own key literals |
|
| FE button | `ui/Button` (a `SIZES` rung) · `IconButton` · `Pressable` (a clickable card/row) · `Chip` · `DragHandle` | a raw `<button>` or `FocusSafeButton` outside the control primitives (lint-enforced) |
|
||||||
| FE list/grid keys | `NavGrid`/`keyboardNav` + `navItemAttrs` + `data-nav-open` | a private arrow-key / roving-tabindex handler |
|
| FE keyboard shortcut | a row in `lib/keymap.ts` + `dispatch()` | a private `keydown` listener with its own key literals |
|
||||||
| FE view push (history) | `rememberNavPosition()` right before `history.pushState` | a push that forgets: Back strands focus on `<body>` |
|
| FE list/grid keys | `NavGrid`/`keyboardNav` + `navItemAttrs` + `data-nav-open` | a private arrow-key / roving-tabindex handler |
|
||||||
| FE control | the chip/segmented primitives (`Select` only for >6 options) | a native `<select>` |
|
| FE view push (history) | `rememberNavPosition()` right before `history.pushState` | a push that forgets: Back strands focus on `<body>` |
|
||||||
| FE loading/empty/error | `QueryState` / `StateMessage` | ad-hoc conditionals |
|
| FE text field / checkbox / slider | `ui/Input` · `Textarea` · `InnerInput` in a `fieldClass` frame · `Checkbox`/`CheckRow` · `Slider` | a raw `<input>`/`<textarea>` anywhere but those primitives (lint-enforced) |
|
||||||
| FE failed write | `meta.failTitle`, else `notifyActionError(err, title)` | a bare `catch {}` or `notify()` with no error attached |
|
| FE glass surface | a role: `SURFACE.panel` / `card` / `menu` / `popover` / `tooltip` (`ui/surfaces.ts`) | a bare `glass`/`glass-card`/`glass-menu` beside a hand-picked radius (lint-enforced) |
|
||||||
| FE 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)
|
||||||
|
|
||||||
|
|||||||
+3
-1
@@ -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 ./
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,30 @@
|
|||||||
|
"""add channels.feed_fallback_at — when a channel's uploads were last read through the API because
|
||||||
|
its RSS feed would not answer
|
||||||
|
|
||||||
|
Revision ID: 0088_channels_feed_fallback_at
|
||||||
|
Revises: 0087_users_diagnostics_level
|
||||||
|
Create Date: 2026-09-19
|
||||||
|
|
||||||
|
YouTube's RSS feeds go dark for hours at a time, per channel (2026-09-18/19: 393 of 396 answered 404
|
||||||
|
for four hours; the same channel answers 404 to every retry). The RSS poll then reads the newest
|
||||||
|
uploads through the Data API instead, at a quota cost, and at most once per `rss_fallback_hours` per
|
||||||
|
channel: this column is that clock. NULL = never needed it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0088_channels_feed_fallback_at"
|
||||||
|
down_revision: str | None = "0087_users_diagnostics_level"
|
||||||
|
branch_labels: str | None = None
|
||||||
|
depends_on: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"channels", sa.Column("feed_fallback_at", sa.DateTime(timezone=True), nullable=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("channels", "feed_fallback_at")
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
"""add debug_sessions + debug_screenshots, and index log_events.debug_session_id (E16)
|
||||||
|
|
||||||
|
Revision ID: 0089_debug_sessions
|
||||||
|
Revises: 0088_channels_feed_fallback_at
|
||||||
|
Create Date: 2026-09-19
|
||||||
|
|
||||||
|
A debug session is one recording: Start → reproduce → Stop (app/debugsession.py). Every log line
|
||||||
|
of that user's requests while it runs carries its id in `log_events.debug_session_id` — the column
|
||||||
|
E22 created and left NULL — and the issue it ends in is built from exactly those rows, so the column
|
||||||
|
gets its index now. Partial: nearly every row has no session, and an index of NULLs would be most
|
||||||
|
of the table for nothing.
|
||||||
|
|
||||||
|
At most one OPEN session per user, held by a partial unique index rather than by the start handler
|
||||||
|
alone: two tabs pressing Start at once would otherwise both pass a "none open" check.
|
||||||
|
|
||||||
|
Screenshots are rows, not files: bounded (size and count per session, app/debugsession.py), gone
|
||||||
|
with their session by cascade, and a self-host has no file volume to configure for them.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0089_debug_sessions"
|
||||||
|
down_revision: str | None = "0088_channels_feed_fallback_at"
|
||||||
|
branch_labels: str | None = None
|
||||||
|
depends_on: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"debug_sessions",
|
||||||
|
sa.Column("id", sa.String(16), primary_key=True),
|
||||||
|
sa.Column(
|
||||||
|
"user_id",
|
||||||
|
sa.Integer,
|
||||||
|
sa.ForeignKey("users.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
),
|
||||||
|
sa.Column("level", sa.String(8), nullable=False),
|
||||||
|
sa.Column("started_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("until", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("ended_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("ended_reason", sa.String(16), nullable=True),
|
||||||
|
sa.Column("issue_url", sa.String(512), nullable=True),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"uq_debug_sessions_open_per_user",
|
||||||
|
"debug_sessions",
|
||||||
|
["user_id"],
|
||||||
|
unique=True,
|
||||||
|
postgresql_where=sa.text("ended_at IS NULL"),
|
||||||
|
)
|
||||||
|
op.create_table(
|
||||||
|
"debug_screenshots",
|
||||||
|
sa.Column("id", sa.Integer, primary_key=True),
|
||||||
|
sa.Column(
|
||||||
|
"session_id",
|
||||||
|
sa.String(16),
|
||||||
|
sa.ForeignKey("debug_sessions.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("mime", sa.String(16), nullable=False),
|
||||||
|
sa.Column("size", sa.Integer, nullable=False),
|
||||||
|
sa.Column("data", sa.LargeBinary, nullable=False),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_log_events_debug_session",
|
||||||
|
"log_events",
|
||||||
|
["debug_session_id"],
|
||||||
|
postgresql_where=sa.text("debug_session_id IS NOT NULL"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_log_events_debug_session", table_name="log_events")
|
||||||
|
op.drop_table("debug_screenshots")
|
||||||
|
op.drop_index("uq_debug_sessions_open_per_user", table_name="debug_sessions")
|
||||||
|
op.drop_table("debug_sessions")
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"""add debug_sessions.draft — the issue being written from a session (E16 S3)
|
||||||
|
|
||||||
|
Revision ID: 0090_debug_session_draft
|
||||||
|
Revises: 0089_debug_sessions
|
||||||
|
Create Date: 2026-09-19
|
||||||
|
|
||||||
|
The composer saves as it goes: the title, the rendered markdown body that an export or an issue
|
||||||
|
carries, the log rows the user took out, and the form's own fields so reopening it later (Settings →
|
||||||
|
Diagnosztika lists past sessions) shows what was written. NULL until the composer first saves.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
|
|
||||||
|
revision: str = "0090_debug_session_draft"
|
||||||
|
down_revision: str | None = "0089_debug_sessions"
|
||||||
|
branch_labels: str | None = None
|
||||||
|
depends_on: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("debug_sessions", sa.Column("draft", JSONB, nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("debug_sessions", "draft")
|
||||||
@@ -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(
|
||||||
|
|||||||
+29
-14
@@ -1,12 +1,10 @@
|
|||||||
import logging
|
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import BackgroundTasks, Depends, HTTPException, Request
|
from fastapi import BackgroundTasks, Depends, HTTPException, Request
|
||||||
from sqlalchemy import delete, func, select
|
from sqlalchemy import delete, func, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from starlette.requests import HTTPConnection
|
from starlette.requests import HTTPConnection
|
||||||
|
|
||||||
from app import 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
|
||||||
@@ -16,6 +14,7 @@ from app.db import get_db
|
|||||||
from app.models import Invite, LogEvent, User
|
from app.models import Invite, LogEvent, User
|
||||||
from app.security import decrypt_optional
|
from app.security import decrypt_optional
|
||||||
from app.utils import canonical_email
|
from app.utils import canonical_email
|
||||||
|
from app.refusal import refused
|
||||||
|
|
||||||
PASSWORD_MIN_LEN = 10
|
PASSWORD_MIN_LEN = 10
|
||||||
|
|
||||||
@@ -39,7 +38,7 @@ READ_SCOPES = f"{BASE_SCOPES} {READ_SCOPE}"
|
|||||||
WRITE_SCOPES = f"{BASE_SCOPES} {READ_SCOPE} {WRITE_SCOPE}"
|
WRITE_SCOPES = f"{BASE_SCOPES} {READ_SCOPE} {WRITE_SCOPE}"
|
||||||
_YOUTUBE_SCOPES = frozenset({READ_SCOPE, WRITE_SCOPE})
|
_YOUTUBE_SCOPES = frozenset({READ_SCOPE, WRITE_SCOPE})
|
||||||
|
|
||||||
log = logging.getLogger("siftlode.auth")
|
log = logs.get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def google_enabled(db: Session) -> bool:
|
def google_enabled(db: Session) -> bool:
|
||||||
@@ -222,7 +221,7 @@ def revoke_google_token(token: str | None) -> None:
|
|||||||
timeout=10,
|
timeout=10,
|
||||||
)
|
)
|
||||||
log.info("Google token revoke: status=%s", resp.status_code)
|
log.info("Google token revoke: status=%s", resp.status_code)
|
||||||
except Exception:
|
except Exception: # noqa: BLE001 — a revoke we cannot make must not fail the sign-out
|
||||||
log.warning("Google token revoke failed", exc_info=True)
|
log.warning("Google token revoke failed", exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
@@ -305,6 +304,13 @@ def resolved_user_id(conn: HTTPConnection, *, strict: bool = True) -> tuple[int
|
|||||||
try:
|
try:
|
||||||
hid = int(hdr)
|
hid = int(hdr)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
|
# Under `strict` this becomes a 401 and the tab authenticates as nobody, which reads
|
||||||
|
# in the browser exactly like a session that expired. The value itself stays out —
|
||||||
|
# it came from a request, and `wallet` says which accounts were actually on offer.
|
||||||
|
log.debug(
|
||||||
|
"the active-account header could not be read as an id",
|
||||||
|
extra=logs.event("auth.account_header.invalid", accounts=len(wallet)),
|
||||||
|
)
|
||||||
hid = None
|
hid = None
|
||||||
if hid is not None and hid in wallet:
|
if hid is not None and hid in wallet:
|
||||||
return hid, hid == default_id
|
return hid, hid == default_id
|
||||||
@@ -336,19 +342,19 @@ def current_user(request: Request, db: Session = Depends(get_db)) -> User:
|
|||||||
|
|
||||||
def _session_user(request: Request, db: Session, user_id: int | None, is_default: bool) -> User:
|
def _session_user(request: Request, db: Session, user_id: int | None, is_default: bool) -> User:
|
||||||
if not user_id:
|
if not user_id:
|
||||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
raise refused(401, "not_authenticated")
|
||||||
user = db.get(User, user_id)
|
user = db.get(User, user_id)
|
||||||
if user is None or not user.is_active or user.is_suspended:
|
if user is None or not user.is_active or user.is_suspended:
|
||||||
# Suspended/deactivated mid-session → end it so the block takes effect at once.
|
# Suspended/deactivated mid-session → end it so the block takes effect at once.
|
||||||
_end_account_session(request, user_id, is_default)
|
_end_account_session(request, user_id, is_default)
|
||||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
raise refused(401, "not_authenticated")
|
||||||
# SA4: reject a cookie whose recorded epoch is behind the account's current one (revoked by a
|
# SA4: reject a cookie whose recorded epoch is behind the account's current one (revoked by a
|
||||||
# password reset/change or "log out everywhere"). A cookie with no recorded epoch (pre-SA4, or an
|
# password reset/change or "log out everywhere"). A cookie with no recorded epoch (pre-SA4, or an
|
||||||
# account signed in before this shipped) is treated as epoch 0 — so the FIRST bump revokes it too.
|
# account signed in before this shipped) is treated as epoch 0 — so the FIRST bump revokes it too.
|
||||||
epochs = request.session.get("epochs") or {}
|
epochs = request.session.get("epochs") or {}
|
||||||
if int(epochs.get(str(user_id), 0)) != (user.session_epoch or 0):
|
if int(epochs.get(str(user_id), 0)) != (user.session_epoch or 0):
|
||||||
_end_account_session(request, user_id, is_default)
|
_end_account_session(request, user_id, is_default)
|
||||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
raise refused(401, "not_authenticated")
|
||||||
# Always keep the session default in the switchable list — covers sessions created before
|
# Always keep the session default in the switchable list — covers sessions created before
|
||||||
# multi-session existed (their account_ids was never seeded), so the switcher isn't empty.
|
# multi-session existed (their account_ids was never seeded), so the switcher isn't empty.
|
||||||
default_id = request.session.get("user_id")
|
default_id = request.session.get("user_id")
|
||||||
@@ -361,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
|
||||||
|
|
||||||
@@ -376,12 +386,20 @@ def optional_current_user(request: Request, db: Session = Depends(get_db)) -> Us
|
|||||||
user_id, is_default = resolved_user_id(request, strict=False)
|
user_id, is_default = resolved_user_id(request, strict=False)
|
||||||
try:
|
try:
|
||||||
return _session_user(request, db, user_id, is_default)
|
return _session_user(request, db, user_id, is_default)
|
||||||
|
# silent-ok: this is the anonymous case as much as the refused one — every landing page hit
|
||||||
|
# comes through here. The second attempt below is the handling, and it reports.
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
if is_default:
|
if is_default:
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
return _session_user(request, db, *resolved_user_id(request, strict=False))
|
return _session_user(request, db, *resolved_user_id(request, strict=False))
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
|
# Both the pinned account AND the default one were refused, so the tab is signed out
|
||||||
|
# without being told why — and it got here from a session that named a real account.
|
||||||
|
log.info(
|
||||||
|
"a session was resolved to nobody: neither the named nor the default account held",
|
||||||
|
extra=logs.event("auth.session.probe_rejected", user_id=user_id),
|
||||||
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -405,10 +423,7 @@ def require_write_scope(user: User = Depends(require_human)) -> User:
|
|||||||
checks that had drifted into three different 403 messages across channels/playlists. Layers on
|
checks that had drifted into three different 403 messages across channels/playlists. Layers on
|
||||||
`require_human` because writing to YouTube needs a real, non-demo identity."""
|
`require_human` because writing to YouTube needs a real, non-demo identity."""
|
||||||
if not has_write_scope(user):
|
if not has_write_scope(user):
|
||||||
raise HTTPException(
|
raise refused(403, "youtube_editing_off")
|
||||||
status_code=403,
|
|
||||||
detail="Enable YouTube editing in Settings first.",
|
|
||||||
)
|
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
@@ -416,7 +431,7 @@ def admin_user(user: User = Depends(current_user)) -> User:
|
|||||||
"""Dependency: require the signed-in user to be an admin. Single source of truth for the
|
"""Dependency: require the signed-in user to be an admin. Single source of truth for the
|
||||||
admin gate — every admin route/action depends on this rather than re-checking the role."""
|
admin gate — every admin route/action depends on this rather than re-checking the role."""
|
||||||
if user.role != "admin":
|
if user.role != "admin":
|
||||||
raise HTTPException(status_code=403, detail="Admin only")
|
raise refused(403, "admin_only")
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
+22
-2
@@ -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.
|
||||||
@@ -16,6 +16,8 @@ def _version_from_file() -> str:
|
|||||||
v = p.read_text(encoding="utf-8").strip()
|
v = p.read_text(encoding="utf-8").strip()
|
||||||
if v:
|
if v:
|
||||||
return v
|
return v
|
||||||
|
# silent-ok: this runs at import, and `app.logs` imports THIS module — a log call here
|
||||||
|
# would be a circular import. The loop tries the other location, then falls back to "dev".
|
||||||
except OSError:
|
except OSError:
|
||||||
continue
|
continue
|
||||||
return "dev"
|
return "dev"
|
||||||
@@ -117,6 +119,9 @@ class Settings(BaseSettings):
|
|||||||
# Recent-first backfill: how far back to fetch on the first pass per channel.
|
# Recent-first backfill: how far back to fetch on the first pass per channel.
|
||||||
backfill_recent_max_videos: int = 100
|
backfill_recent_max_videos: int = 100
|
||||||
backfill_recent_max_days: int = 365
|
backfill_recent_max_days: int = 365
|
||||||
|
# A channel whose RSS feed has not answered for this many hours gets its newest uploads read
|
||||||
|
# through the Data API instead (1 unit), at most once per this many hours. 0 = never.
|
||||||
|
rss_fallback_hours: int = 2
|
||||||
|
|
||||||
# Shorts are confirmed by probing youtube.com/shorts/<id>. Only videos at or below
|
# Shorts are confirmed by probing youtube.com/shorts/<id>. Only videos at or below
|
||||||
# this duration (and not livestreams) are probed; longer videos are never Shorts.
|
# this duration (and not livestreams) are probed; longer videos are never Shorts.
|
||||||
@@ -242,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
|
||||||
@@ -310,6 +319,10 @@ class Settings(BaseSettings):
|
|||||||
# re-encode is CPU-heavy (the VAAPI hardware path is far cheaper and could sustain more); direct-serve
|
# re-encode is CPU-heavy (the VAAPI hardware path is far cheaper and could sustain more); direct-serve
|
||||||
# and remux (stream-copy) are not capped by this.
|
# and remux (stream-copy) are not capped by this.
|
||||||
plex_max_transcodes: int = 4
|
plex_max_transcodes: int = 4
|
||||||
|
# Max concurrent playback sessions of ANY kind — remux (stream-copy) included. Cheap on the CPU but
|
||||||
|
# each one is a whole segmented copy of the film on the scratch volume, written faster than
|
||||||
|
# realtime. One account holds at most two regardless (plex/stream.py `_MAX_SESSIONS_PER_USER`).
|
||||||
|
plex_max_sessions: int = 8
|
||||||
# --- Transcode (playable="transcode") re-encode tuning (E6 S1) ---
|
# --- Transcode (playable="transcode") re-encode tuning (E6 S1) ---
|
||||||
# Hardware-encoder policy: "auto" = VAAPI (h264_vaapi) when the iGPU render node is present AND the
|
# Hardware-encoder policy: "auto" = VAAPI (h264_vaapi) when the iGPU render node is present AND the
|
||||||
# source codec is hardware-decodable, else libx264 software; "on" = force VAAPI wherever the render
|
# source codec is hardware-decodable, else libx264 software; "on" = force VAAPI wherever the render
|
||||||
@@ -342,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).
|
||||||
|
|||||||
+55
-2
@@ -1,6 +1,6 @@
|
|||||||
from collections.abc import Iterator
|
from collections.abc import Iterator
|
||||||
|
|
||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine, text
|
||||||
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
|
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
@@ -12,7 +12,9 @@ class Base(DeclarativeBase):
|
|||||||
|
|
||||||
# Pool sized above the sync-endpoint threadpool (~40) so a burst of short DB sessions can't starve on
|
# Pool sized above the sync-endpoint threadpool (~40) so a burst of short DB sessions can't starve on
|
||||||
# checkout. The image proxies deliberately DON'T hold a connection during their slow external fetch
|
# checkout. The image proxies deliberately DON'T hold a connection during their slow external fetch
|
||||||
# (see routes/plex/), so this headroom is only for the many brief sessions, not long ones.
|
# (see routes/plex/), so this headroom is only for the many brief sessions, not long ones. The one
|
||||||
|
# long hold — a playlist sync's advisory claim, for a whole push — opens its own unpooled connection
|
||||||
|
# (sync/playlists.py `_claim_engine`) instead of drawing a third one from here.
|
||||||
engine = create_engine(
|
engine = create_engine(
|
||||||
settings.database_url,
|
settings.database_url,
|
||||||
pool_pre_ping=True,
|
pool_pre_ping=True,
|
||||||
@@ -20,6 +22,10 @@ engine = create_engine(
|
|||||||
max_overflow=30,
|
max_overflow=30,
|
||||||
pool_timeout=30,
|
pool_timeout=30,
|
||||||
future=True,
|
future=True,
|
||||||
|
# A database error renders its bound parameters into its message — a title, an uploader, a
|
||||||
|
# file path, a URL — and that message is logged and stored as a job's error text. The SQL
|
||||||
|
# stays; the values are data, and the ids an operator needs are on the line already.
|
||||||
|
hide_parameters=True,
|
||||||
)
|
)
|
||||||
SessionLocal = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)
|
SessionLocal = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)
|
||||||
|
|
||||||
@@ -30,3 +36,50 @@ def get_db() -> Iterator[Session]:
|
|||||||
yield db
|
yield db
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def release_read_connection(db: Session) -> None:
|
||||||
|
"""End a request's READ transaction so its pooled connection goes back BEFORE a long await.
|
||||||
|
|
||||||
|
SQLAlchemy 2.0 autobegins on the first SELECT, so a request that has merely authenticated is
|
||||||
|
already holding one of the connections above — `idle in transaction` to Postgres, and nothing
|
||||||
|
times it out. A route that then awaits something slow (a request body still arriving, an
|
||||||
|
external fetch) pins it for the whole wait, which is precisely the invariant the pool is sized
|
||||||
|
on in the comment above. Measured: the next checkout after this call reuses the very backend
|
||||||
|
that was released, and a 1.8 s upload goes from one sustained `idle in transaction` to none.
|
||||||
|
|
||||||
|
Every route that reads its own body from a dependency needs this, and `test_body_limit.py`
|
||||||
|
holds that — the three that do are the ones with a raised (or self-imposed) body ceiling, and
|
||||||
|
they got here one at a time, which is how the upload route ended up the only one fixed.
|
||||||
|
|
||||||
|
The caller must have NOTHING PENDING, and that is checked rather than described. The session is
|
||||||
|
shared with every dependency that ran before, so a rollback here would silently discard a write
|
||||||
|
one of them staged — `service.enqueue` already learned that lesson the expensive way and uses a
|
||||||
|
SAVEPOINT for it. CLAUDE.md: a rule stated in a comment must also be enforced in code.
|
||||||
|
|
||||||
|
TWO checks, because the obvious one is blind to the shapes this codebase actually writes in.
|
||||||
|
`db.new`/`deleted`/`dirty` hold only UNFLUSHED unit-of-work: the moment a helper calls
|
||||||
|
`db.flush()` — CLAUDE.md's canonical split is "the handler commits; helpers flush" — the object
|
||||||
|
is persistent with no unflushed history and all three sets are empty again, and Core DML
|
||||||
|
(`db.execute(update(...))`) never enters them at all. Measured: after `add`+`flush` the sets are
|
||||||
|
empty, the old check passed, and the rollback lost the row. So the transaction is asked
|
||||||
|
directly. Postgres assigns a transaction id on the first WRITE and never for a read, so
|
||||||
|
`pg_current_xact_id_if_assigned()` is NULL for exactly the state this function is safe in — and
|
||||||
|
it sees a raw `text("UPDATE …")` too, which no session-level bookkeeping would.
|
||||||
|
"""
|
||||||
|
if db.new or db.deleted or db.dirty:
|
||||||
|
raise RuntimeError(
|
||||||
|
"release_read_connection() with pending work: something ahead of this dependency "
|
||||||
|
"staged a write, which ending the transaction would discard. Commit it first, or do "
|
||||||
|
"it after the await."
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
db.in_transaction()
|
||||||
|
and db.execute(text("SELECT pg_current_xact_id_if_assigned()")).scalar() is not None
|
||||||
|
):
|
||||||
|
raise RuntimeError(
|
||||||
|
"release_read_connection() on a transaction that has already WRITTEN: something ahead "
|
||||||
|
"of this dependency flushed a row or ran an UPDATE/INSERT/DELETE, which ending the "
|
||||||
|
"transaction would discard. Commit it first, or do it after the await."
|
||||||
|
)
|
||||||
|
db.rollback()
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -81,7 +81,11 @@ def pick(
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
def _comfort() -> dict | None:
|
def _comfort() -> dict | None:
|
||||||
for card in pins.playables(pins.shelf(db, user) if shelf is None else shelf):
|
if shelf is None:
|
||||||
|
own = pins.shelf(db, user, include_plex=rails.plex_available(db))
|
||||||
|
else:
|
||||||
|
own = shelf
|
||||||
|
for card in pins.playables(own):
|
||||||
if cards.key(card) not in skip and fits(card["remaining_seconds"], budget):
|
if cards.key(card) not in skip and fits(card["remaining_seconds"], budget):
|
||||||
return card
|
return card
|
||||||
return None
|
return None
|
||||||
|
|||||||
+109
-15
@@ -8,14 +8,16 @@ second one.
|
|||||||
|
|
||||||
Every shelf card carries a resolved `next`: the thing that actually plays when you click the tile —
|
Every shelf card carries a resolved `next`: the thing that actually plays when you click the tile —
|
||||||
a show's in-progress-or-next episode, a channel's newest unwatched video, a playlist's first
|
a show's in-progress-or-next episode, a channel's newest unwatched video, a playlist's first
|
||||||
unwatched item. It costs a query per non-leaf pin, which is affordable precisely because this shelf is
|
unwatched item. That buys the property the whole surface exists for: one click, no intermediate page,
|
||||||
short and hand-curated, and it buys the property the whole surface exists for: one click, no
|
no second decision. Channels and playlists are resolved for the whole shelf at once (`_Batch`), so the
|
||||||
intermediate page, no second decision.
|
cost does not grow a query or two per pin.
|
||||||
|
|
||||||
Domain module, so nothing here raises HTTP. `resolve_ref` returns None for "no such thing, or not
|
Domain module, so nothing here raises HTTP. `resolve_ref` returns None for "no such thing, or not
|
||||||
yours" and the route turns that into its 404 — core never imports routes.
|
yours" and the route turns that into its 404 — core never imports routes.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
@@ -62,6 +64,8 @@ def resolve_ref(db: Session, user: User, kind: str, ref: str) -> tuple[str, obje
|
|||||||
return (col, sh.id) if sh is not None else None
|
return (col, sh.id) if sh is not None else None
|
||||||
try:
|
try:
|
||||||
pid = int(ref)
|
pid = int(ref)
|
||||||
|
# silent-ok: validating a reference the client supplied. "Not an integer" is answered as "no
|
||||||
|
# such pin target", which is the same answer this function gives for one that does not exist.
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return None
|
return None
|
||||||
pl = db.get(Playlist, pid)
|
pl = db.get(Playlist, pid)
|
||||||
@@ -71,6 +75,44 @@ def resolve_ref(db: Session, user: User, kind: str, ref: str) -> tuple[str, obje
|
|||||||
return (col, pl.id) if pl is not None and pl.user_id == user.id else None
|
return (col, pl.id) if pl is not None and pl.user_id == user.id else None
|
||||||
|
|
||||||
|
|
||||||
|
def reorder(db: Session, user: User, ids: list[int]) -> None:
|
||||||
|
"""Put the listed pins in the listed order, WITHIN the slots they already hold.
|
||||||
|
|
||||||
|
The client sends the order it can SEE, which is not always the whole shelf: with Plex off the Home
|
||||||
|
rail hides the Plex pins, and a stale tab may not know a pin added elsewhere. Numbering only the
|
||||||
|
listed ids 0..n-1 collided with every pin left out — two tiles on one position, and the hidden
|
||||||
|
ones jumped about when they came back. So the pins left out keep their slots, and the listed ones
|
||||||
|
are dealt into the slots the listed set occupies. Ids that are not this user's, or repeated, are
|
||||||
|
ignored rather than failing a stale replay wholesale. Flushes; the route commits."""
|
||||||
|
# Locked: the rows it leaves alone are part of the answer too. Two overlapping reorders each
|
||||||
|
# skipped the rows that already matched in their own snapshot and interleaved into two pins on
|
||||||
|
# one slot; the second now waits and deals from the first one's result. Locked in id order and
|
||||||
|
# sorted HERE: with `ORDER BY position … FOR UPDATE` Postgres sorts before it locks, so the one
|
||||||
|
# that waited got the fresh rows in its stale order and dealt the pins it left out back to their
|
||||||
|
# old slots — and one global lock order means two reorders cannot deadlock.
|
||||||
|
locked = db.execute(
|
||||||
|
select(ComfortPin)
|
||||||
|
.where(ComfortPin.user_id == user.id)
|
||||||
|
.order_by(ComfortPin.id)
|
||||||
|
.with_for_update()
|
||||||
|
.execution_options(populate_existing=True)
|
||||||
|
).scalars()
|
||||||
|
current = sorted(locked, key=lambda p: (p.position, p.id))
|
||||||
|
owned = {p.id for p in current}
|
||||||
|
listed: list[int] = []
|
||||||
|
for i in ids:
|
||||||
|
if i in owned and i not in listed:
|
||||||
|
listed.append(i)
|
||||||
|
chosen = set(listed)
|
||||||
|
queue = iter(listed)
|
||||||
|
order = [next(queue) if p.id in chosen else p.id for p in current]
|
||||||
|
by_id = {p.id: p for p in current}
|
||||||
|
for position, pin_id in enumerate(order):
|
||||||
|
if by_id[pin_id].position != position:
|
||||||
|
by_id[pin_id].position = position
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
|
||||||
def list_pins(db: Session, user: User) -> list[ComfortPin]:
|
def list_pins(db: Session, user: User) -> list[ComfortPin]:
|
||||||
return list(
|
return list(
|
||||||
db.execute(
|
db.execute(
|
||||||
@@ -101,7 +143,34 @@ def next_position(db: Session, user: User) -> int:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _shelf_card(db: Session, user: User, pin: ComfortPin) -> dict | None:
|
@dataclass
|
||||||
|
class _Batch:
|
||||||
|
"""What the shelf resolves for every pinned channel and playlist in one go."""
|
||||||
|
|
||||||
|
channels: dict[str, Channel] = field(default_factory=dict)
|
||||||
|
channel_summary: dict[str, tuple[int, dict | None]] = field(default_factory=dict)
|
||||||
|
playlists: dict[int, Playlist] = field(default_factory=dict)
|
||||||
|
playlist_summary: dict[int, tuple[int, dict | None]] = field(default_factory=dict)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def load(cls, db: Session, user: User, pins: list[ComfortPin]) -> "_Batch":
|
||||||
|
ch_ids = [p.channel_id for p in pins if p.channel_id is not None]
|
||||||
|
pl_ids = [p.playlist_id for p in pins if p.playlist_id is not None]
|
||||||
|
b = cls()
|
||||||
|
if ch_ids:
|
||||||
|
b.channels = {
|
||||||
|
c.id: c for c in db.scalars(select(Channel).where(Channel.id.in_(ch_ids)))
|
||||||
|
}
|
||||||
|
b.channel_summary = queries.channels_summary(db, user, ch_ids)
|
||||||
|
if pl_ids:
|
||||||
|
b.playlists = {
|
||||||
|
p.id: p for p in db.scalars(select(Playlist).where(Playlist.id.in_(pl_ids)))
|
||||||
|
}
|
||||||
|
b.playlist_summary = queries.playlists_summary(db, user, pl_ids)
|
||||||
|
return b
|
||||||
|
|
||||||
|
|
||||||
|
def _shelf_card(db: Session, user: User, pin: ComfortPin, batch: _Batch) -> dict | None:
|
||||||
"""One pin as a unified card with its resolved `next`. None when the pinned row has gone (the FK
|
"""One pin as a unified card with its resolved `next`. None when the pinned row has gone (the FK
|
||||||
cascade removes the pin itself, so this is only the window between a delete and a refetch)."""
|
cascade removes the pin itself, so this is only the window between a delete and a refetch)."""
|
||||||
card: dict | None = None
|
card: dict | None = None
|
||||||
@@ -115,11 +184,11 @@ def _shelf_card(db: Session, user: User, pin: ComfortPin) -> dict | None:
|
|||||||
nxt = dict(card) # a video plays itself
|
nxt = dict(card) # a video plays itself
|
||||||
|
|
||||||
elif pin.channel_id is not None:
|
elif pin.channel_id is not None:
|
||||||
ch = db.get(Channel, pin.channel_id)
|
ch = batch.channels.get(pin.channel_id)
|
||||||
if ch is None:
|
if ch is None:
|
||||||
return None
|
return None
|
||||||
card = cards.from_channel(ch, queries.channel_unwatched_count(db, user, ch.id))
|
count, row = batch.channel_summary.get(ch.id, (0, None))
|
||||||
row = queries.channel_next_video(db, user, ch.id)
|
card = cards.from_channel(ch, count)
|
||||||
nxt = cards.from_video(row) if row else None
|
nxt = cards.from_video(row) if row else None
|
||||||
|
|
||||||
elif pin.plex_item_id is not None:
|
elif pin.plex_item_id is not None:
|
||||||
@@ -140,10 +209,10 @@ def _shelf_card(db: Session, user: User, pin: ComfortPin) -> dict | None:
|
|||||||
nxt = cards.from_plex(ep) if ep else None
|
nxt = cards.from_plex(ep) if ep else None
|
||||||
|
|
||||||
elif pin.playlist_id is not None:
|
elif pin.playlist_id is not None:
|
||||||
pl = db.get(Playlist, pin.playlist_id)
|
pl = batch.playlists.get(pin.playlist_id)
|
||||||
if pl is None:
|
if pl is None:
|
||||||
return None
|
return None
|
||||||
count, row = queries.playlist_summary(db, user, pl.id)
|
count, row = batch.playlist_summary.get(pl.id, (0, None))
|
||||||
card = cards.from_playlist(pl, count, row.get("thumbnail_url") if row else None)
|
card = cards.from_playlist(pl, count, row.get("thumbnail_url") if row else None)
|
||||||
nxt = cards.from_video(row) if row else None
|
nxt = cards.from_video(row) if row else None
|
||||||
|
|
||||||
@@ -157,11 +226,24 @@ def _shelf_card(db: Session, user: User, pin: ComfortPin) -> dict | None:
|
|||||||
return card
|
return card
|
||||||
|
|
||||||
|
|
||||||
def shelf(db: Session, user: User) -> list[dict]:
|
_PLEX_COLUMNS = ("plex_item_id", "plex_show_id")
|
||||||
"""The whole shelf, in the user's own order."""
|
|
||||||
|
|
||||||
|
def shelf(db: Session, user: User, *, include_plex: bool = True) -> list[dict]:
|
||||||
|
"""The whole shelf, in the user's own order.
|
||||||
|
|
||||||
|
`include_plex=False` is the Home surface with the deployment's Plex toggle off (the owner's call,
|
||||||
|
2026-09-19): the rails beside it go YouTube-only, and a Plex tile there would offer a Play that
|
||||||
|
lands on a gated module. The pins themselves are kept — the pin menus still read the full shelf
|
||||||
|
(`GET /pins`), and switching Plex back on brings the tiles back in their old slots
|
||||||
|
(`reorder` keeps a hidden pin's place)."""
|
||||||
out = []
|
out = []
|
||||||
for pin in list_pins(db, user):
|
pins = list_pins(db, user)
|
||||||
card = _shelf_card(db, user, pin)
|
if not include_plex:
|
||||||
|
pins = [p for p in pins if all(getattr(p, c) is None for c in _PLEX_COLUMNS)]
|
||||||
|
batch = _Batch.load(db, user, pins)
|
||||||
|
for pin in pins:
|
||||||
|
card = _shelf_card(db, user, pin, batch)
|
||||||
if card is not None:
|
if card is not None:
|
||||||
out.append(card)
|
out.append(card)
|
||||||
return out
|
return out
|
||||||
@@ -169,5 +251,17 @@ def shelf(db: Session, user: User) -> list[dict]:
|
|||||||
|
|
||||||
def playables(shelf_cards: list[dict]) -> list[dict]:
|
def playables(shelf_cards: list[dict]) -> list[dict]:
|
||||||
"""The `next` of every shelf entry that has one — what the hero may draw from. A pinned channel
|
"""The `next` of every shelf entry that has one — what the hero may draw from. A pinned channel
|
||||||
with nothing unwatched left, or an empty playlist, simply is not an answer right now."""
|
with nothing unwatched left, or an empty playlist, simply is not an answer right now.
|
||||||
return [c["next"] for c in shelf_cards if c.get("next")]
|
|
||||||
|
Each carries `pinned_from`: the pin it was resolved from. A show's next EPISODE is not itself
|
||||||
|
pinned, so without it the hero's menu looked the episode up, missed, and offered "Pin" under a
|
||||||
|
kicker that says "From your shelf" — and taking it inserted a second pin. With it, the hero's
|
||||||
|
pin row acts on the shelf entry the card came from (the owner's call, 2026-09-19: "Unpin")."""
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
**c["next"],
|
||||||
|
"pinned_from": {k: c[k] for k in ("source", "kind", "id", "title")},
|
||||||
|
}
|
||||||
|
for c in shelf_cards
|
||||||
|
if c.get("next")
|
||||||
|
]
|
||||||
|
|||||||
@@ -43,8 +43,9 @@ from app.plex import rollup
|
|||||||
from app.plex.cards import leaf_card, movie_card, show_card
|
from app.plex.cards import leaf_card, movie_card, show_card
|
||||||
from app.plex.query import apply_show_state_filter, content_libs
|
from app.plex.query import apply_show_state_filter, content_libs
|
||||||
|
|
||||||
# How many rows a single rail may carry. Matches `routes/plex/discover.py`'s RAIL_LIMIT on purpose —
|
# How many rows a single rail may carry — Home's rails and the Plex module's Discover rails alike
|
||||||
# the two surfaces should feel like the same app, and a rail is a rail.
|
# (`routes/plex/discover.py` imports it): the two surfaces should feel like the same app, and a rail
|
||||||
|
# is a rail. One definition, because a comment was all that held two copies in step.
|
||||||
RAIL_LIMIT = 20
|
RAIL_LIMIT = 20
|
||||||
|
|
||||||
# The feed parameters a Home rail runs with. Spelled out rather than borrowed from the route's
|
# The feed parameters a Home rail runs with. Spelled out rather than borrowed from the route's
|
||||||
@@ -129,18 +130,6 @@ def yt_surprise(db: Session, user: User, budget: str, seed: int, limit: int) ->
|
|||||||
return [r for r in out if not r["position_seconds"]][:limit]
|
return [r for r in out if not r["position_seconds"]][:limit]
|
||||||
|
|
||||||
|
|
||||||
def channel_next_video(db: Session, user: User, channel_id: str) -> dict | None:
|
|
||||||
"""The newest unwatched video of a pinned channel — what "put on that channel" resolves to."""
|
|
||||||
# `library_source="all"`: a pinned channel means the whole channel, including videos that only
|
|
||||||
# ever arrived through a search. The feed's "organic" default is a feed-scoping choice, not a
|
|
||||||
# statement about what belongs to a channel.
|
|
||||||
params = dict(_FEED_BASE, channel_ids=[channel_id], scope="all", library_source="all")
|
|
||||||
query, rank_expr = filtered_query(db, user, **params)
|
|
||||||
keys = sort_keys("newest", 0, rank_expr)
|
|
||||||
row = db.execute(query.order_by(*order_by(keys)).limit(1)).first()
|
|
||||||
return serialize(row) if row is not None else None
|
|
||||||
|
|
||||||
|
|
||||||
def yt_video_row(db: Session, user: User, video_id: str) -> dict | None:
|
def yt_video_row(db: Session, user: User, video_id: str) -> dict | None:
|
||||||
"""One video as a feed row, with this user's watch state.
|
"""One video as a feed row, with this user's watch state.
|
||||||
|
|
||||||
@@ -158,31 +147,55 @@ def yt_video_row(db: Session, user: User, video_id: str) -> dict | None:
|
|||||||
return serialize(row) if row is not None else None
|
return serialize(row) if row is not None else None
|
||||||
|
|
||||||
|
|
||||||
def channel_unwatched_count(db: Session, user: User, channel_id: str) -> int:
|
def channels_summary(
|
||||||
"""How many unwatched videos a pinned channel is holding — the tile's only honest promise, since a
|
db: Session, user: User, channel_ids: list[str]
|
||||||
channel has no runtime of its own."""
|
) -> dict[str, tuple[int, dict | None]]:
|
||||||
params = dict(_FEED_BASE, channel_ids=[channel_id], scope="all", library_source="all")
|
"""For each pinned channel: (unwatched count, its newest unwatched video) — the tile's only honest
|
||||||
query, _ = filtered_query(db, user, **params)
|
promise, since a channel has no runtime of its own, and what "put on that channel" resolves to.
|
||||||
return int(db.execute(select(func.count()).select_from(query.subquery())).scalar() or 0)
|
|
||||||
|
ONE query for the whole shelf, not two per channel: the count and the first row per channel are
|
||||||
|
window functions over the same filtered set. `library_source="all"`: a pinned channel means the
|
||||||
|
whole channel, including videos that only ever arrived through a search — the feed's "organic"
|
||||||
|
default is a feed-scoping choice, not a statement about what belongs to a channel."""
|
||||||
|
if not channel_ids:
|
||||||
|
return {}
|
||||||
|
params = dict(_FEED_BASE, channel_ids=list(channel_ids), scope="all", library_source="all")
|
||||||
|
query, rank_expr = filtered_query(db, user, **params)
|
||||||
|
newest = order_by(sort_keys("newest", 0, rank_expr))
|
||||||
|
per_channel = {"partition_by": Video.channel_id}
|
||||||
|
sub = query.add_columns(
|
||||||
|
func.row_number().over(**per_channel, order_by=newest).label("_rn"),
|
||||||
|
func.count().over(**per_channel).label("_total"),
|
||||||
|
).subquery()
|
||||||
|
rows = db.execute(select(sub).where(sub.c._rn == 1)).all()
|
||||||
|
return {r.channel_id: (int(r._total), serialize(r)) for r in rows}
|
||||||
|
|
||||||
|
|
||||||
def playlist_summary(db: Session, user: User, playlist_id: int) -> tuple[int, dict | None]:
|
def playlists_summary(
|
||||||
"""(item count, the row to play next) for a pinned playlist — its first not-yet-watched item, or
|
db: Session, user: User, playlist_ids: list[int]
|
||||||
its first item once the whole list is watched. The same row doubles as the tile's artwork, so a
|
) -> dict[int, tuple[int, dict | None]]:
|
||||||
shelf of playlists is scannable instead of a row of blank boxes."""
|
"""For each pinned playlist: (item count, the row to play next) — its first not-yet-watched item,
|
||||||
|
or its first item once the whole list is watched. The same row doubles as the tile's artwork, so
|
||||||
|
a shelf of playlists is scannable instead of a row of blank boxes. One query for the whole shelf."""
|
||||||
|
if not playlist_ids:
|
||||||
|
return {}
|
||||||
st = aliased(VideoState)
|
st = aliased(VideoState)
|
||||||
rows = db.execute(
|
rows = db.execute(
|
||||||
select(*feed_columns(user, st), PlaylistItem.position)
|
select(*feed_columns(user, st), PlaylistItem.playlist_id.label("_playlist"))
|
||||||
.join(PlaylistItem, PlaylistItem.video_id == Video.id)
|
.join(PlaylistItem, PlaylistItem.video_id == Video.id)
|
||||||
.join(Channel, Channel.id == Video.channel_id)
|
.join(Channel, Channel.id == Video.channel_id)
|
||||||
.outerjoin(st, and_(st.video_id == Video.id, st.user_id == user.id))
|
.outerjoin(st, and_(st.video_id == Video.id, st.user_id == user.id))
|
||||||
.where(PlaylistItem.playlist_id == playlist_id)
|
.where(PlaylistItem.playlist_id.in_(playlist_ids))
|
||||||
.order_by(PlaylistItem.position.asc())
|
.order_by(PlaylistItem.playlist_id, PlaylistItem.position.asc())
|
||||||
).all()
|
).all()
|
||||||
if not rows:
|
grouped: dict[int, list] = {}
|
||||||
return 0, None
|
for r in rows:
|
||||||
nxt = next((r for r in rows if (r.status or "new") not in ("watched", "hidden")), rows[0])
|
grouped.setdefault(r._playlist, []).append(r)
|
||||||
return len(rows), serialize(nxt)
|
out: dict[int, tuple[int, dict | None]] = {}
|
||||||
|
for pid, items in grouped.items():
|
||||||
|
nxt = next((r for r in items if (r.status or "new") not in ("watched", "hidden")), items[0])
|
||||||
|
out[pid] = (len(items), serialize(nxt))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
# ── Plex ──────────────────────────────────────────────────────────────────────────────────────────
|
# ── Plex ──────────────────────────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import yt_dlp
|
|||||||
from yt_dlp.networking.impersonate import ImpersonateTarget
|
from yt_dlp.networking.impersonate import ImpersonateTarget
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
from app.downloads.stated import StatedFailure
|
||||||
|
|
||||||
# Facebook's "Cannot parse data": the page yt-dlp got was a wall, not the content. Both the Chrome retry
|
# Facebook's "Cannot parse data": the page yt-dlp got was a wall, not the content. Both the Chrome retry
|
||||||
# below and the failure catalog (errors.py) decide on it, so the wording exists once.
|
# below and the failure catalog (errors.py) decide on it, so the wording exists once.
|
||||||
@@ -178,11 +179,11 @@ class FacebookAdapter(SourceAdapter):
|
|||||||
# Measured (E11.5 review): a refused or expired cookie gets the login page (~450 KB, a login
|
# Measured (E11.5 review): a refused or expired cookie gets the login page (~450 KB, a login
|
||||||
# form, no post id at all), which is not a post without a video — the user's cookie is the cause.
|
# form, no post id at all), which is not a post without a video — the user's cookie is the cause.
|
||||||
if _quoted(post_id) not in page:
|
if _quoted(post_id) not in page:
|
||||||
raise RuntimeError(POST_NOT_SHOWN_MESSAGE)
|
raise StatedFailure(POST_NOT_SHOWN_MESSAGE)
|
||||||
video_id = facebook_post_video_id(page, post_id)
|
video_id = facebook_post_video_id(page, post_id)
|
||||||
if video_id is None:
|
if video_id is None:
|
||||||
# Never fall back to the post link: that is exactly where the wrong video comes from.
|
# Never fall back to the post link: that is exactly where the wrong video comes from.
|
||||||
raise RuntimeError(POST_WITHOUT_VIDEO_MESSAGE)
|
raise StatedFailure(POST_WITHOUT_VIDEO_MESSAGE)
|
||||||
return f"https://www.facebook.com/watch/?v={video_id}"
|
return f"https://www.facebook.com/watch/?v={video_id}"
|
||||||
|
|
||||||
def client_sets(self, authed: bool = False) -> list:
|
def client_sets(self, authed: bool = False) -> list:
|
||||||
|
|||||||
@@ -11,11 +11,12 @@ Run once per environment:
|
|||||||
docker exec siftlode-worker-1 python -c "from app.downloads import backfill; print(backfill.run())"
|
docker exec siftlode-worker-1 python -c "from app.downloads import backfill; print(backfill.run())"
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
import yt_dlp
|
import yt_dlp
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app import logs
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.db import SessionLocal
|
from app.db import SessionLocal
|
||||||
from app.downloads import adapters
|
from app.downloads import adapters
|
||||||
@@ -23,12 +24,17 @@ from app.downloads import edit as editmod
|
|||||||
from app.downloads import formats, service
|
from app.downloads import formats, service
|
||||||
from app.models import MediaAsset
|
from app.models import MediaAsset
|
||||||
|
|
||||||
log = logging.getLogger("siftlode.backfill")
|
log = logs.get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def _extract_meta(url: str, adapter: adapters.SourceAdapter) -> dict | None:
|
def _extract_meta(url: str, adapter: adapters.SourceAdapter, asset_id: int) -> dict | None:
|
||||||
"""Metadata-only yt-dlp extraction, using the source adapter's client sets + options (YouTube
|
"""Metadata-only yt-dlp extraction, using the source adapter's client sets + options (YouTube
|
||||||
needs the POT/player-client strategy even just to extract)."""
|
needs the POT/player-client strategy even just to extract)."""
|
||||||
|
with formats.hiding(url):
|
||||||
|
return _extract_meta_scoped(url, adapter, asset_id)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_meta_scoped(url: str, adapter: adapters.SourceAdapter, asset_id: int) -> dict | None:
|
||||||
for clients in adapter.client_sets():
|
for clients in adapter.client_sets():
|
||||||
opts = formats.build_ydl_opts(
|
opts = formats.build_ydl_opts(
|
||||||
{}, "%(id)s.%(ext)s", lambda d: None, extra=adapter.ydl_opts_extra(clients)
|
{}, "%(id)s.%(ext)s", lambda d: None, extra=adapter.ydl_opts_extra(clients)
|
||||||
@@ -40,7 +46,19 @@ def _extract_meta(url: str, adapter: adapters.SourceAdapter) -> dict | None:
|
|||||||
if info:
|
if info:
|
||||||
return info
|
return info
|
||||||
except Exception as exc: # noqa: BLE001 — best-effort backfill, keep going
|
except Exception as exc: # noqa: BLE001 — best-effort backfill, keep going
|
||||||
log.info("meta extract failed for %s (%s): %s", url, clients, str(exc)[:100])
|
# The asset id, not the URL. A source URL is a row of this user's download
|
||||||
|
# history, and `redact_text` only takes credential params out of it — the rest
|
||||||
|
# of it goes in whole. The id names the same asset to anyone holding the
|
||||||
|
# database and nothing at all to anyone who is not; the host is kept because it
|
||||||
|
# is what decides which client set was tried. yt-dlp's message goes through
|
||||||
|
# `log_safe` for the same reason: it opens with the URL or the video id.
|
||||||
|
log.info(
|
||||||
|
"meta extract failed for asset %s on %s (%s): %s",
|
||||||
|
asset_id,
|
||||||
|
urlsplit(url).hostname or "?", # log-pii-ok: the host, never the path or query
|
||||||
|
clients,
|
||||||
|
formats.log_safe(exc)[:100],
|
||||||
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -60,12 +78,14 @@ def run() -> dict:
|
|||||||
adapter = adapters.resolve_adapter(a.source_kind, a.source_ref)
|
adapter = adapters.resolve_adapter(a.source_kind, a.source_ref)
|
||||||
if a.uploader_url is None and adapter.wants_metadata_backfill():
|
if a.uploader_url is None and adapter.wants_metadata_backfill():
|
||||||
url = a.source_webpage_url or service.source_url(a.source_kind, a.source_ref)
|
url = a.source_webpage_url or service.source_url(a.source_kind, a.source_ref)
|
||||||
info = _extract_meta(url, adapter)
|
info = _extract_meta(url, adapter, a.id)
|
||||||
uu = adapter.uploader_url(info) if info else None
|
uu = adapter.uploader_url(info) if info else None
|
||||||
if uu:
|
if uu:
|
||||||
a.uploader_url = uu
|
a.uploader_url = uu
|
||||||
filled_url += 1
|
filled_url += 1
|
||||||
log.info("asset %s uploader_url <- %s", a.id, uu)
|
log.info(
|
||||||
|
"asset %s uploader_url filled", a.id
|
||||||
|
) # the URL names what they downloaded
|
||||||
if a.poster_path is None:
|
if a.poster_path is None:
|
||||||
# Record a self-hosted poster for every download (the reliable og:image source):
|
# Record a self-hosted poster for every download (the reliable og:image source):
|
||||||
# ensure_poster returns the existing <base>.jpg thumbnail sidecar, or cuts a frame.
|
# ensure_poster returns the existing <base>.jpg thumbnail sidecar, or cuts a frame.
|
||||||
@@ -73,7 +93,7 @@ def run() -> dict:
|
|||||||
if rel:
|
if rel:
|
||||||
a.poster_path = rel
|
a.poster_path = rel
|
||||||
filled_poster += 1
|
filled_poster += 1
|
||||||
log.info("asset %s poster <- %s", a.id, rel)
|
log.info("asset %s poster filled", a.id) # the path carries the title
|
||||||
db.commit()
|
db.commit()
|
||||||
result = {
|
result = {
|
||||||
"assets": len(assets),
|
"assets": len(assets),
|
||||||
|
|||||||
@@ -23,19 +23,19 @@ Security controls enforced here:
|
|||||||
logged, or written to a job's error/snapshot.
|
logged, or written to a job's error/snapshot.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app import logs
|
||||||
from app.downloads import formats
|
from app.downloads import formats
|
||||||
from app.downloads.adapters import _host_is
|
from app.downloads.adapters import _host_is
|
||||||
from app.models import DownloadCredential, MediaAsset
|
from app.models import DownloadCredential, MediaAsset
|
||||||
from app.security import decrypt_optional, encrypt
|
from app.security import decrypt_optional, encrypt
|
||||||
|
|
||||||
log = logging.getLogger("siftlode.downloads.credentials")
|
log = logs.get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class HostSpec:
|
class HostSpec:
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ for a player hover-preview later.
|
|||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import logging
|
|
||||||
import math
|
import math
|
||||||
import os
|
import os
|
||||||
import queue
|
import queue
|
||||||
@@ -33,9 +32,11 @@ import threading
|
|||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app import logs
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
from app.downloads.stated import StatedFailure
|
||||||
|
|
||||||
log = logging.getLogger("siftlode.edit")
|
log = logs.get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def encode_thread_cap() -> int:
|
def encode_thread_cap() -> int:
|
||||||
@@ -71,7 +72,13 @@ def normalize_edit_spec(spec: dict | None) -> dict:
|
|||||||
if crop:
|
if crop:
|
||||||
try:
|
try:
|
||||||
c = {k: int(round(float(crop[k]))) for k in ("x", "y", "w", "h")}
|
c = {k: int(round(float(crop[k]))) for k in ("x", "y", "w", "h")}
|
||||||
except (KeyError, TypeError, ValueError):
|
except (KeyError, TypeError, ValueError) as exc:
|
||||||
|
# The clip still renders — uncropped. The user asked for a crop, got a whole frame,
|
||||||
|
# and nothing anywhere called it a failure; this line is the only trace there is.
|
||||||
|
log.warning(
|
||||||
|
"the crop in an edit spec could not be read and was dropped",
|
||||||
|
extra=logs.event("edit.spec.crop_dropped", reason=type(exc).__name__),
|
||||||
|
)
|
||||||
c = None
|
c = None
|
||||||
if c and c["w"] > 0 and c["h"] > 0 and c["x"] >= 0 and c["y"] >= 0:
|
if c and c["w"] > 0 and c["h"] > 0 and c["x"] >= 0 and c["y"] >= 0:
|
||||||
out["crop"] = c
|
out["crop"] = c
|
||||||
@@ -80,6 +87,7 @@ def normalize_edit_spec(spec: dict | None) -> dict:
|
|||||||
segs = spec.get("segments")
|
segs = spec.get("segments")
|
||||||
if isinstance(segs, list) and segs:
|
if isinstance(segs, list) and segs:
|
||||||
norm: list[dict] = []
|
norm: list[dict] = []
|
||||||
|
unreadable = 0
|
||||||
for s in segs:
|
for s in segs:
|
||||||
try:
|
try:
|
||||||
st = max(0.0, float((s or {}).get("start_s") or 0.0))
|
st = max(0.0, float((s or {}).get("start_s") or 0.0))
|
||||||
@@ -88,9 +96,18 @@ def normalize_edit_spec(spec: dict | None) -> dict:
|
|||||||
continue
|
continue
|
||||||
en = float(end_raw)
|
en = float(end_raw)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
|
# silent-ok: counted, not logged here — one cut-list can drop several, and the
|
||||||
|
# join that comes out is simply shorter than the one the user built. Reported
|
||||||
|
# once after the loop.
|
||||||
|
unreadable += 1
|
||||||
continue
|
continue
|
||||||
if en > st:
|
if en > st:
|
||||||
norm.append({"start_s": round(st, 3), "end_s": round(en, 3)})
|
norm.append({"start_s": round(st, 3), "end_s": round(en, 3)})
|
||||||
|
if unreadable:
|
||||||
|
log.warning(
|
||||||
|
"some segments of a cut list could not be read and were dropped",
|
||||||
|
extra=logs.event("edit.spec.segments_dropped", dropped=unreadable, kept=len(norm)),
|
||||||
|
)
|
||||||
norm.sort(key=lambda x: x["start_s"])
|
norm.sort(key=lambda x: x["start_s"])
|
||||||
# SEC-13: an accurate join builds one ffmpeg `filter_complex` with two filters per segment,
|
# SEC-13: an accurate join builds one ffmpeg `filter_complex` with two filters per segment,
|
||||||
# so an unbounded list becomes an unbounded command line and an ffmpeg that never finishes.
|
# so an unbounded list becomes an unbounded command line and an ffmpeg that never finishes.
|
||||||
@@ -114,7 +131,13 @@ def normalize_edit_spec(spec: dict | None) -> dict:
|
|||||||
start = max(0.0, float(trim.get("start_s") or 0.0))
|
start = max(0.0, float(trim.get("start_s") or 0.0))
|
||||||
end_raw = trim.get("end_s")
|
end_raw = trim.get("end_s")
|
||||||
end = float(end_raw) if end_raw is not None else None
|
end = float(end_raw) if end_raw is not None else None
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError) as exc:
|
||||||
|
# Either a 400 the user cannot act on, or — beside a crop — a full-length clip where
|
||||||
|
# they asked for a cut. Both are worth one line saying the spec was the problem.
|
||||||
|
log.warning(
|
||||||
|
"the trim in an edit spec could not be read and was dropped",
|
||||||
|
extra=logs.event("edit.spec.trim_dropped", reason=type(exc).__name__),
|
||||||
|
)
|
||||||
start, end = 0.0, None
|
start, end = 0.0, None
|
||||||
t: dict = {"start_s": round(start, 3)}
|
t: dict = {"start_s": round(start, 3)}
|
||||||
if end is not None and end > start:
|
if end is not None and end > start:
|
||||||
@@ -306,11 +329,14 @@ def _parse_out_time(line: str) -> float | None:
|
|||||||
if line.startswith("out_time_us="):
|
if line.startswith("out_time_us="):
|
||||||
try:
|
try:
|
||||||
return int(line.split("=", 1)[1]) / 1_000_000
|
return int(line.split("=", 1)[1]) / 1_000_000
|
||||||
|
# silent-ok: one line of ffmpeg's `-progress` stream, several per second. None means "this
|
||||||
|
# line carried no usable time", which the caller already treats as "no progress yet".
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return None
|
return None
|
||||||
if line.startswith("out_time_ms="): # (some builds mislabel this as microseconds)
|
if line.startswith("out_time_ms="): # (some builds mislabel this as microseconds)
|
||||||
try:
|
try:
|
||||||
return int(line.split("=", 1)[1]) / 1_000_000
|
return int(line.split("=", 1)[1]) / 1_000_000
|
||||||
|
# silent-ok: as above, the other spelling of the same line.
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return None
|
return None
|
||||||
return None
|
return None
|
||||||
@@ -329,7 +355,9 @@ def _pump(stream, sink: "queue.Queue[str | None]") -> None:
|
|||||||
try:
|
try:
|
||||||
for line in stream:
|
for line in stream:
|
||||||
sink.put(line)
|
sink.put(line)
|
||||||
except (ValueError, OSError): # pipe closed under us by the kill path
|
# silent-ok: the pipe was closed under us by our OWN kill path — the expected end of a
|
||||||
|
# cancelled or timed-out run, and the code that did the killing is the code that reports it.
|
||||||
|
except (ValueError, OSError):
|
||||||
pass
|
pass
|
||||||
finally:
|
finally:
|
||||||
sink.put(None)
|
sink.put(None)
|
||||||
@@ -347,6 +375,8 @@ def _drain_tail(stream, sink: list[str]) -> None:
|
|||||||
sink.append(line)
|
sink.append(line)
|
||||||
if len(sink) > 200:
|
if len(sink) > 200:
|
||||||
del sink[:100]
|
del sink[:100]
|
||||||
|
# silent-ok: same closed pipe as `_pump` above, and the tail collected so far is still handed
|
||||||
|
# to whoever reports the failure.
|
||||||
except (ValueError, OSError):
|
except (ValueError, OSError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -362,13 +392,17 @@ def escalate_kill(proc: subprocess.Popen, terminate, kill, grace_s: float, label
|
|||||||
try:
|
try:
|
||||||
proc.wait(timeout=grace_s)
|
proc.wait(timeout=grace_s)
|
||||||
return
|
return
|
||||||
|
# silent-ok: the escalation below IS the handling of this timeout, and surviving THAT is what
|
||||||
|
# the line under the second wait reports.
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
pass
|
pass
|
||||||
kill()
|
kill()
|
||||||
try:
|
try:
|
||||||
proc.wait(timeout=grace_s)
|
proc.wait(timeout=grace_s)
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
log.error("%s pid %s survived SIGKILL", label, proc.pid)
|
# noqa TRY400: the exception IS the message here. A TimeoutExpired's traceback is this
|
||||||
|
# `wait` call and nothing else — it would add three frames and no fact the line lacks.
|
||||||
|
log.error("%s pid %s survived SIGKILL", label, proc.pid) # noqa: TRY400
|
||||||
|
|
||||||
|
|
||||||
def _stop(proc: subprocess.Popen) -> None:
|
def _stop(proc: subprocess.Popen) -> None:
|
||||||
@@ -404,6 +438,8 @@ def run_ffmpeg(
|
|||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
line = lines.get(timeout=1.0)
|
line = lines.get(timeout=1.0)
|
||||||
|
# silent-ok: not a failure — the one-second timeout is how this loop gets a turn to
|
||||||
|
# poll for cancellation while ffmpeg is quiet.
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
line = ""
|
line = ""
|
||||||
if line is None: # stdout hit EOF — ffmpeg is done (or dying)
|
if line is None: # stdout hit EOF — ffmpeg is done (or dying)
|
||||||
@@ -416,11 +452,11 @@ def run_ffmpeg(
|
|||||||
if should_cancel():
|
if should_cancel():
|
||||||
raise EditAborted
|
raise EditAborted
|
||||||
if time.monotonic() - last_output > stall_timeout_s:
|
if time.monotonic() - last_output > stall_timeout_s:
|
||||||
raise RuntimeError(f"ffmpeg stalled: no progress for {int(stall_timeout_s)}s")
|
raise StatedFailure(f"ffmpeg stalled: no progress for {int(stall_timeout_s)}s")
|
||||||
try:
|
try:
|
||||||
ret = proc.wait(timeout=_FFMPEG_EXIT_GRACE_S)
|
ret = proc.wait(timeout=_FFMPEG_EXIT_GRACE_S)
|
||||||
except subprocess.TimeoutExpired: # pipes closed but the process lingers
|
except subprocess.TimeoutExpired: # pipes closed but the process lingers
|
||||||
raise RuntimeError("ffmpeg did not exit after closing its output")
|
raise StatedFailure("ffmpeg did not exit after closing its output")
|
||||||
except BaseException:
|
except BaseException:
|
||||||
# ONE exit path stops the process, so the invariant is unconditional: nothing leaves this
|
# ONE exit path stops the process, so the invariant is unconditional: nothing leaves this
|
||||||
# function with ffmpeg still running. Not just our own EditAborted/stall — `on_progress`
|
# function with ffmpeg still running. Not just our own EditAborted/stall — `on_progress`
|
||||||
@@ -435,7 +471,7 @@ def run_ffmpeg(
|
|||||||
if pipe:
|
if pipe:
|
||||||
pipe.close()
|
pipe.close()
|
||||||
if ret != 0:
|
if ret != 0:
|
||||||
raise RuntimeError(f"ffmpeg failed ({ret}): {''.join(err_tail)[-300:]}")
|
raise StatedFailure(f"ffmpeg failed ({ret}): {''.join(err_tail)[-300:]}")
|
||||||
|
|
||||||
|
|
||||||
# --- ffmpeg: universal browser playability --------------------------------------------------
|
# --- ffmpeg: universal browser playability --------------------------------------------------
|
||||||
@@ -471,7 +507,13 @@ def run_ffprobe_json(
|
|||||||
try:
|
try:
|
||||||
out = subprocess.run(args, capture_output=True, text=True, timeout=30, check=True).stdout
|
out = subprocess.run(args, capture_output=True, text=True, timeout=30, check=True).stdout
|
||||||
return json.loads(out)
|
return json.loads(out)
|
||||||
except (subprocess.TimeoutExpired, subprocess.CalledProcessError, OSError, ValueError):
|
except (subprocess.TimeoutExpired, subprocess.CalledProcessError, OSError, ValueError) as exc:
|
||||||
|
# None makes every field it would have filled read as absent, and a file with no streams
|
||||||
|
# and no duration is indistinguishable here from one that is not media at all.
|
||||||
|
log.warning(
|
||||||
|
"ffprobe could not read a file",
|
||||||
|
extra=logs.event("media.ffprobe_failed", reason=type(exc).__name__),
|
||||||
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -525,7 +567,13 @@ def probe_duration(path: Path) -> int:
|
|||||||
check=True,
|
check=True,
|
||||||
).stdout.strip()
|
).stdout.strip()
|
||||||
return int(float(out)) if out else 0
|
return int(float(out)) if out else 0
|
||||||
except (subprocess.TimeoutExpired, subprocess.CalledProcessError, OSError, ValueError):
|
except (subprocess.TimeoutExpired, subprocess.CalledProcessError, OSError, ValueError) as exc:
|
||||||
|
# Zero is the denominator of the re-encode progress bar, so a failed probe leaves the user
|
||||||
|
# watching a bar that never moves rather than a job that says it went wrong.
|
||||||
|
log.info(
|
||||||
|
"the duration probe failed; progress for this job has no total",
|
||||||
|
extra=logs.event("media.duration_probe_failed", reason=type(exc).__name__),
|
||||||
|
)
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
@@ -670,15 +718,28 @@ def _run_ffmpeg_best_effort(cmd: list[str], timeout_s: int) -> Exception | None:
|
|||||||
try:
|
try:
|
||||||
subprocess.run(cmd, capture_output=True, timeout=timeout_s, check=True)
|
subprocess.run(cmd, capture_output=True, timeout=timeout_s, check=True)
|
||||||
return None
|
return None
|
||||||
|
# silent-ok: the exception is the RETURN VALUE. This helper exists so the three callers share
|
||||||
|
# one run/timeout policy, and each logs the failure with the context only it has.
|
||||||
except (subprocess.TimeoutExpired, subprocess.CalledProcessError, OSError) as exc:
|
except (subprocess.TimeoutExpired, subprocess.CalledProcessError, OSError) as exc:
|
||||||
return exc
|
return exc
|
||||||
|
|
||||||
|
|
||||||
|
def _step_failure(err: BaseException) -> str:
|
||||||
|
"""What a best-effort image step's failure line says: the kind and, for ffmpeg, its exit
|
||||||
|
status. Never `str(err)`: a CalledProcessError or TimeoutExpired quotes ffmpeg's argv, and an
|
||||||
|
OSError its file name — the source path either way, which is built from the channel and the
|
||||||
|
title."""
|
||||||
|
code = getattr(err, "returncode", None)
|
||||||
|
return f"{type(err).__name__} {code}" if code is not None else type(err).__name__
|
||||||
|
|
||||||
|
|
||||||
def _is_jpeg(path: Path) -> bool:
|
def _is_jpeg(path: Path) -> bool:
|
||||||
"""True if the file starts with the JPEG SOI marker (FF D8 FF)."""
|
"""True if the file starts with the JPEG SOI marker (FF D8 FF)."""
|
||||||
try:
|
try:
|
||||||
with path.open("rb") as f:
|
with path.open("rb") as f:
|
||||||
return f.read(3) == b"\xff\xd8\xff"
|
return f.read(3) == b"\xff\xd8\xff"
|
||||||
|
# silent-ok: False only means "do not copy this straight through" — the caller then goes the
|
||||||
|
# ffmpeg route, which opens the same file and reports it properly if it really is unreadable.
|
||||||
except OSError:
|
except OSError:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -699,13 +760,15 @@ def thumbnail_to_jpg(src: Path | None, dest: Path, timeout_s: int = 30) -> Path
|
|||||||
shutil.copyfile(src, dest)
|
shutil.copyfile(src, dest)
|
||||||
return dest
|
return dest
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
log.info("thumbnail copy failed (%s); poster falls back to a cut frame", str(exc)[:120])
|
log.info(
|
||||||
|
"thumbnail copy failed (%s); poster falls back to a cut frame", _step_failure(exc)
|
||||||
|
)
|
||||||
return None
|
return None
|
||||||
err = _run_ffmpeg_best_effort(build_thumbnail_jpg_cmd(src, dest), timeout_s)
|
err = _run_ffmpeg_best_effort(build_thumbnail_jpg_cmd(src, dest), timeout_s)
|
||||||
if err is not None:
|
if err is not None:
|
||||||
log.info(
|
log.info(
|
||||||
"thumbnail->jpg conversion failed (%s); poster falls back to a cut frame",
|
"thumbnail->jpg conversion failed (%s); poster falls back to a cut frame",
|
||||||
str(err)[:120],
|
_step_failure(err),
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
return dest if dest.exists() else None
|
return dest if dest.exists() else None
|
||||||
@@ -730,7 +793,7 @@ def ensure_poster(asset, download_root: str, timeout_s: int = 30) -> str | None:
|
|||||||
at = min(3.0, (asset.duration_s or 10) * 0.1)
|
at = min(3.0, (asset.duration_s or 10) * 0.1)
|
||||||
err = _run_ffmpeg_best_effort(build_poster_cmd(src, dest, at), timeout_s)
|
err = _run_ffmpeg_best_effort(build_poster_cmd(src, dest, at), timeout_s)
|
||||||
if err is not None:
|
if err is not None:
|
||||||
log.info("poster gen skipped for asset %s: %s", asset.id, str(err)[:120])
|
log.info("poster gen skipped for asset %s: %s", asset.id, _step_failure(err))
|
||||||
return None
|
return None
|
||||||
return rel if dest.exists() else None
|
return rel if dest.exists() else None
|
||||||
|
|
||||||
@@ -761,7 +824,7 @@ def ensure_storyboard(asset, download_root: str, timeout_s: int = 90) -> dict |
|
|||||||
cmd = build_storyboard_cmd(src, dest, asset.duration_s, geom)
|
cmd = build_storyboard_cmd(src, dest, asset.duration_s, geom)
|
||||||
err = _run_ffmpeg_best_effort(cmd, timeout_s)
|
err = _run_ffmpeg_best_effort(cmd, timeout_s)
|
||||||
if err is not None:
|
if err is not None:
|
||||||
log.info("storyboard gen skipped for asset %s: %s", asset.id, str(err)[:120])
|
log.info("storyboard gen skipped for asset %s: %s", asset.id, _step_failure(err))
|
||||||
return None
|
return None
|
||||||
if not dest.exists():
|
if not dest.exists():
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -17,8 +17,134 @@ spec shape (all keys optional; defaults + browser-playability clamp applied by `
|
|||||||
`container`/`vcodec` keys survive only so an old stored profile deserializes without error.
|
`container`/`vcodec` keys survive only so an old stored profile deserializes without error.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
import contextvars
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import urllib.parse
|
||||||
|
from collections.abc import Iterator
|
||||||
|
|
||||||
|
from app import logs
|
||||||
|
|
||||||
|
log = logs.get_logger(__name__)
|
||||||
|
|
||||||
|
# yt-dlp names what it is working on in these shapes, and each one is a row of someone's download
|
||||||
|
# history: the source URL, the `[extractor] <id>:` prefix on nearly every warning and error, the
|
||||||
|
# file path it writes to (`storage.rel_path` builds it from the channel and the title), a
|
||||||
|
# playlist's title, and an id quoted mid-sentence. The job id is already on every line
|
||||||
|
# (`logs.set_job` in the download child), so none of them adds anything an operator needs. What
|
||||||
|
# stays: the host (it says which extractor is failing) and the extractor tag. Every pattern below
|
||||||
|
# is a shape measured in yt-dlp's own source (2026.07.04), pinned in test_ytdlp_logging.py.
|
||||||
|
_URL_RE = re.compile(r"https?://([^/\s]+)\S*")
|
||||||
|
# `Destination:` is yt-dlp's own word in `[download] Destination: <path>`, not an id — the path
|
||||||
|
# after it is what `_PATH_RE` takes out.
|
||||||
|
_ID_PREFIX_RE = re.compile(r"(\[[\w:.-]+\] )(?!Destination:)[^\s:\[\]]+:")
|
||||||
|
# The generic extractor's "id" is the URL's basename, percent-DECODED: `My%20Holiday.mp4` is
|
||||||
|
# `My Holiday` — spaces, brackets, colons and all, which no pattern can bound (`Our Trip: Day 1`
|
||||||
|
# ends at which `: `?). The job knows its own URL, so the exact name is taken out as a literal
|
||||||
|
# (`hiding`). This pattern is the fallback for a line logged outside that scope: up to the first
|
||||||
|
# `: `. Only this extractor: every other one names a site id, and letting the prefix run to `: `
|
||||||
|
# everywhere would take the words out of lines like `[VideoConvertor] Converting …; Destination:`.
|
||||||
|
_GENERIC_ID_RE = re.compile(r"(\[generic\] )(?!Destination:)[^\[\]\n]+?:(?= |$)", re.M)
|
||||||
|
# `[youtube:tab] Playlist <title>: Downloading 5 items` — the title is not an id, and has spaces.
|
||||||
|
_PLAYLIST_TITLE_RE = re.compile(r"(\] Playlist ).+?(?=: Downloading )")
|
||||||
|
# `[download] Finished downloading playlist: <title>`, `[download] Downloading playlist: <title>`,
|
||||||
|
# `… Skipping already downloaded playlist: <title>` — the title runs to the end of the line.
|
||||||
|
_TITLE_TAIL_RE = re.compile(r"(playlist: |\[download\] Downloading \w+: ).*", re.I)
|
||||||
|
# An id quoted mid-sentence, not as a prefix: `… not available for <id>`, and `--no-playlist`'s
|
||||||
|
# `Downloading just the video <id>` (`_yes_playlist`) / `Downloading just video <id>` (multifeed).
|
||||||
|
_BARE_ID_RE = re.compile(
|
||||||
|
r"((?:subtitles not available for|Downloading just (?:the )?\w+) )[^\s,;]+"
|
||||||
|
)
|
||||||
|
# Two segments or more, and not glued to a word: `www.youtube.com/…` is what the URL pattern
|
||||||
|
# leaves behind, and it must survive this one.
|
||||||
|
_PATH_RE = re.compile(r"(?<![\w.…])/(?:[^/\s\"']+/)+[^/\s\"']*")
|
||||||
|
|
||||||
|
|
||||||
|
_HIDDEN: contextvars.ContextVar[tuple[str, ...]] = contextvars.ContextVar(
|
||||||
|
"formats_hidden", default=()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def generic_id(url: str) -> str:
|
||||||
|
"""The name yt-dlp's generic extractor gives a URL: its basename, extension off, percent-
|
||||||
|
decoded — `InfoExtractor._generic_id` (2026.07.04), pinned against it in the tests."""
|
||||||
|
return urllib.parse.unquote(os.path.splitext(url.rstrip("/").split("/")[-1])[0])
|
||||||
|
|
||||||
|
|
||||||
|
@contextlib.contextmanager
|
||||||
|
def hiding(url: str | None) -> Iterator[None]:
|
||||||
|
"""For the work on one URL: `log_safe` also takes out the name yt-dlp's generic extractor
|
||||||
|
gives it, exactly, where yt-dlp puts it (`[generic] <name>:`). A context variable, so it
|
||||||
|
holds for yt-dlp's logger calls too — they run on the same thread as the extraction."""
|
||||||
|
name = generic_id(url) if url else ""
|
||||||
|
token = _HIDDEN.set((*_HIDDEN.get(), name) if name else _HIDDEN.get())
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
_HIDDEN.reset(token)
|
||||||
|
|
||||||
|
|
||||||
|
def log_safe(text: object) -> str:
|
||||||
|
"""yt-dlp's text (its own lines, or a DownloadError's message) with the parts that name a
|
||||||
|
person's download taken out. Every log line that carries yt-dlp's words goes through here,
|
||||||
|
not just the adapter's: yt-dlp raises the same text it has just logged, so a line that skipped
|
||||||
|
it would put back one line later exactly what the adapter took out."""
|
||||||
|
text = str(text)
|
||||||
|
for name in _HIDDEN.get():
|
||||||
|
text = text.replace(f"[generic] {name}:", "[generic] …:")
|
||||||
|
text = _URL_RE.sub(lambda m: f"{m.group(1)}/…", text)
|
||||||
|
text = _ID_PREFIX_RE.sub(r"\1…:", text)
|
||||||
|
text = _GENERIC_ID_RE.sub(r"\1…:", text)
|
||||||
|
text = _PLAYLIST_TITLE_RE.sub(r"\1…", text)
|
||||||
|
text = _TITLE_TAIL_RE.sub(r"\1…", text)
|
||||||
|
text = _BARE_ID_RE.sub(r"\1…", text)
|
||||||
|
return _PATH_RE.sub("…", text)
|
||||||
|
|
||||||
|
|
||||||
|
class _YdlLog:
|
||||||
|
"""yt-dlp's logger protocol, pointed at ours.
|
||||||
|
|
||||||
|
Without it yt-dlp explains itself to nobody: `quiet` sends its output to a stdout we do not
|
||||||
|
read, so "Requested format is not available", a signature-extraction failure or a POT refusal
|
||||||
|
reached us only as whatever text ended up in the raised exception. Measured against yt-dlp
|
||||||
|
2026.07.04: with `logger` set it calls `debug` for extraction and progress lines and
|
||||||
|
`warning`/`error` for the rest, and `quiet`/`no_warnings` do NOT gate those calls.
|
||||||
|
|
||||||
|
`debug` goes to TRACE: it is several lines per second during a download, so it shows only
|
||||||
|
when the whole process runs at TRACE. (Not under a user's own Teljes consent — that lives in
|
||||||
|
the API process's request scope, and the download child that runs yt-dlp has none.)
|
||||||
|
|
||||||
|
`error` goes to WARNING, not ERROR. yt-dlp calls it from `trouble()` just before it raises,
|
||||||
|
on EVERY failed attempt — including the ones the pipeline then recovers from with its fallback
|
||||||
|
client set. Whether the job failed is the pipeline's verdict (`download.job.failed`), and that
|
||||||
|
is the one line whose level should say so."""
|
||||||
|
|
||||||
|
def _emit(self, level: int, msg: str) -> None:
|
||||||
|
# The line goes as an ARGUMENT, not as the format string. yt-dlp writes progress as
|
||||||
|
# `[download] 53.2% of 12.00MiB`, and while stdlib logging only applies `%` formatting to
|
||||||
|
# a record that has args, this way the question never arises — and it is the shape ruff's
|
||||||
|
# `G` rules ask for. (The sink's flood limiter groups a bare `%s` by the text itself, so
|
||||||
|
# distinct yt-dlp warnings do not share one bucket — `logsink._template`.)
|
||||||
|
log.log(level, "%s", log_safe(msg), extra=logs.event("download.ytdlp"))
|
||||||
|
|
||||||
|
def debug(self, msg):
|
||||||
|
self._emit(logs.TRACE, msg)
|
||||||
|
|
||||||
|
def info(self, msg):
|
||||||
|
self._emit(logging.DEBUG, msg)
|
||||||
|
|
||||||
|
def warning(self, msg):
|
||||||
|
self._emit(logging.WARNING, msg)
|
||||||
|
|
||||||
|
def error(self, msg):
|
||||||
|
self._emit(logging.WARNING, msg)
|
||||||
|
|
||||||
|
|
||||||
|
_YDL_LOG = _YdlLog()
|
||||||
|
|
||||||
# Fields that affect the produced file — and therefore the cache identity.
|
# Fields that affect the produced file — and therefore the cache identity.
|
||||||
_SIG_KEYS = (
|
_SIG_KEYS = (
|
||||||
@@ -78,7 +204,13 @@ def normalize(spec: dict) -> dict:
|
|||||||
# rather than raising an unhandled 500 at enqueue.
|
# rather than raising an unhandled 500 at enqueue.
|
||||||
try:
|
try:
|
||||||
s["max_height"] = int(s["max_height"])
|
s["max_height"] = int(s["max_height"])
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError) as exc:
|
||||||
|
# "best" is the opposite of what a height cap asks for: the user set a ceiling to save
|
||||||
|
# bandwidth or disk, and the download quietly takes the largest rendition there is.
|
||||||
|
log.warning(
|
||||||
|
"the height cap on a download could not be read and was dropped",
|
||||||
|
extra=logs.event("download.format.max_height_dropped", reason=type(exc).__name__),
|
||||||
|
)
|
||||||
s["max_height"] = None
|
s["max_height"] = None
|
||||||
for b in ("embed_subs", "embed_chapters", "embed_thumbnail", "sponsorblock"):
|
for b in ("embed_subs", "embed_chapters", "embed_thumbnail", "sponsorblock"):
|
||||||
s[b] = bool(s[b])
|
s[b] = bool(s[b])
|
||||||
@@ -181,6 +313,12 @@ def build_ydl_opts(
|
|||||||
if extra:
|
if extra:
|
||||||
opts.update(extra)
|
opts.update(extra)
|
||||||
|
|
||||||
|
# With a logger attached, `quiet` and `no_warnings` stop deciding whether yt-dlp explains itself
|
||||||
|
# and only decide where — so both stay on above and the explanation comes here instead of to a
|
||||||
|
# stdout nothing reads. AFTER the adapter merge, like `cookiefile` and the size cap: an adapter
|
||||||
|
# must not be able to silence the one account of what went wrong that a failed job leaves.
|
||||||
|
opts["logger"] = _YDL_LOG
|
||||||
|
|
||||||
# SEC-13: hard ceiling on a single download. AFTER the adapter merge, for the same reason
|
# SEC-13: hard ceiling on a single download. AFTER the adapter merge, for the same reason
|
||||||
# `cookiefile` is — a source adapter must not be able to raise a limit the quota layer set.
|
# `cookiefile` is — a source adapter must not be able to raise a limit the quota layer set.
|
||||||
#
|
#
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ Five passes each run:
|
|||||||
Runs in the API process (which mounts DOWNLOAD_ROOT). Pure disk/DB work — no YouTube quota.
|
Runs in the API process (which mounts DOWNLOAD_ROOT). Pure disk/DB work — no YouTube quota.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
|
||||||
import time
|
import time
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -24,14 +23,13 @@ from pathlib import Path
|
|||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app import sysconfig
|
from app import logs, sysconfig
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.downloads import storage
|
from app.downloads import storage
|
||||||
from app.models import DownloadJob, MediaAsset
|
from app.models import DownloadJob, MediaAsset
|
||||||
from app.notifications import create_notification
|
from app.notifications import create_notification
|
||||||
|
|
||||||
log = logging.getLogger("siftlode.downloads.gc")
|
log = logs.get_logger(__name__)
|
||||||
|
|
||||||
# Dot-prefixed top-level trees that are NOT part of the Plex/flat asset layout — the orphan sweep
|
# Dot-prefixed top-level trees that are NOT part of the Plex/flat asset layout — the orphan sweep
|
||||||
# never touches them (user clips, Plex HLS/img caches, in-progress staging, editor storyboards).
|
# never touches them (user clips, Plex HLS/img caches, in-progress staging, editor storyboards).
|
||||||
_SYSTEM_TREE = {".edits", ".plex-hls", ".plex-img-cache", ".staging", ".storyboards"}
|
_SYSTEM_TREE = {".edits", ".plex-hls", ".plex-img-cache", ".staging", ".storyboards"}
|
||||||
@@ -99,6 +97,7 @@ def _sweep_orphan_files(db: Session, download_root: str) -> int:
|
|||||||
if pause_s and touched % batch == 0:
|
if pause_s and touched % batch == 0:
|
||||||
time.sleep(pause_s)
|
time.sleep(pause_s)
|
||||||
|
|
||||||
|
unreclaimed = 0
|
||||||
for entry in root.iterdir():
|
for entry in root.iterdir():
|
||||||
if entry.name.startswith("."): # .edits / .plex-* / .staging / .storyboards + any dotfile
|
if entry.name.startswith("."): # .edits / .plex-* / .staging / .storyboards + any dotfile
|
||||||
continue
|
continue
|
||||||
@@ -111,7 +110,9 @@ def _sweep_orphan_files(db: Session, download_root: str) -> int:
|
|||||||
f.unlink()
|
f.unlink()
|
||||||
removed += 1
|
removed += 1
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
# silent-ok: counted, not logged per file — a broken mount would put one line
|
||||||
|
# under every orphan. The total is reported once at the end of the sweep.
|
||||||
|
unreclaimed += 1
|
||||||
if entry.is_dir(): # prune emptied dirs (deepest first), then the channel dir itself
|
if entry.is_dir(): # prune emptied dirs (deepest first), then the channel dir itself
|
||||||
for d in sorted(
|
for d in sorted(
|
||||||
(p for p in entry.rglob("*") if p.is_dir()),
|
(p for p in entry.rglob("*") if p.is_dir()),
|
||||||
@@ -120,12 +121,20 @@ def _sweep_orphan_files(db: Session, download_root: str) -> int:
|
|||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
d.rmdir()
|
d.rmdir()
|
||||||
|
# silent-ok: `rmdir` refusing means the directory still holds files, which is
|
||||||
|
# the ordinary answer here — this prune only takes the ones that emptied.
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
try:
|
try:
|
||||||
entry.rmdir()
|
entry.rmdir()
|
||||||
|
# silent-ok: the same prune one level up, at the channel directory.
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
|
if unreclaimed:
|
||||||
|
log.warning(
|
||||||
|
"some orphaned download files could not be reclaimed",
|
||||||
|
extra=logs.event("download.gc.unreclaimed", files=unreclaimed, removed=removed),
|
||||||
|
)
|
||||||
return removed
|
return removed
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ def check_grant(token: str, grant: str | None, password_version: int) -> bool:
|
|||||||
exp_s, sig = grant.split(".", 1)
|
exp_s, sig = grant.split(".", 1)
|
||||||
try:
|
try:
|
||||||
exp = int(exp_s)
|
exp = int(exp_s)
|
||||||
|
# silent-ok: an untrusted grant that does not parse is simply not a valid grant, and the
|
||||||
|
# honest answer to it — a password prompt — is the same as for one that expired.
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return False
|
return False
|
||||||
if exp < datetime.now(timezone.utc).timestamp():
|
if exp < datetime.now(timezone.utc).timestamp():
|
||||||
|
|||||||
@@ -66,6 +66,8 @@ def _jpeg_size(path: Path) -> tuple[int, int] | None:
|
|||||||
width = (data[3] << 8) + data[4]
|
width = (data[3] << 8) + data[4]
|
||||||
return width, height
|
return width, height
|
||||||
f.seek(length - 2, 1) # skip this segment's payload
|
f.seek(length - 2, 1) # skip this segment's payload
|
||||||
|
# silent-ok: None omits width/height from the unfurl card, which is what the card does for
|
||||||
|
# every poster whose dimensions were never asked for. Nothing is claimed that is not true.
|
||||||
except OSError:
|
except OSError:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
"""Child-side download/edit pipeline — the yt-dlp download + ffmpeg edit that runs in the spawned subprocess."""
|
"""Child-side download/edit pipeline — the yt-dlp download + ffmpeg edit that runs in the spawned subprocess."""
|
||||||
|
|
||||||
import logging
|
|
||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import time
|
import time
|
||||||
@@ -9,7 +8,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
import yt_dlp
|
import yt_dlp
|
||||||
|
|
||||||
from app import sysconfig
|
from app import logs, sysconfig
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.db import SessionLocal
|
from app.db import SessionLocal
|
||||||
from app.downloads import edit as editmod
|
from app.downloads import edit as editmod
|
||||||
@@ -24,12 +23,13 @@ from app.downloads.jobdb import (
|
|||||||
_staging_dir,
|
_staging_dir,
|
||||||
_touch_job,
|
_touch_job,
|
||||||
)
|
)
|
||||||
|
from app.downloads.stated import StatedFailure
|
||||||
from app.downloads.status import JobStatus
|
from app.downloads.status import JobStatus
|
||||||
from app.models import DownloadJob, MediaAsset
|
from app.models import DISPLAY_TEXT_MAX, DownloadJob, MediaAsset
|
||||||
from app.notifications import create_notification
|
from app.notifications import create_notification
|
||||||
from app.titles import normalize_title
|
from app.titles import normalize_title
|
||||||
|
|
||||||
log = logging.getLogger("siftlode.worker")
|
log = logs.get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class _Aborted(Exception):
|
class _Aborted(Exception):
|
||||||
@@ -98,6 +98,8 @@ def _parse_upload_date(raw) -> date | None:
|
|||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
return datetime.strptime(str(raw), "%Y%m%d").date()
|
return datetime.strptime(str(raw), "%Y%m%d").date()
|
||||||
|
# silent-ok: a date we cannot read becomes a date the card does not show, which is what the
|
||||||
|
# card shows for every source that never supplies one. Absent is the honest rendering.
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -138,7 +140,14 @@ def _make_progress_hook(job_id: int, asset_id: int):
|
|||||||
try:
|
try:
|
||||||
_fill_asset_meta_early(asset_id, d["info_dict"])
|
_fill_asset_meta_early(asset_id, d["info_dict"])
|
||||||
except Exception: # noqa: BLE001 — metadata is best-effort, never fail a download
|
except Exception: # noqa: BLE001 — metadata is best-effort, never fail a download
|
||||||
pass
|
# Best-effort is about the DOWNLOAD, not about knowing. What this swallows is
|
||||||
|
# anything from a shape change in yt-dlp's info dict to the database being down,
|
||||||
|
# and all the user sees is a card with an id on it until the job finishes.
|
||||||
|
log.info(
|
||||||
|
"early metadata could not be filled in for this download",
|
||||||
|
extra=logs.event("download.meta.early_fill_failed", asset_id=asset_id),
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
if d.get("status") != "downloading":
|
if d.get("status") != "downloading":
|
||||||
return
|
return
|
||||||
if not allow_write():
|
if not allow_write():
|
||||||
@@ -299,7 +308,7 @@ def _produced_file(info: dict, staging: Path) -> Path:
|
|||||||
and not _YTDLP_TEMP_RE.search(p.name)
|
and not _YTDLP_TEMP_RE.search(p.name)
|
||||||
]
|
]
|
||||||
if not cands:
|
if not cands:
|
||||||
raise RuntimeError("yt-dlp produced no output file")
|
raise StatedFailure("yt-dlp produced no output file")
|
||||||
return max(cands, key=lambda p: p.stat().st_size)
|
return max(cands, key=lambda p: p.stat().st_size)
|
||||||
|
|
||||||
|
|
||||||
@@ -375,13 +384,22 @@ def _extract_with_fallback(
|
|||||||
"job %s: clients %s failed (%s); retrying with fallback",
|
"job %s: clients %s failed (%s); retrying with fallback",
|
||||||
job_id,
|
job_id,
|
||||||
clients,
|
clients,
|
||||||
str(exc)[:70],
|
formats.log_safe(exc)[:70],
|
||||||
)
|
)
|
||||||
for p in list(staging.iterdir()): # drop partials before the retry
|
for p in list(staging.iterdir()): # drop partials before the retry
|
||||||
try:
|
try:
|
||||||
p.unlink()
|
p.unlink()
|
||||||
except OSError:
|
except OSError as exc:
|
||||||
pass
|
# A partial left behind is not inert: the next client set resumes into it,
|
||||||
|
# so the retry this loop exists to make clean starts dirty instead.
|
||||||
|
log.info(
|
||||||
|
"a partial file could not be removed before the retry",
|
||||||
|
extra=logs.event(
|
||||||
|
"download.retry.partial_unlink_failed",
|
||||||
|
job_id=job_id,
|
||||||
|
reason=type(exc).__name__,
|
||||||
|
),
|
||||||
|
)
|
||||||
_set_job(job_id, progress=0, phase="downloading")
|
_set_job(job_id, progress=0, phase="downloading")
|
||||||
continue
|
continue
|
||||||
raise
|
raise
|
||||||
@@ -464,7 +482,7 @@ def _download(
|
|||||||
try:
|
try:
|
||||||
urlguard.check_url(url)
|
urlguard.check_url(url)
|
||||||
except urlguard.BlockedUrl as e:
|
except urlguard.BlockedUrl as e:
|
||||||
raise RuntimeError(f"Refusing to download from this address ({e.reason}).") from e
|
raise StatedFailure(f"Refusing to download from this address ({e.reason}).") from e
|
||||||
# S6: attach the acting user's per-host cookie ONLY when it's host-scoped to this URL AND
|
# S6: attach the acting user's per-host cookie ONLY when it's host-scoped to this URL AND
|
||||||
# this asset is that user's private, salted identity (the double gate lives in credentials).
|
# this asset is that user's private, salted identity (the double gate lives in credentials).
|
||||||
remaining_bytes: int | None = None
|
remaining_bytes: int | None = None
|
||||||
@@ -579,20 +597,30 @@ def _download(
|
|||||||
}
|
}
|
||||||
if job and not job.display_name and meta.title:
|
if job and not job.display_name and meta.title:
|
||||||
# display_name is VARCHAR(255) — clip a long title (see _fill_asset_meta_early).
|
# display_name is VARCHAR(255) — clip a long title (see _fill_asset_meta_early).
|
||||||
fields["display_name"] = meta.title[:255]
|
fields["display_name"] = meta.title[:DISPLAY_TEXT_MAX]
|
||||||
# Atomic terminal write: finalizes ONLY while the row is still running, so a child that
|
# Atomic terminal write: finalizes ONLY while the row is still running, so a child that
|
||||||
# finishes just after a route cancel/pause can't resurrect it (see finalize_if_running).
|
# finishes just after a route cancel/pause can't resurrect it (see finalize_if_running).
|
||||||
finalized = service.finalize_if_running(db, job_id, **fields)
|
finalized = service.finalize_if_running(db, job_id, **fields)
|
||||||
db.commit()
|
db.commit()
|
||||||
if finalized:
|
if finalized:
|
||||||
_notify_download_done(db, owner_id, meta.title, job_id, kind="download")
|
_notify_download_done(db, owner_id, meta.title, job_id, kind="download")
|
||||||
log.info("job %s done: %s", job_id, rel)
|
# The asset id, not `rel`: the path is built from the channel and the title.
|
||||||
|
log.info(
|
||||||
|
"job %s done: asset %s",
|
||||||
|
job_id,
|
||||||
|
asset_id,
|
||||||
|
extra=logs.event("download.job.done", job_id=job_id, asset_id=asset_id),
|
||||||
|
)
|
||||||
|
|
||||||
except (_Aborted, editmod.EditAborted):
|
except (_Aborted, editmod.EditAborted):
|
||||||
# Paused/canceled mid-download or mid-transcode: free the asset for a later retry; keep the
|
# Paused/canceled mid-download or mid-transcode: free the asset for a later retry; keep the
|
||||||
# job's status (the route set it to paused/canceled). ref_count is adjusted by the route.
|
# job's status (the route set it to paused/canceled). ref_count is adjusted by the route.
|
||||||
_reset_asset_pending(asset_id)
|
_reset_asset_pending(asset_id)
|
||||||
log.info("job %s aborted (pause/cancel)", job_id)
|
log.info(
|
||||||
|
"job %s aborted (pause/cancel)",
|
||||||
|
job_id,
|
||||||
|
extra=logs.event("download.job.aborted", job_id=job_id),
|
||||||
|
)
|
||||||
except _OwnerRefused as exc:
|
except _OwnerRefused as exc:
|
||||||
# Fail this job only. The asset is never marked `error`; it goes back to `pending` (below),
|
# Fail this job only. The asset is never marked `error`; it goes back to `pending` (below),
|
||||||
# so another user's job for the same video is claimed and downloaded normally instead of
|
# so another user's job for the same video is claimed and downloaded normally instead of
|
||||||
@@ -613,7 +641,12 @@ def _download(
|
|||||||
# it, and this one must already be out of the way. (The staging tree is still torn down in
|
# it, and this one must already be out of the way. (The staging tree is still torn down in
|
||||||
# `finally` below — the same narrow window the pause/cancel path above has always had.)
|
# `finally` below — the same narrow window the pause/cancel path above has always had.)
|
||||||
_reset_asset_pending(asset_id)
|
_reset_asset_pending(asset_id)
|
||||||
log.info("job %s refused for its owner: %s", job_id, msg)
|
log.info(
|
||||||
|
"job %s refused for its owner: %s",
|
||||||
|
job_id,
|
||||||
|
formats.log_safe(msg),
|
||||||
|
extra=logs.event("download.job.refused", job_id=job_id),
|
||||||
|
)
|
||||||
except Exception as exc: # noqa: BLE001 — surface any failure to the user, keep worker alive
|
except Exception as exc: # noqa: BLE001 — surface any failure to the user, keep worker alive
|
||||||
msg = str(exc)[:500]
|
msg = str(exc)[:500]
|
||||||
# The catalog needs the link and whether the user's cookie went with it: the same yt-dlp
|
# The catalog needs the link and whether the user's cookie went with it: the same yt-dlp
|
||||||
@@ -626,8 +659,9 @@ def _download(
|
|||||||
if asset:
|
if asset:
|
||||||
asset.status = "error"
|
asset.status = "error"
|
||||||
asset.error = msg
|
asset.error = msg
|
||||||
# Atomic fail: only while still running, so a route-set cancel/pause is never overwritten
|
# Atomic fail: only while still running, so a route-set cancel/pause is never
|
||||||
# with `error` (error also holds the asset → would re-open the double-release class).
|
# overwritten with `error` (error also holds the asset → would re-open the
|
||||||
|
# double-release class).
|
||||||
service.finalize_if_running(
|
service.finalize_if_running(
|
||||||
db,
|
db,
|
||||||
job_id,
|
job_id,
|
||||||
@@ -638,7 +672,10 @@ def _download(
|
|||||||
phase=None,
|
phase=None,
|
||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
log.warning("job %s failed: %s", job_id, msg)
|
# After the write: a job the write could not fail is not a failed job. When it raises,
|
||||||
|
# the runner reports this failure with it (`runner._run`) and the worker fails the job.
|
||||||
|
# silent-ok: not silent — `_log_download_failed` is the report.
|
||||||
|
_log_download_failed(job_id, msg, diagnosis.code, exc)
|
||||||
finally:
|
finally:
|
||||||
# Unlink the decrypted cookie file explicitly (the rmtree below removes the whole staging
|
# Unlink the decrypted cookie file explicitly (the rmtree below removes the whole staging
|
||||||
# tree too, but never leave a plaintext session cookie on disk a moment longer than needed).
|
# tree too, but never leave a plaintext session cookie on disk a moment longer than needed).
|
||||||
@@ -647,7 +684,50 @@ def _download(
|
|||||||
shutil.rmtree(staging, ignore_errors=True)
|
shutil.rmtree(staging, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
def _fail_edit(job_id: int, asset_id: int, msg: str) -> None:
|
def _failure_stack(exc: BaseException) -> dict[str, str]:
|
||||||
|
"""The `extra=` fragment that puts a failure's traceback on its line — or nothing.
|
||||||
|
|
||||||
|
Nothing for yt-dlp's DownloadError and for our own `StatedFailure` (`ffmpeg failed (1): …`):
|
||||||
|
their message is the whole story, and their stack is only the `except` that caught them.
|
||||||
|
Everything else keeps it — a TypeError on an odd info dict, a plain RuntimeError, an OSError,
|
||||||
|
a database error: that is our code or our platform failing, and the stack is the lead.
|
||||||
|
|
||||||
|
Scrubbed, link by link (`logs.scrubbed_stack`): a stack's messages are data — an OSError names
|
||||||
|
its file, and a database error raised inside `except DownloadError` carries yt-dlp's
|
||||||
|
unscrubbed text as its context."""
|
||||||
|
if isinstance(exc, (yt_dlp.utils.DownloadError, StatedFailure)):
|
||||||
|
return {}
|
||||||
|
return logs.scrubbed_stack(exc, formats.log_safe)
|
||||||
|
|
||||||
|
|
||||||
|
def _log_download_failed(job_id: int, msg: str, code: str | None, exc: BaseException) -> None:
|
||||||
|
"""The child's `download.job.failed` line. `msg` is the user's copy and keeps their URL; the
|
||||||
|
log's copy goes through `log_safe`, or it would put back one line later the URL and id the
|
||||||
|
yt-dlp adapter took out of the line before it. The stack only when the classifier could not
|
||||||
|
place the failure: a recognised one is fully described by its code."""
|
||||||
|
unplaced = code in (None, "", "unknown")
|
||||||
|
log.warning(
|
||||||
|
"job %s failed: %s",
|
||||||
|
job_id,
|
||||||
|
formats.log_safe(msg),
|
||||||
|
extra={
|
||||||
|
**logs.event(
|
||||||
|
"download.job.failed",
|
||||||
|
job_id=job_id,
|
||||||
|
error_code=code,
|
||||||
|
error_type=type(exc).__name__,
|
||||||
|
),
|
||||||
|
**(_failure_stack(exc) if unplaced else {}),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _fail_edit(job_id: int, asset_id: int, msg: str, exc: BaseException | None = None) -> None:
|
||||||
|
"""Fail an edit job, and say so ONCE — every way an edit fails comes through here, so this is
|
||||||
|
where its line and its event code live. `exc` is the exception when there was one.
|
||||||
|
|
||||||
|
Its traceback goes with the line unless the message already tells it all (`_failure_stack`).
|
||||||
|
Logged after the write, as on the download error path."""
|
||||||
with SessionLocal() as db:
|
with SessionLocal() as db:
|
||||||
asset = db.get(MediaAsset, asset_id)
|
asset = db.get(MediaAsset, asset_id)
|
||||||
if asset:
|
if asset:
|
||||||
@@ -663,7 +743,22 @@ def _fail_edit(job_id: int, asset_id: int, msg: str) -> None:
|
|||||||
phase=None,
|
phase=None,
|
||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
log.warning("edit job %s failed: %s", job_id, msg)
|
# `msg` is the user's copy; ffmpeg's stderr tail in it can name the source file, whose path is
|
||||||
|
# built from the channel and the title.
|
||||||
|
log.warning(
|
||||||
|
"edit job %s failed: %s",
|
||||||
|
job_id,
|
||||||
|
formats.log_safe(msg),
|
||||||
|
extra={
|
||||||
|
**logs.event(
|
||||||
|
"edit.job.failed",
|
||||||
|
job_id=job_id,
|
||||||
|
asset_id=asset_id,
|
||||||
|
**({"error_type": type(exc).__name__} if exc is not None else {}),
|
||||||
|
),
|
||||||
|
**(_failure_stack(exc) if exc is not None else {}),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _process_edit(job_id: int, asset_id: int) -> None:
|
def _process_edit(job_id: int, asset_id: int) -> None:
|
||||||
@@ -715,7 +810,7 @@ def _process_edit(job_id: int, asset_id: int) -> None:
|
|||||||
should_cancel=_cancel_poll(job_id),
|
should_cancel=_cancel_poll(job_id),
|
||||||
)
|
)
|
||||||
if not dest_staging.exists():
|
if not dest_staging.exists():
|
||||||
raise RuntimeError("ffmpeg produced no output file")
|
raise StatedFailure("ffmpeg produced no output file")
|
||||||
|
|
||||||
rel = storage.edit_rel_path(user_id, asset_id, out_ext)
|
rel = storage.edit_rel_path(user_id, asset_id, out_ext)
|
||||||
storage.place_file(dest_staging, settings.download_root, rel)
|
storage.place_file(dest_staging, settings.download_root, rel)
|
||||||
@@ -751,13 +846,24 @@ def _process_edit(job_id: int, asset_id: int) -> None:
|
|||||||
db.commit()
|
db.commit()
|
||||||
if finalized:
|
if finalized:
|
||||||
_notify_download_done(db, owner_id, dl_title, job_id, kind="edit")
|
_notify_download_done(db, owner_id, dl_title, job_id, kind="edit")
|
||||||
log.info("edit job %s done: %s", job_id, rel)
|
# The asset id, not `rel`: the path is built from the channel and the title.
|
||||||
|
log.info(
|
||||||
|
"edit job %s done: asset %s",
|
||||||
|
job_id,
|
||||||
|
asset_id,
|
||||||
|
extra=logs.event("edit.job.done", job_id=job_id, asset_id=asset_id),
|
||||||
|
)
|
||||||
|
|
||||||
except editmod.EditAborted:
|
except editmod.EditAborted:
|
||||||
# Paused/canceled mid-encode: free the asset for a later retry (ref_count is the route's job).
|
# Paused/canceled mid-encode: free the asset for a later retry (ref_count is the route's job).
|
||||||
_reset_asset_pending(asset_id)
|
_reset_asset_pending(asset_id)
|
||||||
log.info("edit job %s aborted (pause/cancel)", job_id)
|
log.info(
|
||||||
|
"edit job %s aborted (pause/cancel)",
|
||||||
|
job_id,
|
||||||
|
extra=logs.event("edit.job.aborted", job_id=job_id),
|
||||||
|
)
|
||||||
except Exception as exc: # noqa: BLE001 — surface the failure, keep the worker alive
|
except Exception as exc: # noqa: BLE001 — surface the failure, keep the worker alive
|
||||||
_fail_edit(job_id, asset_id, str(exc)[:500])
|
# silent-ok: not silent — `_fail_edit` is the report, and it takes `exc` for its stack.
|
||||||
|
_fail_edit(job_id, asset_id, str(exc)[:500], exc)
|
||||||
finally:
|
finally:
|
||||||
shutil.rmtree(staging, ignore_errors=True)
|
shutil.rmtree(staging, ignore_errors=True)
|
||||||
|
|||||||
@@ -64,6 +64,8 @@ def _facts_from_probe(
|
|||||||
raw = fmt.get("duration")
|
raw = fmt.get("duration")
|
||||||
if raw is not None:
|
if raw is not None:
|
||||||
duration = int(float(raw))
|
duration = int(float(raw))
|
||||||
|
# silent-ok: an unusable duration is stored as no duration, which the UI renders as unknown
|
||||||
|
# rather than as zero — the honest state, and the one a re-probe can still fill in.
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
duration = None
|
duration = None
|
||||||
return vcodec, acodec, width, height, duration
|
return vcodec, acodec, width, height, duration
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ writes the terminal job/asset state itself (done / error, or a cooperative pause
|
|||||||
progress hook), then exits 0. An unexpected crash exits non-zero and the parent fails the job.
|
progress hook), then exits 0. An unexpected crash exits non-zero and the parent fails the job.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
@@ -22,9 +21,10 @@ from app import logsink
|
|||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.db import SessionLocal
|
from app.db import SessionLocal
|
||||||
from app.downloads import edit as editmod
|
from app.downloads import edit as editmod
|
||||||
|
from app.downloads import formats
|
||||||
from app.models import DownloadJob
|
from app.models import DownloadJob
|
||||||
|
|
||||||
log = logging.getLogger("siftlode.worker.runner")
|
log = logs.get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def _deprioritize() -> None:
|
def _deprioritize() -> None:
|
||||||
@@ -57,7 +57,12 @@ def main() -> None:
|
|||||||
logs.configure_logging("runner")
|
logs.configure_logging("runner")
|
||||||
_deprioritize()
|
_deprioritize()
|
||||||
if len(sys.argv) != 2:
|
if len(sys.argv) != 2:
|
||||||
print("usage: python -m app.downloads.runner <job_id>", file=sys.stderr)
|
# noqa T201: a usage line is for the person who typed the command. Logging is already
|
||||||
|
# configured here, and in a deployment it writes JSON records — the one reader of this
|
||||||
|
# line wants the bare sentence, not a record with a request id they do not have.
|
||||||
|
print( # noqa: T201
|
||||||
|
"usage: python -m app.downloads.runner <job_id>", file=sys.stderr
|
||||||
|
)
|
||||||
raise SystemExit(2)
|
raise SystemExit(2)
|
||||||
job_id = int(sys.argv[1])
|
job_id = int(sys.argv[1])
|
||||||
# This whole process exists to run ONE job — tag every line it prints with it, so the child's
|
# This whole process exists to run ONE job — tag every line it prints with it, so the child's
|
||||||
@@ -77,14 +82,40 @@ def main() -> None:
|
|||||||
if asset_id is None:
|
if asset_id is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
_run(job_id, asset_id, job_kind, source_kind, source_ref, spec, user_id)
|
||||||
|
|
||||||
|
|
||||||
|
def _run(job_id, asset_id, job_kind, source_kind, source_ref, spec, user_id) -> None:
|
||||||
|
"""Run the job, and be the last stop before Python's own exception hook.
|
||||||
|
|
||||||
|
What gets past the pipeline is a failure it could not even record (the database refused the
|
||||||
|
write that fails the job). Python's hook would print it to stderr raw — and its context is the
|
||||||
|
download's own failure, whose text is the URL or ffmpeg's stderr naming the file. Here it is
|
||||||
|
one line with the whole chain scrubbed; the non-zero exit then has the worker fail the job
|
||||||
|
(`cause=child_exit`)."""
|
||||||
# Imported here (not at module top) so the child pays the yt-dlp import cost only when it has
|
# Imported here (not at module top) so the child pays the yt-dlp import cost only when it has
|
||||||
# real work, and to keep the import graph obvious: runner is a thin entry over the pipeline.
|
# real work, and to keep the import graph obvious: runner is a thin entry over the pipeline.
|
||||||
from app.downloads.pipeline import _download, _process_edit
|
from app.downloads.pipeline import _download, _process_edit
|
||||||
|
|
||||||
if job_kind == "edit":
|
with formats.hiding(source_ref):
|
||||||
_process_edit(job_id, asset_id)
|
try:
|
||||||
else:
|
if job_kind == "edit":
|
||||||
_download(job_id, asset_id, source_kind, source_ref, spec, user_id)
|
_process_edit(job_id, asset_id)
|
||||||
|
else:
|
||||||
|
_download(job_id, asset_id, source_kind, source_ref, spec, user_id)
|
||||||
|
except Exception as exc: # noqa: BLE001 — whatever it is, it is reported here or raw
|
||||||
|
# noqa TRY400: the stack goes with it scrubbed (`scrubbed_stack`); `exc_info` is raw.
|
||||||
|
log.error( # noqa: TRY400
|
||||||
|
"the download child for job %s failed",
|
||||||
|
job_id,
|
||||||
|
extra={
|
||||||
|
**logs.event(
|
||||||
|
"download.child.failed", job_id=job_id, error_type=type(exc).__name__
|
||||||
|
),
|
||||||
|
**logs.scrubbed_stack(exc, formats.log_safe),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
raise SystemExit(1) from None
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ from app.downloads import credentials
|
|||||||
from app.downloads import edit as editmod
|
from app.downloads import edit as editmod
|
||||||
from app.downloads import formats, mediakind, quota, storage
|
from app.downloads import formats, mediakind, quota, storage
|
||||||
from app.downloads.status import HOLDING, JobStatus
|
from app.downloads.status import HOLDING, JobStatus
|
||||||
from app.models import Channel, DownloadJob, MediaAsset, Video
|
from app.models import DISPLAY_TEXT_MAX, Channel, DownloadJob, MediaAsset, Video
|
||||||
|
|
||||||
|
|
||||||
class EditError(Exception):
|
class EditError(Exception):
|
||||||
@@ -87,6 +87,8 @@ def get_or_create_asset(
|
|||||||
with db.begin_nested():
|
with db.begin_nested():
|
||||||
db.add(asset)
|
db.add(asset)
|
||||||
db.flush()
|
db.flush()
|
||||||
|
# silent-ok: the expected outcome of the race this savepoint exists for — two jobs wanting the
|
||||||
|
# same asset. Losing it means the other insert won, and re-selecting is the whole handling.
|
||||||
except IntegrityError:
|
except IntegrityError:
|
||||||
asset = db.execute(stmt).scalar_one()
|
asset = db.execute(stmt).scalar_one()
|
||||||
return asset
|
return asset
|
||||||
@@ -177,7 +179,7 @@ def enqueue(
|
|||||||
profile_id=profile_id,
|
profile_id=profile_id,
|
||||||
profile_snapshot=spec,
|
profile_snapshot=spec,
|
||||||
display_name=display_name
|
display_name=display_name
|
||||||
or (asset.title[:255] if asset.status == "ready" and asset.title else None),
|
or (asset.title[:DISPLAY_TEXT_MAX] if asset.status == "ready" and asset.title else None),
|
||||||
status="queued",
|
status="queued",
|
||||||
queue_pos=next_queue_pos(db),
|
queue_pos=next_queue_pos(db),
|
||||||
)
|
)
|
||||||
@@ -196,10 +198,13 @@ def enqueue(
|
|||||||
|
|
||||||
|
|
||||||
def _clip_title(source_title: str | None, display_name: str | None) -> str:
|
def _clip_title(source_title: str | None, display_name: str | None) -> str:
|
||||||
|
# Clipped to DISPLAY_TEXT_MAX, not to a literal: this feeds `DownloadJob.display_name` through
|
||||||
|
# `enqueue_edit`, the same column `display_text` guards. A machine-made title, so it is clipped
|
||||||
|
# rather than run through `display_text` — blank here means "no title", not None.
|
||||||
if display_name:
|
if display_name:
|
||||||
return display_name[:255]
|
return display_name[:DISPLAY_TEXT_MAX]
|
||||||
base = source_title or "Clip"
|
base = source_title or "Clip"
|
||||||
return f"{base} (clip)"[:255]
|
return f"{base} (clip)"[:DISPLAY_TEXT_MAX]
|
||||||
|
|
||||||
|
|
||||||
def enqueue_edit(
|
def enqueue_edit(
|
||||||
@@ -253,7 +258,7 @@ def enqueue_edit(
|
|||||||
source_asset_id=src_asset.id,
|
source_asset_id=src_asset.id,
|
||||||
edit_spec=spec,
|
edit_spec=spec,
|
||||||
profile_snapshot={},
|
profile_snapshot={},
|
||||||
display_name=display_name or asset.title,
|
display_name=display_name or asset.title[:DISPLAY_TEXT_MAX],
|
||||||
status="queued",
|
status="queued",
|
||||||
queue_pos=next_queue_pos(db),
|
queue_pos=next_queue_pos(db),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
"""The failures the download pipeline raises itself, with the whole story in the message.
|
||||||
|
|
||||||
|
`ffmpeg failed (1): …`, `yt-dlp produced no output file`, a post that shows no video: the message
|
||||||
|
says what happened, and the stack is only the `except` that caught it. A failure line leaves that
|
||||||
|
stack off (`pipeline._failure_stack`) — but ONLY for these. A plain `RuntimeError` is also what
|
||||||
|
Python raises for a bug of ours (a dict changed size during iteration, a generator's
|
||||||
|
StopIteration, `RecursionError`, `NotImplementedError`), and that one's stack is the only lead.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class StatedFailure(RuntimeError):
|
||||||
|
"""A failure we raised on purpose, whose message is its full explanation. A `RuntimeError` so
|
||||||
|
every `except RuntimeError` that caught these before still does."""
|
||||||
@@ -19,8 +19,11 @@ from datetime import date
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from xml.sax.saxutils import escape
|
from xml.sax.saxutils import escape
|
||||||
|
|
||||||
|
from app import logs
|
||||||
from app.downloads import mediakind
|
from app.downloads import mediakind
|
||||||
|
|
||||||
|
log = logs.get_logger(__name__)
|
||||||
|
|
||||||
# Reserve room for the "_id.ext" suffix + parent dirs within the 255-char component limit.
|
# Reserve room for the "_id.ext" suffix + parent dirs within the 255-char component limit.
|
||||||
_MAX_TITLE = 120
|
_MAX_TITLE = 120
|
||||||
# User-facing (Content-Disposition) names can be longer than an on-disk component.
|
# User-facing (Content-Disposition) names can be longer than an on-disk component.
|
||||||
@@ -294,7 +297,13 @@ def write_sidecars(download_root: str, rel: str, meta: MediaMeta, thumb_src: Pat
|
|||||||
if thumb_src and thumb_src.exists():
|
if thumb_src and thumb_src.exists():
|
||||||
shutil.copyfile(str(thumb_src), str(_sidecar(media, ".jpg")))
|
shutil.copyfile(str(thumb_src), str(_sidecar(media, ".jpg")))
|
||||||
return True
|
return True
|
||||||
except OSError:
|
except OSError as exc:
|
||||||
|
# The download itself still completes, so the job reads as done while the library it was
|
||||||
|
# downloaded FOR has no .nfo to read and no poster to show.
|
||||||
|
log.warning(
|
||||||
|
"the sidecars beside a download could not be written",
|
||||||
|
extra=logs.event("download.sidecars.write_failed", reason=type(exc).__name__),
|
||||||
|
)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
@@ -304,14 +313,23 @@ def delete_asset_files(download_root: str, rel: str) -> None:
|
|||||||
for p in (media, _sidecar(media, ".nfo"), _sidecar(media, ".jpg")):
|
for p in (media, _sidecar(media, ".nfo"), _sidecar(media, ".jpg")):
|
||||||
try:
|
try:
|
||||||
p.unlink()
|
p.unlink()
|
||||||
except OSError:
|
except FileNotFoundError:
|
||||||
pass
|
pass # silent-ok: already gone is the outcome this function wanted
|
||||||
|
except OSError as exc:
|
||||||
|
# The row goes and the bytes stay. Nothing else ever revisits this path, so the disk
|
||||||
|
# quietly keeps a file no account has any way to see, let alone delete.
|
||||||
|
log.info(
|
||||||
|
"a deleted download left a file behind",
|
||||||
|
extra=logs.event("download.asset.file_unlink_failed", reason=type(exc).__name__),
|
||||||
|
)
|
||||||
# Prune empty Season/Channel dirs up to (but not including) the root.
|
# Prune empty Season/Channel dirs up to (but not including) the root.
|
||||||
root = Path(download_root).resolve()
|
root = Path(download_root).resolve()
|
||||||
parent = media.parent
|
parent = media.parent
|
||||||
while parent != root and root in parent.parents:
|
while parent != root and root in parent.parents:
|
||||||
try:
|
try:
|
||||||
parent.rmdir() # only succeeds if empty
|
parent.rmdir() # only succeeds if empty
|
||||||
|
# silent-ok: this `rmdir` failing is the loop's stop condition — the parent still
|
||||||
|
# holds another download's files, so the prune has gone as far up as it may.
|
||||||
except OSError:
|
except OSError:
|
||||||
break
|
break
|
||||||
parent = parent.parent
|
parent = parent.parent
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ from sqlalchemy.orm import Session
|
|||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.downloads import edit as editmod
|
from app.downloads import edit as editmod
|
||||||
from app.downloads import mediakind, probe, quota, service, storage
|
from app.downloads import mediakind, probe, quota, service, storage
|
||||||
from app.models import DownloadJob, MediaAsset
|
from app.models import DownloadJob, MediaAsset, display_text
|
||||||
|
|
||||||
|
|
||||||
def _title_from_filename(filename: str) -> str:
|
def _title_from_filename(filename: str) -> str:
|
||||||
@@ -99,7 +99,7 @@ def ingest_upload(
|
|||||||
source_kind="upload",
|
source_kind="upload",
|
||||||
source_ref=ref,
|
source_ref=ref,
|
||||||
profile_snapshot={},
|
profile_snapshot={},
|
||||||
display_name=(display_name or "").strip() or None,
|
display_name=display_text(display_name),
|
||||||
status="done",
|
status="done",
|
||||||
progress=100,
|
progress=100,
|
||||||
queue_pos=service.next_queue_pos(db),
|
queue_pos=service.next_queue_pos(db),
|
||||||
|
|||||||
+15
-7
@@ -11,7 +11,6 @@ to be a person telling them nothing arrived.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import html
|
import html
|
||||||
import logging
|
|
||||||
import re
|
import re
|
||||||
import smtplib
|
import smtplib
|
||||||
import ssl
|
import ssl
|
||||||
@@ -21,15 +20,14 @@ from email.utils import formatdate
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app import mailtext, sysconfig
|
from app import logs, mailtext, sysconfig
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.db import SessionLocal
|
from app.db import SessionLocal
|
||||||
from app.models import User
|
from app.models import User
|
||||||
from app.notifications import create_notification
|
from app.notifications import create_notification
|
||||||
from app.ratelimit import RateLimiter
|
from app.ratelimit import RateLimiter
|
||||||
|
|
||||||
log = logging.getLogger("siftlode.email")
|
log = logs.get_logger(__name__)
|
||||||
|
|
||||||
# One admin notice an hour per reason. A relay that is down fails every send, and the inbox must
|
# One admin notice an hour per reason. A relay that is down fails every send, and the inbox must
|
||||||
# stay readable; the log keeps the full record either way.
|
# stay readable; the log keeps the full record either way.
|
||||||
_alert_throttle = RateLimiter(max_events=1, window_seconds=3600)
|
_alert_throttle = RateLimiter(max_events=1, window_seconds=3600)
|
||||||
@@ -155,7 +153,15 @@ def _send(to: list[str], subject: str, body: str, reply_to: str | None = None) -
|
|||||||
return False
|
return False
|
||||||
c = _smtp()
|
c = _smtp()
|
||||||
if not (c["host"] and c["user"] and c["password"]):
|
if not (c["host"] and c["user"] and c["password"]):
|
||||||
log.info("SMTP not configured; skipping email %r to %s", subject, recipients)
|
# The count, not the addresses: `redact_text` rewrites every one of them to `[email]`
|
||||||
|
# before either sink sees the line, so the list only ever printed as noise. The subject
|
||||||
|
# stays — the templates are static, and the one that interpolates (`admin_new_request`)
|
||||||
|
# interpolates an address, which the same redactor covers.
|
||||||
|
log.info(
|
||||||
|
"SMTP not configured; skipping email %r to %d recipient(s)",
|
||||||
|
subject,
|
||||||
|
len(recipients), # log-pii-ok: how many, not who
|
||||||
|
)
|
||||||
_alert_admins("not_configured", subject, recipients)
|
_alert_admins("not_configured", subject, recipients)
|
||||||
return False
|
return False
|
||||||
msg = EmailMessage()
|
msg = EmailMessage()
|
||||||
@@ -176,10 +182,12 @@ def _send(to: list[str], subject: str, body: str, reply_to: str | None = None) -
|
|||||||
s.starttls(context=ssl.create_default_context())
|
s.starttls(context=ssl.create_default_context())
|
||||||
s.login(c["user"], c["password"])
|
s.login(c["user"], c["password"])
|
||||||
s.send_message(msg)
|
s.send_message(msg)
|
||||||
log.info("Sent email %r to %s", subject, recipients)
|
# log-pii-ok: how many, not who
|
||||||
|
log.info("Sent email %r to %d recipient(s)", subject, len(recipients))
|
||||||
return True
|
return True
|
||||||
except Exception:
|
except Exception:
|
||||||
log.exception("SMTP send failed (%r to %s)", subject, recipients)
|
# log-pii-ok: how many, not who
|
||||||
|
log.exception("SMTP send failed (%r to %d recipient(s))", subject, len(recipients))
|
||||||
_alert_admins("send_failed", subject, recipients)
|
_alert_admins("send_failed", subject, recipients)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ from app.models import (
|
|||||||
Video,
|
Video,
|
||||||
VideoState,
|
VideoState,
|
||||||
)
|
)
|
||||||
from app.utils import iso, to_tsquery_str
|
from app.utils import iso, like_escape, to_tsquery_str
|
||||||
|
|
||||||
|
|
||||||
def _channel_url(channel_id: str, handle: str | None) -> str:
|
def _channel_url(channel_id: str, handle: str | None) -> str:
|
||||||
@@ -272,8 +272,9 @@ def filtered_query(
|
|||||||
# `unaccent(channels.title) ILIKE '%q%'` straight into the per-video predicate defeated the
|
# `unaccent(channels.title) ILIKE '%q%'` straight into the per-video predicate defeated the
|
||||||
# search_vector GIN — the planner detoasted every non-matching tsvector to test the `@@`
|
# search_vector GIN — the planner detoasted every non-matching tsvector to test the `@@`
|
||||||
# (~2.6s/search on the prod-sized set); channel_id.in_(...) keeps the GIN as a BitmapOr branch
|
# (~2.6s/search on the prod-sized set); channel_id.in_(...) keeps the GIN as a BitmapOr branch
|
||||||
# (PERF-12). Empty match -> false() so the OR collapses to the text match alone.
|
# (PERF-12). Empty match -> false() so the OR collapses to the text match alone. The query
|
||||||
like = func.unaccent(f"%{q}%")
|
# is a literal inside the pattern: `_` in `my_channel` is not a wildcard.
|
||||||
|
like = func.unaccent(f"%{like_escape(q)}%")
|
||||||
matching_channel_ids = (
|
matching_channel_ids = (
|
||||||
db.execute(select(Channel.id).where(func.unaccent(Channel.title).ilike(like)))
|
db.execute(select(Channel.id).where(func.unaccent(Channel.title).ilike(like)))
|
||||||
.scalars()
|
.scalars()
|
||||||
|
|||||||
@@ -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 ""
|
||||||
+175
-22
@@ -15,7 +15,7 @@ issue attachment will carry, so it is deliberately flat and stable.
|
|||||||
|
|
||||||
Call sites stay ordinary stdlib logging:
|
Call sites stay ordinary stdlib logging:
|
||||||
|
|
||||||
log = logging.getLogger("siftlode.downloads")
|
log = logs.get_logger(__name__) # app/downloads/pipeline.py -> siftlode.downloads.pipeline
|
||||||
log.info("job finished", extra=logs.event("download.job.done", job_id=7, bytes=n))
|
log.info("job finished", extra=logs.event("download.job.done", job_id=7, bytes=n))
|
||||||
|
|
||||||
`event()` is optional — a bare `log.info("…")` still comes out well-formed — but the dotted code is
|
`event()` is optional — a bare `log.info("…")` still comes out well-formed — but the dotted code is
|
||||||
@@ -37,7 +37,9 @@ import re
|
|||||||
import sys
|
import sys
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
import traceback
|
||||||
import uuid
|
import uuid
|
||||||
|
from collections.abc import Callable
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from urllib.parse import parse_qsl
|
from urllib.parse import parse_qsl
|
||||||
@@ -98,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(
|
||||||
@@ -111,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
|
||||||
@@ -123,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:
|
||||||
@@ -152,11 +170,15 @@ def bind(**fields: Any):
|
|||||||
_CONTEXT_VARS[key].reset(token)
|
_CONTEXT_VARS[key].reset(token)
|
||||||
|
|
||||||
|
|
||||||
def event(code: str, **attrs: Any) -> dict[str, Any]:
|
def event(code: str, /, **attrs: Any) -> dict[str, Any]:
|
||||||
"""`extra=` payload for a log call: a stable dotted event code plus structured attributes.
|
"""`extra=` payload for a log call: a stable dotted event code plus structured attributes.
|
||||||
|
|
||||||
Returns a plain dict so it composes with stdlib logging and costs nothing when the level is off
|
Returns a plain dict so it composes with stdlib logging and costs nothing when the level is off
|
||||||
(the dict is built either way; keep the attrs cheap — no DB reads inside the call)."""
|
(the dict is built either way; keep the attrs cheap — no DB reads inside the call).
|
||||||
|
|
||||||
|
`code` is positional-only so that no attribute name is reserved: `event("x", code=...)` means
|
||||||
|
the attribute, not this parameter. Without the `/` it was a TypeError, and `code` is exactly
|
||||||
|
the word a caller reaches for when the thing that failed has one of its own."""
|
||||||
return {"event": code, "attrs": attrs}
|
return {"event": code, "attrs": attrs}
|
||||||
|
|
||||||
|
|
||||||
@@ -259,11 +281,53 @@ def redact(value: Any, *, key: str = "") -> Any:
|
|||||||
|
|
||||||
|
|
||||||
# --- formatters ----------------------------------------------------------------------------
|
# --- formatters ----------------------------------------------------------------------------
|
||||||
|
_SCRUBBED_STACK = "scrubbed_stack"
|
||||||
|
|
||||||
|
|
||||||
|
def scrubbed_stack(exc: BaseException, scrub: Callable[[BaseException], str]) -> dict[str, str]:
|
||||||
|
"""`extra=` fragment: `exc`'s traceback, rendered where an `exc_info` stack goes, with the
|
||||||
|
message of every exception in it put through `scrub`.
|
||||||
|
|
||||||
|
For a failure whose message can name someone's data (a download's URL, a file path, a video
|
||||||
|
id). The frames name code and are kept whole; the text at the foot of each link is the data.
|
||||||
|
A plain `exc_info` renders the chain too — the `__cause__`/`__context__` a DB error raised
|
||||||
|
inside `except DownloadError` carries — so scrubbing only the line's own message is not
|
||||||
|
enough. Merge it into the call's `extra`: `{**event(...), **scrubbed_stack(...)}`."""
|
||||||
|
links: list[BaseException] = []
|
||||||
|
link: BaseException | None = exc
|
||||||
|
while link is not None and all(link is not seen for seen in links):
|
||||||
|
links.append(link)
|
||||||
|
link = link.__cause__ or (None if link.__suppress_context__ else link.__context__)
|
||||||
|
parts: list[str] = []
|
||||||
|
for i in range(len(links) - 1, -1, -1): # the oldest first, as Python prints a chain
|
||||||
|
link = links[i]
|
||||||
|
kind = type(link)
|
||||||
|
name = kind.__qualname__
|
||||||
|
if kind.__module__ not in ("builtins", "__main__"):
|
||||||
|
name = f"{kind.__module__}.{name}"
|
||||||
|
try:
|
||||||
|
message = scrub(link)
|
||||||
|
# silent-ok: the message is what could not be rendered, and the placeholder says so — the
|
||||||
|
# caller is in the middle of reporting one failure and must not get a second from here.
|
||||||
|
except Exception: # noqa: BLE001 — a `__str__` that raises, of any shape
|
||||||
|
message = "<unprintable>"
|
||||||
|
frames = "".join(traceback.format_tb(link.__traceback__))
|
||||||
|
head = f"Traceback (most recent call last):\n{frames}" if frames else ""
|
||||||
|
parts.append(f"{head}{name}: {message}" if message else f"{head}{name}")
|
||||||
|
if i:
|
||||||
|
parts.append(
|
||||||
|
"\nThe above exception was the direct cause of the following exception:\n"
|
||||||
|
if links[i - 1].__cause__ is link
|
||||||
|
else "\nDuring handling of the above exception, another exception occurred:\n"
|
||||||
|
)
|
||||||
|
return {_SCRUBBED_STACK: "\n".join(parts)}
|
||||||
|
|
||||||
|
|
||||||
# Attributes every LogRecord carries; anything else a caller passed via `extra=` is a structured
|
# Attributes every LogRecord carries; anything else a caller passed via `extra=` is a structured
|
||||||
# attribute and is merged into `attrs` (so `extra={"job_id": 7}` works as well as `event(...)`).
|
# attribute and is merged into `attrs` (so `extra={"job_id": 7}` works as well as `event(...)`).
|
||||||
_RECORD_STANDARD = frozenset(
|
_RECORD_STANDARD = frozenset(
|
||||||
vars(logging.LogRecord("", 0, "", 0, "", None, None)).keys()
|
vars(logging.LogRecord("", 0, "", 0, "", None, None)).keys()
|
||||||
| {"message", "asctime", "taskName", "event", "attrs", PERSIST, "elevated"}
|
| {"message", "asctime", "taskName", "event", "attrs", PERSIST, "elevated", _SCRUBBED_STACK}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -280,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
|
||||||
|
|
||||||
|
|
||||||
@@ -314,6 +384,8 @@ def _safe_message(record: logging.LogRecord) -> str:
|
|||||||
must not take down the caller, so the failure is reported IN the line instead."""
|
must not take down the caller, so the failure is reported IN the line instead."""
|
||||||
try:
|
try:
|
||||||
return record.getMessage()
|
return record.getMessage()
|
||||||
|
# silent-ok: the failure is reported IN the returned line. Logging it would re-enter this same
|
||||||
|
# formatter with a record built out of the one that just failed.
|
||||||
except Exception as exc: # noqa: BLE001 — any formatting error, reported not raised
|
except Exception as exc: # noqa: BLE001 — any formatting error, reported not raised
|
||||||
return f"{record.msg!r} (log formatting failed: {exc})"
|
return f"{record.msg!r} (log formatting failed: {exc})"
|
||||||
|
|
||||||
@@ -342,6 +414,8 @@ def record_fields(record: logging.LogRecord) -> dict[str, Any]:
|
|||||||
fields["attrs"] = redact(attrs)
|
fields["attrs"] = redact(attrs)
|
||||||
if record.exc_info:
|
if record.exc_info:
|
||||||
fields["exc"] = redact_text(logging.Formatter().formatException(record.exc_info))
|
fields["exc"] = redact_text(logging.Formatter().formatException(record.exc_info))
|
||||||
|
elif getattr(record, _SCRUBBED_STACK, None):
|
||||||
|
fields["exc"] = redact_text(getattr(record, _SCRUBBED_STACK))
|
||||||
elif record.exc_text:
|
elif record.exc_text:
|
||||||
# A QueueHandler-style hand-off formats the traceback into exc_text and drops exc_info.
|
# A QueueHandler-style hand-off formats the traceback into exc_text and drops exc_info.
|
||||||
fields["exc"] = redact_text(record.exc_text)
|
fields["exc"] = redact_text(record.exc_text)
|
||||||
@@ -375,6 +449,8 @@ class TextFormatter(logging.Formatter):
|
|||||||
line += f" {json.dumps(redact(attrs), ensure_ascii=False, default=str)}"
|
line += f" {json.dumps(redact(attrs), ensure_ascii=False, default=str)}"
|
||||||
if record.exc_info:
|
if record.exc_info:
|
||||||
line += "\n" + redact_text(self.formatException(record.exc_info))
|
line += "\n" + redact_text(self.formatException(record.exc_info))
|
||||||
|
elif getattr(record, _SCRUBBED_STACK, None):
|
||||||
|
line += "\n" + redact_text(getattr(record, _SCRUBBED_STACK))
|
||||||
return line
|
return line
|
||||||
|
|
||||||
|
|
||||||
@@ -390,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 —
|
||||||
@@ -401,6 +476,28 @@ _THIRD_PARTY_LEVELS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_logger(name: str) -> logging.Logger:
|
||||||
|
"""The logger for one module: `logs.get_logger(__name__)` in `app/plex/sync.py` is
|
||||||
|
`siftlode.plex.sync`.
|
||||||
|
|
||||||
|
The names were literals before, and 25 of them covered 34 modules — six different Plex modules
|
||||||
|
all called themselves `siftlode.plex`, so neither a level pinned per logger nor E22.6's console
|
||||||
|
filter could tell a sync line from a stream line. Derived from `__name__` there is exactly one
|
||||||
|
name per module, it cannot drift from the file it sits in, and `test_logger_names.py` fails if a
|
||||||
|
module hand-rolls one again.
|
||||||
|
|
||||||
|
`__main__` is resolved to the module it really is. The worker runs as `python -m app.worker`
|
||||||
|
and spawns `python -m app.downloads.runner`, so in the two long-running processes `__name__`
|
||||||
|
is `__main__` — taken at its word, both would log as the bare root. `-m` records the real name
|
||||||
|
on the module's spec; a plain script has none and stays the root."""
|
||||||
|
if name == "__main__":
|
||||||
|
spec = getattr(sys.modules.get("__main__"), "__spec__", None)
|
||||||
|
name = getattr(spec, "name", None) or name
|
||||||
|
stem = name.removeprefix("app.") if name.startswith("app.") else ""
|
||||||
|
# logger-name-ok: this call IS the derivation
|
||||||
|
return logging.getLogger(f"siftlode.{stem}" if stem else "siftlode")
|
||||||
|
|
||||||
|
|
||||||
def resolve_level(name: str) -> int:
|
def resolve_level(name: str) -> int:
|
||||||
"""Level name → number. Both ways in (the `LOG_LEVEL` env and the `log_level` admin knob) are
|
"""Level name → number. Both ways in (the `LOG_LEVEL` env and the `log_level` admin knob) are
|
||||||
already held to the `LogLevel` choices, so an unknown name here is a programming error."""
|
already held to the `LogLevel` choices, so an unknown name here is a programming error."""
|
||||||
@@ -469,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()
|
||||||
@@ -497,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)
|
||||||
@@ -535,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
|
||||||
@@ -562,11 +673,37 @@ def current_source() -> str:
|
|||||||
_REQUEST_ID_RE = re.compile(r"^[A-Za-z0-9._-]{8,64}$")
|
_REQUEST_ID_RE = re.compile(r"^[A-Za-z0-9._-]{8,64}$")
|
||||||
|
|
||||||
# Paths whose success is logged at DEBUG rather than INFO: the container healthcheck runs every 15s
|
# Paths whose success is logged at DEBUG rather than INFO: the container healthcheck runs every 15s
|
||||||
# (5.7k lines/day saying nothing) and hashed static assets are served by the hundred per page load.
|
# (5.7k lines/day saying nothing), hashed static assets are served by the hundred per page load, and
|
||||||
# A FAILURE on either still logs at WARNING/ERROR — this lowers the floor, it does not hide.
|
# the admin log console reads the stored log — its live tail every few seconds, so at INFO the log
|
||||||
_QUIET_PREFIXES = ("/healthz", "/assets/", "/static/")
|
# would fill with the console watching itself. A FAILURE on any of them still logs at WARNING/ERROR
|
||||||
|
# — this lowers the floor, it does not hide.
|
||||||
|
_QUIET_PREFIXES = ("/healthz", "/assets/", "/static/", "/api/admin/logs")
|
||||||
|
|
||||||
_request_log = logging.getLogger("siftlode.http")
|
# The reads the frontend POLLS (a `useLiveQuery`, the `me` heartbeat, the sync status, the subtitle
|
||||||
|
# progress), by route template, GET only — logged at DEBUG like the prefixes above. One open tab asked
|
||||||
|
# for the two unread counts, `/api/me` and the download index every 30 s, and at INFO those were ~95%
|
||||||
|
# of the stored log (measured on prod 2026-09-19): 11k lines a day per tab burying every line someone
|
||||||
|
# caused. The same route answered with a failure still logs at WARNING/ERROR.
|
||||||
|
_POLLED_ROUTES = frozenset(
|
||||||
|
{
|
||||||
|
"/api/me",
|
||||||
|
"/api/me/notifications/unread_count",
|
||||||
|
"/api/messages/unread_count",
|
||||||
|
"/api/downloads",
|
||||||
|
"/api/downloads/index",
|
||||||
|
"/api/downloads/usage",
|
||||||
|
"/api/sync/my-status",
|
||||||
|
"/api/admin/scheduler",
|
||||||
|
"/api/admin/downloads",
|
||||||
|
"/api/admin/downloads/storage",
|
||||||
|
"/api/plex/subtitle/{rating_key}/{sub_ord}/status",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Not `siftlode.logs`: this is the replacement for uvicorn's ACCESS log, and `siftlode.http` is
|
||||||
|
# what an operator pins a level on when they want request lines and nothing else. The module it
|
||||||
|
# happens to live in is not what the name is about.
|
||||||
|
_request_log = logging.getLogger("siftlode.http") # logger-name-ok: named for the traffic
|
||||||
|
|
||||||
|
|
||||||
async def log_requests(request, call_next):
|
async def log_requests(request, call_next):
|
||||||
@@ -608,7 +745,9 @@ async def log_requests(request, call_next):
|
|||||||
raise
|
raise
|
||||||
duration_ms = round((time.perf_counter() - started) * 1000, 1)
|
duration_ms = round((time.perf_counter() - started) * 1000, 1)
|
||||||
status = response.status_code
|
status = response.status_code
|
||||||
quiet = request.url.path.startswith(_QUIET_PREFIXES)
|
quiet = request.url.path.startswith(_QUIET_PREFIXES) or (
|
||||||
|
request.method == "GET" and _route_template(request) in _POLLED_ROUTES
|
||||||
|
)
|
||||||
if status >= 500:
|
if status >= 500:
|
||||||
level = logging.ERROR
|
level = logging.ERROR
|
||||||
elif status >= 400:
|
elif status >= 400:
|
||||||
@@ -634,6 +773,10 @@ async def log_requests(request, call_next):
|
|||||||
extra={**event("http.request", **attrs), **_request_owner(request, level)},
|
extra={**event("http.request", **attrs), **_request_owner(request, level)},
|
||||||
)
|
)
|
||||||
response.headers["X-Request-Id"] = rid
|
response.headers["X-Request-Id"] = rid
|
||||||
|
# The browser logs its own copy of this line (frontend lib/http.ts) and must pick the same level:
|
||||||
|
# it learns "this one is quiet" here instead of keeping a second copy of the polled-route list.
|
||||||
|
if level == logging.DEBUG:
|
||||||
|
response.headers["X-Log-Quiet"] = "1"
|
||||||
# The template, so the browser's copy of this line groups the same way (frontend lib/http.ts).
|
# The template, so the browser's copy of this line groups the same way (frontend lib/http.ts).
|
||||||
# Only a MATCHED route's: a template never carries a path parameter's value (a share token stays
|
# Only a MATCHED route's: a template never carries a path parameter's value (a share token stays
|
||||||
# `{token}`), while the raw-path fallback of an unmatched request could — the browser then
|
# `{token}`), while the raw-path fallback of an unmatched request could — the browser then
|
||||||
@@ -646,6 +789,16 @@ async def log_requests(request, call_next):
|
|||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
def consented_trace() -> bool:
|
||||||
|
"""Whether the current request's OWN user turned Teljes (TRACE) on — the gate for a line that
|
||||||
|
carries what they typed or what was quoted back to them. The level alone is not enough: an
|
||||||
|
admin running the whole process at TRACE would pass it for every user, and a scheduler thread,
|
||||||
|
which has no request, for none of them in particular. Guard such a line with this, then log it
|
||||||
|
at TRACE."""
|
||||||
|
scope = _scope.get()
|
||||||
|
return scope is not None and _traced(scope)
|
||||||
|
|
||||||
|
|
||||||
def _traced(scope: _RequestScope) -> bool:
|
def _traced(scope: _RequestScope) -> bool:
|
||||||
"""Whether this request's query string is logged: only under its OWN user's Teljes consent. An
|
"""Whether this request's query string is logged: only under its OWN user's Teljes consent. An
|
||||||
admin setting the whole process to TRACE gets more detail, not every user's search terms — the
|
admin setting the whole process to TRACE gets more detail, not every user's search terms — the
|
||||||
|
|||||||
+62
-10
@@ -29,6 +29,7 @@ import atexit
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import queue
|
import queue
|
||||||
|
import re
|
||||||
import sys
|
import sys
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
@@ -43,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
|
||||||
@@ -66,7 +68,7 @@ _LIMITS = {
|
|||||||
"exc": 32_000,
|
"exc": 32_000,
|
||||||
}
|
}
|
||||||
|
|
||||||
_log = logging.getLogger("siftlode.logsink")
|
_log = logs.get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def should_store(record: logging.LogRecord) -> bool:
|
def should_store(record: logging.LogRecord) -> bool:
|
||||||
@@ -137,6 +139,21 @@ def _notice(code: str, msg: str, **attrs: Any) -> dict[str, Any]:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_DIGITS = re.compile(r"\d+")
|
||||||
|
|
||||||
|
|
||||||
|
def _template(record: logging.LogRecord) -> str:
|
||||||
|
"""The flood limiter's grouping key: the message template. Except when the template is a bare
|
||||||
|
`%s` — a pass-through for someone else's text (yt-dlp's lines, `formats._YdlLog`), where the
|
||||||
|
template says nothing and every distinct line would share one bucket. Then the text itself
|
||||||
|
groups, with its numbers folded, so `Retrying fragment 5 (1/10)` and `(2/10)` are still one
|
||||||
|
repeat and a different warning is not crowded out by them."""
|
||||||
|
msg = str(record.msg)
|
||||||
|
if msg != "%s" or not record.args:
|
||||||
|
return msg
|
||||||
|
return _DIGITS.sub("#", record.getMessage())[:200]
|
||||||
|
|
||||||
|
|
||||||
class _Repeats:
|
class _Repeats:
|
||||||
"""Per-template admission for one minute at a time. Used by the writer thread only (unlocked)."""
|
"""Per-template admission for one minute at a time. Used by the writer thread only (unlocked)."""
|
||||||
|
|
||||||
@@ -155,9 +172,11 @@ class _Repeats:
|
|||||||
self.seen[key] = (count or 0) + 1
|
self.seen[key] = (count or 0) + 1
|
||||||
return self.seen[key] <= self.per_window
|
return self.seen[key] <= self.per_window
|
||||||
|
|
||||||
def roll(self, now: float) -> list[dict[str, Any]]:
|
def roll(self, now: float, *, final: bool = False) -> list[dict[str, Any]]:
|
||||||
"""Close the window if it is over: one notice per template that went past the limit."""
|
"""Close the window if it is over — or, `final`, because the process is: one notice per
|
||||||
if now - self.started < self.window:
|
template that went past the limit. Without `final` a process that lives under a minute
|
||||||
|
(every download child) would drop its repeats and never say so."""
|
||||||
|
if not final and now - self.started < self.window:
|
||||||
return []
|
return []
|
||||||
notices = [
|
notices = [
|
||||||
_notice(
|
_notice(
|
||||||
@@ -202,6 +221,8 @@ class LogSink(logging.Handler):
|
|||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
row = to_row(logs.record_fields(record))
|
row = to_row(logs.record_fields(record))
|
||||||
|
# silent-ok: `handleError` is the report — logging's own out-of-band channel to stderr.
|
||||||
|
# A log call here would be handed to this handler, which is the one that just failed.
|
||||||
except Exception: # noqa: BLE001 — a record we cannot turn into a row is reported, not raised
|
except Exception: # noqa: BLE001 — a record we cannot turn into a row is reported, not raised
|
||||||
self.handleError(record)
|
self.handleError(record)
|
||||||
return
|
return
|
||||||
@@ -209,21 +230,25 @@ class LogSink(logging.Handler):
|
|||||||
key = (
|
key = (
|
||||||
None
|
None
|
||||||
if row["event"] == "http.request"
|
if row["event"] == "http.request"
|
||||||
else (record.name, str(record.msg), record.levelno)
|
else (record.name, _template(record), record.levelno)
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
self._queue.put_nowait((key, row))
|
self._queue.put_nowait((key, row))
|
||||||
|
# silent-ok: counted, not logged — the count goes out as the `log.sink.dropped` notice row
|
||||||
|
# the writer emits, which is the one report that does not feed this queue again.
|
||||||
except queue.Full:
|
except queue.Full:
|
||||||
with self._dropped_lock:
|
with self._dropped_lock:
|
||||||
self._dropped += 1
|
self._dropped += 1
|
||||||
|
|
||||||
# --- writer side ---------------------------------------------------------------------------
|
# --- writer side ---------------------------------------------------------------------------
|
||||||
def write_pending(self, wait: float = 0.0) -> int:
|
def write_pending(self, wait: float = 0.0, *, final: bool = False) -> int:
|
||||||
"""Write one batch: whatever is queued (up to BATCH_MAX), waiting up to `wait` seconds for the
|
"""Write one batch: whatever is queued (up to BATCH_MAX), waiting up to `wait` seconds for the
|
||||||
first item. Returns the number of rows written, notices included."""
|
first item. Returns the number of rows written, notices included. `final` closes the
|
||||||
|
repeat window early — the last batch before the process exits."""
|
||||||
rows: list[dict[str, Any]] = []
|
rows: list[dict[str, Any]] = []
|
||||||
try:
|
try:
|
||||||
item = self._queue.get(timeout=wait) if wait > 0 else self._queue.get_nowait()
|
item = self._queue.get(timeout=wait) if wait > 0 else self._queue.get_nowait()
|
||||||
|
# silent-ok: an idle sink. Nothing failed; there was simply nothing waiting.
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
item = None
|
item = None
|
||||||
while item is not None:
|
while item is not None:
|
||||||
@@ -234,9 +259,10 @@ class LogSink(logging.Handler):
|
|||||||
break
|
break
|
||||||
try:
|
try:
|
||||||
item = self._queue.get_nowait()
|
item = self._queue.get_nowait()
|
||||||
|
# silent-ok: the batch ends here because the queue ran dry, which is the normal end.
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
item = None
|
item = None
|
||||||
rows.extend(self._repeats.roll(time.monotonic()))
|
rows.extend(self._repeats.roll(time.monotonic(), final=final))
|
||||||
with self._dropped_lock:
|
with self._dropped_lock:
|
||||||
dropped, self._dropped = self._dropped, 0
|
dropped, self._dropped = self._dropped, 0
|
||||||
if dropped:
|
if dropped:
|
||||||
@@ -254,8 +280,12 @@ class LogSink(logging.Handler):
|
|||||||
with self._bind.connect() as conn, logs.erasure_guard(), conn.begin():
|
with self._bind.connect() as conn, logs.erasure_guard(), conn.begin():
|
||||||
conn.execute(insert(LogEvent), without_erased(rows))
|
conn.execute(insert(LogEvent), without_erased(rows))
|
||||||
except Exception as exc: # noqa: BLE001 — the database is down or refused the batch
|
except Exception as exc: # noqa: BLE001 — the database is down or refused the batch
|
||||||
print(
|
# silent-ok / noqa T201: the sink's OWN failure. A log call here would be handed to
|
||||||
f"log sink: could not store {len(rows)} log events: {logs.redact_text(str(exc))[:300]}",
|
# this sink, which is the thing that just failed — straight into a loop. stderr is
|
||||||
|
# the one way out that does not re-enter.
|
||||||
|
print( # noqa: T201
|
||||||
|
f"log sink: could not store {len(rows)} log events: "
|
||||||
|
f"{logs.redact_text(str(exc))[:300]}",
|
||||||
file=sys.stderr,
|
file=sys.stderr,
|
||||||
)
|
)
|
||||||
return 0
|
return 0
|
||||||
@@ -266,6 +296,7 @@ class LogSink(logging.Handler):
|
|||||||
self.write_pending(wait=FLUSH_SECONDS)
|
self.write_pending(wait=FLUSH_SECONDS)
|
||||||
while self.write_pending(): # drain what arrived before the stop
|
while self.write_pending(): # drain what arrived before the stop
|
||||||
pass
|
pass
|
||||||
|
self.write_pending(final=True) # and the repeat notices of a window cut short
|
||||||
|
|
||||||
def start(self) -> None:
|
def start(self) -> None:
|
||||||
self._writer = threading.Thread(target=self._run, name="log-sink", daemon=True)
|
self._writer = threading.Thread(target=self._run, name="log-sink", daemon=True)
|
||||||
@@ -313,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,
|
||||||
|
}
|
||||||
|
|||||||
+42
-17
@@ -1,5 +1,4 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
|
||||||
import math
|
import math
|
||||||
from contextlib import asynccontextmanager, suppress
|
from contextlib import asynccontextmanager, suppress
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -12,17 +11,19 @@ from fastapi.exceptions import RequestValidationError
|
|||||||
# import time is already covered. ONE format for the api, the worker, the download child and alembic
|
# import time is already covered. ONE format for the api, the worker, the download child and alembic
|
||||||
# alike (app/logs.py) — this replaces the old uvicorn --log-config + the hand-rolled fallback that
|
# alike (app/logs.py) — this replaces the old uvicorn --log-config + the hand-rolled fallback that
|
||||||
# used to live here, and uvicorn's access log is replaced by the `http.request` middleware below.
|
# used to live here, and uvicorn's access log is replaced by the `http.request` middleware below.
|
||||||
|
from app import logs
|
||||||
from app.logs import configure_logging, current_level, log_requests, set_level
|
from app.logs import configure_logging, current_level, log_requests, set_level
|
||||||
|
|
||||||
configure_logging("api")
|
configure_logging("api")
|
||||||
|
|
||||||
log = logging.getLogger("siftlode.app")
|
log = logs.get_logger(__name__)
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
|
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from sqlalchemy.exc import DataError
|
from sqlalchemy.exc import DataError
|
||||||
from starlette.middleware.sessions import SessionMiddleware
|
from starlette.middleware.sessions import SessionMiddleware
|
||||||
|
|
||||||
|
from app import 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
|
||||||
@@ -32,10 +33,12 @@ from app.plex import stream as plex_stream
|
|||||||
from app.plex import subtitles as plex_subtitles
|
from app.plex import subtitles as plex_subtitles
|
||||||
from app.routes import (
|
from app.routes import (
|
||||||
admin,
|
admin,
|
||||||
|
admin_logs as admin_logs_routes,
|
||||||
audit as audit_routes,
|
audit as audit_routes,
|
||||||
auth as auth_routes,
|
auth as auth_routes,
|
||||||
channels,
|
channels,
|
||||||
config as config_routes,
|
config as config_routes,
|
||||||
|
debug_sessions as debug_sessions_routes,
|
||||||
diagnostics as diagnostics_routes,
|
diagnostics as diagnostics_routes,
|
||||||
discover as discover_routes,
|
discover as discover_routes,
|
||||||
downloads,
|
downloads,
|
||||||
@@ -73,17 +76,17 @@ async def lifespan(app: FastAPI):
|
|||||||
# booting — an unreachable or not-yet-migrated database has to start into the env level.
|
# booting — an unreachable or not-yet-migrated database has to start into the env level.
|
||||||
try:
|
try:
|
||||||
set_level(sysconfig.get_str(db, "log_level"))
|
set_level(sysconfig.get_str(db, "log_level"))
|
||||||
except Exception as exc: # noqa: BLE001 — any DB/config failure keeps the bootstrap level
|
except Exception: # noqa: BLE001 — any DB/config failure keeps the bootstrap level
|
||||||
log.warning(
|
# With the traceback: this catch spans a connection, a query and a coercion at boot,
|
||||||
"could not read the stored log level (%s); staying at %s", exc, current_level()
|
# and `%s` of the exception does not say which of them raised.
|
||||||
)
|
log.exception("could not read the stored log level; staying at %s", current_level())
|
||||||
if not state.is_configured(db):
|
if not state.is_configured(db):
|
||||||
token = state.rotate_setup_token(db)
|
token = state.rotate_setup_token(db)
|
||||||
url = f"{settings.app_base}/setup?token={token}"
|
url = f"{settings.app_base}/setup?token={token}"
|
||||||
log.warning(
|
log.warning(
|
||||||
"FIRST-RUN SETUP REQUIRED — this instance isn't configured yet.\n"
|
"FIRST-RUN SETUP REQUIRED — this instance isn't configured yet.\n"
|
||||||
" Open the install wizard (internal access only):\n %s",
|
" Open the install wizard (internal access only):\n %s",
|
||||||
url,
|
url, # log-pii-ok: this instance's OWN setup URL, and `?token=` is redacted
|
||||||
)
|
)
|
||||||
# No HLS session survives a restart (they live in this process), so anything still on disk is
|
# No HLS session survives a restart (they live in this process), so anything still on disk is
|
||||||
# a crashed run's segments — sweep them or they accumulate forever. FIRE AND FORGET, not
|
# a crashed run's segments — sweep them or they accumulate forever. FIRE AND FORGET, not
|
||||||
@@ -187,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")
|
||||||
@@ -215,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
|
||||||
@@ -257,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)
|
||||||
@@ -272,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)
|
||||||
@@ -285,6 +309,7 @@ app.include_router(admin.router)
|
|||||||
app.include_router(config_routes.router)
|
app.include_router(config_routes.router)
|
||||||
app.include_router(scheduler_routes.router)
|
app.include_router(scheduler_routes.router)
|
||||||
app.include_router(audit_routes.router)
|
app.include_router(audit_routes.router)
|
||||||
|
app.include_router(admin_logs_routes.router)
|
||||||
app.include_router(quota.router)
|
app.include_router(quota.router)
|
||||||
app.include_router(version.router)
|
app.include_router(version.router)
|
||||||
app.include_router(setup_routes.router)
|
app.include_router(setup_routes.router)
|
||||||
|
|||||||
+90
-4
@@ -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
|
||||||
@@ -241,6 +270,9 @@ class Channel(Base, TimestampMixin):
|
|||||||
details_synced_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
details_synced_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
recent_synced_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
recent_synced_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
last_rss_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
last_rss_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
# When the newest uploads were last read through the Data API because the RSS feed would not
|
||||||
|
# answer (`sync/runner.py` `run_feed_fallback`): the once-per-`rss_fallback_hours` clock.
|
||||||
|
feed_fallback_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
backfill_cursor: Mapped[str | None] = mapped_column(String(128))
|
backfill_cursor: Mapped[str | None] = mapped_column(String(128))
|
||||||
backfill_done: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
backfill_done: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||||
# True when this channel only entered the catalog because a live YouTube search
|
# True when this channel only entered the catalog because a live YouTube search
|
||||||
@@ -562,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)
|
||||||
@@ -581,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)."""
|
||||||
|
|
||||||
@@ -895,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(
|
||||||
|
|||||||
@@ -6,16 +6,15 @@ metadata, image proxying, and (P5) optional watch-state write-back. Requests JSO
|
|||||||
``Accept: application/json`` header (Plex serves XML by default).
|
``Accept: application/json`` header (Plex serves XML by default).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app import sysconfig
|
from app import logs, sysconfig
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
|
||||||
log = logging.getLogger("siftlode.plex")
|
log = logs.get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class PlexError(Exception):
|
class PlexError(Exception):
|
||||||
@@ -106,7 +105,18 @@ class PlexClient:
|
|||||||
Best-effort (needs the library's related data) — returns [] if Plex has none."""
|
Best-effort (needs the library's related data) — returns [] if Plex has none."""
|
||||||
try:
|
try:
|
||||||
mc = self._get(f"/library/metadata/{rating_key}/related")
|
mc = self._get(f"/library/metadata/{rating_key}/related")
|
||||||
except PlexError:
|
except PlexError as exc:
|
||||||
|
# An empty list is also the answer for an item that genuinely has no related titles,
|
||||||
|
# so the caller cannot tell the two apart — and it deliberately declines to CACHE an
|
||||||
|
# empty one for exactly that reason. This line is what says which it was.
|
||||||
|
log.debug(
|
||||||
|
"the related items for a Plex item could not be read",
|
||||||
|
extra=logs.event(
|
||||||
|
"plex.client.related_unavailable",
|
||||||
|
rating_key=rating_key,
|
||||||
|
reason=type(exc).__name__,
|
||||||
|
),
|
||||||
|
)
|
||||||
return []
|
return []
|
||||||
out: list[dict] = []
|
out: list[dict] = []
|
||||||
for hub in mc.get("Hub", []) or []:
|
for hub in mc.get("Hub", []) or []:
|
||||||
@@ -139,6 +149,8 @@ class PlexClient:
|
|||||||
raise PlexError(str(e)) from e
|
raise PlexError(str(e)) from e
|
||||||
try:
|
try:
|
||||||
return (r.json() or {}).get("MediaContainer", {}) or {} if r.content else {}
|
return (r.json() or {}).get("MediaContainer", {}) or {} if r.content else {}
|
||||||
|
# silent-ok: the HTTP call already succeeded — this is a write endpoint, and Plex answers
|
||||||
|
# those with an empty or non-JSON body as a matter of course.
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ def rich_detail(meta: dict) -> dict:
|
|||||||
if str(r.get("image") or "").startswith("imdb://"):
|
if str(r.get("image") or "").startswith("imdb://"):
|
||||||
try:
|
try:
|
||||||
imdb_rating = round(float(r.get("value")), 1)
|
imdb_rating = round(float(r.get("value")), 1)
|
||||||
|
# silent-ok: no IMDb score, which is the common and legitimate case — the UI has a
|
||||||
|
# separate audience score to fall back on and shows nothing if neither exists.
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
pass
|
pass
|
||||||
break
|
break
|
||||||
|
|||||||
@@ -11,10 +11,13 @@ The cache directory (``cache.CACHE_DIR``) is shared with the subtitle cache (see
|
|||||||
from fastapi import Response
|
from fastapi import Response
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app import logs
|
||||||
from app.models import PlexCollection, PlexItem, PlexSeason, PlexShow
|
from app.models import PlexCollection, PlexItem, PlexSeason, PlexShow
|
||||||
from app.plex.cache import CACHE_DIR
|
from app.plex.cache import CACHE_DIR
|
||||||
from app.plex.lookups import by_rating_key
|
from app.plex.lookups import by_rating_key
|
||||||
|
|
||||||
|
log = logs.get_logger(__name__)
|
||||||
|
|
||||||
_EXT_CT = [("jpg", "image/jpeg"), ("png", "image/png"), ("webp", "image/webp")]
|
_EXT_CT = [("jpg", "image/jpeg"), ("png", "image/png"), ("webp", "image/webp")]
|
||||||
_CT_EXT = {ct: ext for ext, ct in _EXT_CT}
|
_CT_EXT = {ct: ext for ext, ct in _EXT_CT}
|
||||||
# Target box per variant — Plex resizes to these server-side. Posters fill ≤176px cells (grid/info/
|
# Target box per variant — Plex resizes to these server-side. Posters fill ≤176px cells (grid/info/
|
||||||
@@ -46,6 +49,8 @@ def read(key: str) -> tuple[bytes, str] | None:
|
|||||||
try:
|
try:
|
||||||
if p.exists() and p.stat().st_size > 0:
|
if p.exists() and p.stat().st_size > 0:
|
||||||
return p.read_bytes(), ct
|
return p.read_bytes(), ct
|
||||||
|
# silent-ok: a miss. The caller fetches from Plex and writes the file again, and the write
|
||||||
|
# path below is the one that reports a cache that cannot be used.
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
return None
|
return None
|
||||||
@@ -58,8 +63,14 @@ def write(key: str, data: bytes, ct: str) -> None:
|
|||||||
tmp = CACHE_DIR / f"{key}.{ext}.tmp"
|
tmp = CACHE_DIR / f"{key}.{ext}.tmp"
|
||||||
tmp.write_bytes(data)
|
tmp.write_bytes(data)
|
||||||
tmp.replace(CACHE_DIR / f"{key}.{ext}") # atomic publish
|
tmp.replace(CACHE_DIR / f"{key}.{ext}") # atomic publish
|
||||||
except OSError:
|
except OSError as exc:
|
||||||
pass # cache is best-effort
|
# Best-effort, and silently so: with an unwritable cache every poster on every grid goes
|
||||||
|
# to Plex live, for as long as nobody notices. Debug, because a grid is ~100 images and
|
||||||
|
# the point is the condition, not the count.
|
||||||
|
log.debug(
|
||||||
|
"the Plex image cache could not be written",
|
||||||
|
extra=logs.event("plex.imgcache.write_failed", reason=type(exc).__name__),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def response(data: bytes, ct: str) -> Response:
|
def response(data: bytes, ct: str) -> Response:
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ from pathlib import Path
|
|||||||
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app import sysconfig
|
from app import logs, sysconfig
|
||||||
|
|
||||||
|
log = logs.get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def parse_map(raw: str) -> list[tuple[str, str]]:
|
def parse_map(raw: str) -> list[tuple[str, str]]:
|
||||||
@@ -47,6 +49,13 @@ def local_media_path(db: Session, plex_path: str | None) -> Path | None:
|
|||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
p = Path(map_path(db, plex_path)).resolve()
|
p = Path(map_path(db, plex_path)).resolve()
|
||||||
except OSError:
|
except OSError as exc:
|
||||||
|
# None is answered as a 404 "no local file", which is also the answer for a title the
|
||||||
|
# server does not hold — so a dead mount or a wrong path map reads as missing media for
|
||||||
|
# the whole library at once. The path itself stays out: it carries the title.
|
||||||
|
log.warning(
|
||||||
|
"a Plex media path could not be resolved locally",
|
||||||
|
extra=logs.event("plex.paths.resolve_failed", reason=type(exc).__name__),
|
||||||
|
)
|
||||||
return None
|
return None
|
||||||
return p if p.is_file() else None
|
return p if p.is_file() else None
|
||||||
|
|||||||
@@ -298,10 +298,6 @@ class LibraryFilters:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def like_escape(s: str) -> str:
|
|
||||||
return s.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
|
||||||
|
|
||||||
|
|
||||||
# --- Watch-state scope (shared by the /library grid AND the /facets aggregation) ---------------
|
# --- Watch-state scope (shared by the /library grid AND the /facets aggregation) ---------------
|
||||||
#
|
#
|
||||||
# The `show` scope (all | watched | in_progress | unwatched) filters the browse result by per-user
|
# The `show` scope (all | watched | in_progress | unwatched) filters the browse result by per-user
|
||||||
|
|||||||
@@ -43,7 +43,9 @@ def rollup(cards: list[dict]) -> dict:
|
|||||||
]
|
]
|
||||||
resume = None
|
resume = None
|
||||||
if inprog:
|
if inprog:
|
||||||
resume = inprog[-1] # continue the latest-started episode
|
# The LAST in-progress episode in broadcast order — no timestamp is consulted. Home's
|
||||||
|
# `discover.queries.next_episode` picks the same one, so both Resume buttons agree.
|
||||||
|
resume = inprog[-1]
|
||||||
else:
|
else:
|
||||||
resume = next((c for c in cards if c.get("status") != "watched"), None)
|
resume = next((c for c in cards if c.get("status") != "watched"), None)
|
||||||
return {
|
return {
|
||||||
|
|||||||
+180
-50
@@ -18,7 +18,6 @@ segments. The remux is CPU-light (video copy + a tiny audio transcode), so it ru
|
|||||||
CPU-only prod host; only P3's full transcode is CPU-heavy.
|
CPU-only prod host; only P3's full transcode is CPU-heavy.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import secrets
|
import secrets
|
||||||
@@ -31,15 +30,14 @@ from pathlib import Path
|
|||||||
|
|
||||||
from sqlalchemy.orm import Session as DbSession
|
from sqlalchemy.orm import Session as DbSession
|
||||||
|
|
||||||
from app import sysconfig
|
from app import logs, sysconfig
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.downloads.edit import encode_thread_cap
|
from app.downloads.edit import encode_thread_cap
|
||||||
from app.models import PlexItem
|
from app.models import PlexItem
|
||||||
from app.plex import paths
|
from app.plex import paths
|
||||||
from app.plex.sync import HW_DECODABLE_CODECS
|
from app.plex.sync import HW_DECODABLE_CODECS
|
||||||
|
|
||||||
log = logging.getLogger("siftlode.plex")
|
log = logs.get_logger(__name__)
|
||||||
|
|
||||||
_HLS_ROOT = (
|
_HLS_ROOT = (
|
||||||
Path(settings.plex_hls_dir)
|
Path(settings.plex_hls_dir)
|
||||||
if settings.plex_hls_dir
|
if settings.plex_hls_dir
|
||||||
@@ -62,8 +60,10 @@ _WARMUP_SEG_TIMEOUT_S = 8.0
|
|||||||
# its index for that one. See `segment_name` for why this is a constant and not a comment.
|
# its index for that one. See `segment_name` for why this is a constant and not a comment.
|
||||||
_VIDEO_VARIANT = "0"
|
_VIDEO_VARIANT = "0"
|
||||||
_SESSION_IDLE_S = 600 # reap a session with no access for this long
|
_SESSION_IDLE_S = 600 # reap a session with no access for this long
|
||||||
# A session directory is named `{rating_key}_{int(start_s)}_{tag}_{aoff:+.2f}` (see start_session);
|
# A session directory is named `{rating_key}_u{user_id}_{int(start_s)}_{tag}_{aoff:+.2f}` (see
|
||||||
# before multi-audio it was just `{rating_key}_{int(start_s)}`. `sweep_orphans` deletes ONLY names
|
# start_session); before per-viewer sessions it was `{rating_key}_{int(start_s)}_{tag}_{aoff:+.2f}`,
|
||||||
|
# and before multi-audio just `{rating_key}_{int(start_s)}` — the older shapes stay sweepable, since a
|
||||||
|
# restart leaves them behind like any other. `sweep_orphans` deletes ONLY names
|
||||||
# matching one of those two shapes: `_HLS_ROOT` is admin-configurable (PLEX_HLS_DIR), so it may
|
# matching one of those two shapes: `_HLS_ROOT` is admin-configurable (PLEX_HLS_DIR), so it may
|
||||||
# legitimately point at a shared scratch path we do not own, and a blanket "delete every directory
|
# legitimately point at a shared scratch path we do not own, and a blanket "delete every directory
|
||||||
# under the root" would take the neighbours with it.
|
# under the root" would take the neighbours with it.
|
||||||
@@ -74,7 +74,9 @@ _SESSION_IDLE_S = 600 # reap a session with no access for this long
|
|||||||
# non-numeric rating_key would merely leave its directory unswept, which is the safe direction to
|
# non-numeric rating_key would merely leave its directory unswept, which is the safe direction to
|
||||||
# fail. Covered by test_plex_stream.py — this regex is the only thing standing between the sweep
|
# fail. Covered by test_plex_stream.py — this regex is the only thing standing between the sweep
|
||||||
# and someone else's data.
|
# and someone else's data.
|
||||||
_SESSION_DIR_RE = re.compile(r"^\d+_\d+(_[^_]+_[+-]\d+\.\d{2})?$")
|
_SESSION_DIR_RE = re.compile(
|
||||||
|
r"^\d+_\d+(_[^_]+_[+-]\d+\.\d{2})?$|^\d+_u\d+_\d+_[^_]+_[+-]\d+\.\d{2}$"
|
||||||
|
)
|
||||||
# When this process started. `sweep_orphans` only deletes directories older than this: everything
|
# When this process started. `sweep_orphans` only deletes directories older than this: everything
|
||||||
# newer belongs to a session THIS run created, and since `start_session` reuses a directory path
|
# newer belongs to a session THIS run created, and since `start_session` reuses a directory path
|
||||||
# for the same (rating_key, offset, tag), "stale name" is not the same as "stale directory".
|
# for the same (rating_key, offset, tag), "stale name" is not the same as "stale directory".
|
||||||
@@ -90,7 +92,16 @@ _PROCESS_START = time.time()
|
|||||||
_MTIME_GRACE_S = 2.0
|
_MTIME_GRACE_S = 2.0
|
||||||
|
|
||||||
_lock = threading.Lock()
|
_lock = threading.Lock()
|
||||||
_sessions: dict[str, "HlsSession"] = {}
|
# Keyed by VIEWER and item, (user_id, rating_key). Keyed by the item alone, a second account opening
|
||||||
|
# the same film `_kill`ed the first one's session: the displaced viewer got a 404, reclaimed at its
|
||||||
|
# playhead, and so evicted the other in turn — two people could not watch one film at once. (One
|
||||||
|
# account on two devices still shares a session; that case ends in the player's bounded reclaim.)
|
||||||
|
_sessions: dict[tuple[int, str], "HlsSession"] = {}
|
||||||
|
# How many live sessions one account may hold at once (two devices, or a next item starting while the
|
||||||
|
# last one winds down). A new one past it replaces that account's least recently used — whose waiting
|
||||||
|
# session request then returns at once, since `wait_for` watches the killed ffmpeg — so one account
|
||||||
|
# can neither pile up film-sized scratch copies nor park more than this many request workers.
|
||||||
|
_MAX_SESSIONS_PER_USER = 2
|
||||||
|
|
||||||
|
|
||||||
class HlsSession:
|
class HlsSession:
|
||||||
@@ -104,16 +115,17 @@ class HlsSession:
|
|||||||
transcode: bool = False,
|
transcode: bool = False,
|
||||||
hw: bool = False,
|
hw: bool = False,
|
||||||
tonemap: bool = False,
|
tonemap: bool = False,
|
||||||
|
user_id: int = 0,
|
||||||
):
|
):
|
||||||
# Unguessable per-session id, carried in the URLs the client is handed. Sessions are keyed
|
# Unguessable per-session id, carried in the URLs the client is handed. A seek-restart
|
||||||
# by rating_key alone, so a seek-restart or a SECOND viewer of the same item replaces the
|
# replaces the viewer's registered session for the item — and the old player's next request
|
||||||
# registered one — and the old viewer's next request for `seg_12.ts`, resolved by rating_key,
|
# for `seg_12.ts`, resolved by (viewer, rating_key) alone, was answered 200 with the NEW session's twelfth segment, produced from a different offset
|
||||||
# was answered 200 with the NEW session's twelfth segment, produced from a different offset
|
|
||||||
# under `-copyts`. Wrong bytes, not an error, so the client's reclaim never fired on it
|
# under `-copyts`. Wrong bytes, not an error, so the client's reclaim never fired on it
|
||||||
# (deep-review round 5). A request naming a session that is no longer registered is a 404,
|
# (deep-review round 5). A request naming a session that is no longer registered is a 404,
|
||||||
# which the player recovers from by restarting at the playhead.
|
# which the player recovers from by restarting at the playhead.
|
||||||
self.id = secrets.token_urlsafe(9)
|
self.id = secrets.token_urlsafe(9)
|
||||||
self.key = key
|
self.key = key # the item's rating_key (what the logs name)
|
||||||
|
self.user_id = user_id # whose session it is: the registry key is (user_id, key)
|
||||||
self.dir = directory
|
self.dir = directory
|
||||||
self.proc = proc
|
self.proc = proc
|
||||||
self.transcode = transcode # video re-encoded (non-h264 source), not stream-copied
|
self.transcode = transcode # video re-encoded (non-h264 source), not stream-copied
|
||||||
@@ -135,6 +147,8 @@ class HlsSession:
|
|||||||
# single index. Derived, not passed, so it cannot disagree with the argv.
|
# single index. Derived, not passed, so it cannot disagree with the argv.
|
||||||
self.entry = _MASTER_PL if self.variants else _INDEX_PL
|
self.entry = _MASTER_PL if self.variants else _INDEX_PL
|
||||||
self.last_access = time.time()
|
self.last_access = time.time()
|
||||||
|
# `artifact_may_arrive` reports a refusing session directory once, not per segment.
|
||||||
|
self.probe_failure_reported = False
|
||||||
|
|
||||||
|
|
||||||
def _seek_opts(start_s: float) -> list[str]:
|
def _seek_opts(start_s: float) -> list[str]:
|
||||||
@@ -232,7 +246,15 @@ def _probe_video_kind(src: Path) -> str:
|
|||||||
text=True,
|
text=True,
|
||||||
timeout=10,
|
timeout=10,
|
||||||
)
|
)
|
||||||
except (subprocess.SubprocessError, OSError):
|
except (subprocess.SubprocessError, OSError) as exc:
|
||||||
|
# The safe kind is a real answer, but it is the answer for an 8-bit SDR source — a blind
|
||||||
|
# ffprobe sends every HDR file down it, so playback quietly degrades to a software
|
||||||
|
# transcode with nothing anywhere saying why. The path stays out of the line: it is the
|
||||||
|
# title of whatever is playing.
|
||||||
|
log.info(
|
||||||
|
"video-kind probe failed; assuming the HW-safe pipeline",
|
||||||
|
extra=logs.event("plex.stream.probe_kind_failed", reason=type(exc).__name__),
|
||||||
|
)
|
||||||
return _VK_HWSAFE
|
return _VK_HWSAFE
|
||||||
pix_fmt = bits = transfer = ""
|
pix_fmt = bits = transfer = ""
|
||||||
for line in out.stdout.splitlines():
|
for line in out.stdout.splitlines():
|
||||||
@@ -261,7 +283,14 @@ def _opencl_present() -> bool:
|
|||||||
software libx264. Its own function so it's monkeypatchable in tests, mirroring _render_node_present."""
|
software libx264. Its own function so it's monkeypatchable in tests, mirroring _render_node_present."""
|
||||||
try:
|
try:
|
||||||
return any(_OPENCL_ICD_DIR.glob("*.icd"))
|
return any(_OPENCL_ICD_DIR.glob("*.icd"))
|
||||||
except OSError:
|
except OSError as exc:
|
||||||
|
# A MISSING directory does not come through here — `glob` simply yields nothing for one.
|
||||||
|
# This is a directory we cannot read, and the answer it forces (no OpenCL) costs every HDR
|
||||||
|
# source the hardware tonemap path.
|
||||||
|
log.info(
|
||||||
|
"the OpenCL ICD directory could not be read; treating OpenCL as absent",
|
||||||
|
extra=logs.event("plex.stream.opencl_probe_failed", reason=type(exc).__name__),
|
||||||
|
)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
@@ -447,6 +476,8 @@ def _sw_child_preexec() -> None:
|
|||||||
playback."""
|
playback."""
|
||||||
try:
|
try:
|
||||||
os.nice(_SW_TRANSCODE_NICE)
|
os.nice(_SW_TRANSCODE_NICE)
|
||||||
|
# silent-ok: this runs in the forked child, between fork and exec. Logging there can deadlock
|
||||||
|
# on a lock some other thread held at fork time, and the child is about to become ffmpeg.
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -473,7 +504,14 @@ def _probe_audio_langs(src: Path) -> list[str]:
|
|||||||
timeout=10,
|
timeout=10,
|
||||||
)
|
)
|
||||||
return [ln.strip() for ln in out.stdout.splitlines()]
|
return [ln.strip() for ln in out.stdout.splitlines()]
|
||||||
except (subprocess.SubprocessError, OSError):
|
except (subprocess.SubprocessError, OSError) as exc:
|
||||||
|
# An empty list IS the single-audio path, so a failed probe silently collapses a file with
|
||||||
|
# several audio tracks down to one — and the viewer gets a player with no language picker
|
||||||
|
# rather than an error.
|
||||||
|
log.warning(
|
||||||
|
"audio-track probe failed; falling back to a single-audio session",
|
||||||
|
extra=logs.event("plex.stream.probe_audio_failed", reason=type(exc).__name__),
|
||||||
|
)
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
@@ -582,8 +620,14 @@ def _probe_first_pts(seg: Path) -> float | None:
|
|||||||
tok = line.strip().strip(",")
|
tok = line.strip().strip(",")
|
||||||
if tok:
|
if tok:
|
||||||
return float(tok)
|
return float(tok)
|
||||||
except (subprocess.SubprocessError, ValueError, OSError):
|
except (subprocess.SubprocessError, ValueError, OSError) as exc:
|
||||||
pass
|
# None means "keep the offset the client asked for", and the whole point of this probe is
|
||||||
|
# that the two differ: without it the subtitle track is shifted by whatever the container
|
||||||
|
# started its timestamps at. Seconds of drift, and nothing on screen calls it a failure.
|
||||||
|
log.warning(
|
||||||
|
"first-PTS probe failed; the requested offset is used as-is",
|
||||||
|
extra=logs.event("plex.stream.first_pts_probe_failed", reason=type(exc).__name__),
|
||||||
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -592,29 +636,62 @@ def _kill(s: HlsSession) -> None:
|
|||||||
s.proc.terminate()
|
s.proc.terminate()
|
||||||
try:
|
try:
|
||||||
s.proc.wait(timeout=3)
|
s.proc.wait(timeout=3)
|
||||||
|
# silent-ok: the escalation IS the handling. A transcode that ignores TERM for three
|
||||||
|
# seconds gets KILL, which is exactly what this branch does.
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
s.proc.kill()
|
s.proc.kill()
|
||||||
except Exception:
|
except Exception as exc: # noqa: BLE001 — teardown finishes whatever the process does
|
||||||
pass
|
# Not nothing: what survives this is an ffmpeg nobody owns any more, holding a VAAPI
|
||||||
|
# handle and a core, and the transcode cap that is supposed to bound them counts rows in
|
||||||
|
# `_sessions` rather than processes. One line is the only way that ever surfaces.
|
||||||
|
log.info(
|
||||||
|
"could not stop the transcode process",
|
||||||
|
extra=logs.event("plex.stream.kill_failed", key=s.key, reason=type(exc).__name__),
|
||||||
|
)
|
||||||
shutil.rmtree(s.dir, ignore_errors=True)
|
shutil.rmtree(s.dir, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
def _enforce_cap(cap: int) -> None:
|
def _enforce_caps(user_id: int, max_transcodes: int, max_sessions: int) -> None:
|
||||||
# Caller holds _lock. Only TRANSCODE sessions count toward plex_max_transcodes — remux
|
"""Caller holds _lock. Three bounds, applied after a session is registered, each dropping the
|
||||||
# (stream-copy) and direct-serve are cheap and deliberately uncapped (see config.py:
|
least recently accessed sessions over it:
|
||||||
# plex_max_transcodes, and the user-facing config hint). Drop the least-recently-accessed
|
|
||||||
# transcode sessions over the cap, leaving any concurrent remux streams untouched.
|
* this account's own sessions (`_MAX_SESSIONS_PER_USER`) — first, so a single account filling the
|
||||||
tx = [(key, s) for key, s in _sessions.items() if s.transcode]
|
box evicts itself before anyone else;
|
||||||
if len(tx) <= cap:
|
* TRANSCODE sessions (plex_max_transcodes): the CPU/GPU-heavy ones;
|
||||||
return
|
* EVERY session (plex_max_sessions). A remux is cheap on the CPU but not on the disk: each one is a
|
||||||
for key, s in sorted(tx, key=lambda kv: kv[1].last_access)[: len(tx) - cap]:
|
whole segmented copy of the film under `_HLS_ROOT`, written faster than realtime. Counted only
|
||||||
_kill(s)
|
by the transcode cap, N distinct items were N ffmpegs and N film-sized scratch directories.
|
||||||
_sessions.pop(key, None)
|
|
||||||
|
Somebody's playback stops at each drop. The route turns the missing session into a retryable 404
|
||||||
|
and the player restarts at the playhead, so from the server nothing failed — and "my film keeps
|
||||||
|
rebuffering" has no other explanation anywhere, hence the line."""
|
||||||
|
|
||||||
|
def cull(pool: list[tuple[tuple[int, str], HlsSession]], cap: int, bound: str) -> None:
|
||||||
|
if len(pool) <= cap:
|
||||||
|
return
|
||||||
|
for reg, sess in sorted(pool, key=lambda kv: kv[1].last_access)[: len(pool) - cap]:
|
||||||
|
log.info(
|
||||||
|
"a session was dropped to stay under a cap",
|
||||||
|
extra=logs.event(
|
||||||
|
"plex.stream.session_culled",
|
||||||
|
key=sess.key,
|
||||||
|
bound=bound,
|
||||||
|
cap=cap,
|
||||||
|
sessions=len(pool),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
_kill(sess)
|
||||||
|
_sessions.pop(reg, None)
|
||||||
|
|
||||||
|
cull([kv for kv in _sessions.items() if kv[0][0] == user_id], _MAX_SESSIONS_PER_USER, "user")
|
||||||
|
cull([kv for kv in _sessions.items() if kv[1].transcode], max_transcodes, "transcode")
|
||||||
|
cull(list(_sessions.items()), max_sessions, "total")
|
||||||
|
|
||||||
|
|
||||||
def start_session(
|
def start_session(
|
||||||
db: DbSession,
|
db: DbSession,
|
||||||
item: PlexItem,
|
item: PlexItem,
|
||||||
|
user_id: int,
|
||||||
start_s: float,
|
start_s: float,
|
||||||
audio_ord: int | None = None,
|
audio_ord: int | None = None,
|
||||||
aoff: float = 0.0,
|
aoff: float = 0.0,
|
||||||
@@ -634,6 +711,7 @@ def start_session(
|
|||||||
# session respawn (the HW->SW fallback) must not pick up a mid-playback config change.
|
# session respawn (the HW->SW fallback) must not pick up a mid-playback config change.
|
||||||
loudnorm = sysconfig.get_bool(db, "plex_audio_loudnorm")
|
loudnorm = sysconfig.get_bool(db, "plex_audio_loudnorm")
|
||||||
key = item.rating_key
|
key = item.rating_key
|
||||||
|
reg = (user_id, key) # the registry key: one session per viewer per item
|
||||||
start_s = max(0.0, float(start_s))
|
start_s = max(0.0, float(start_s))
|
||||||
audio_langs = _probe_audio_langs(src) if multi else []
|
audio_langs = _probe_audio_langs(src) if multi else []
|
||||||
is_multi = len(audio_langs) > 1 # only worth a master playlist when there really are ≥2 tracks
|
is_multi = len(audio_langs) > 1 # only worth a master playlist when there really are ≥2 tracks
|
||||||
@@ -646,7 +724,7 @@ def start_session(
|
|||||||
# the tag with a DASH — `_SESSION_DIR_RE` treats the tag as one underscore-free field, and that
|
# the tag with a DASH — `_SESSION_DIR_RE` treats the tag as one underscore-free field, and that
|
||||||
# regex is the only thing keeping the orphan sweep off a shared scratch path (see its comment).
|
# regex is the only thing keeping the orphan sweep off a shared scratch path (see its comment).
|
||||||
tag = ("multi" if is_multi else str(audio_ord)) + ("-ln" if loudnorm else "")
|
tag = ("multi" if is_multi else str(audio_ord)) + ("-ln" if loudnorm else "")
|
||||||
directory = _HLS_ROOT / f"{key}_{int(start_s)}_{tag}_{aoff:+.2f}"
|
directory = _HLS_ROOT / f"{key}_u{user_id}_{int(start_s)}_{tag}_{aoff:+.2f}"
|
||||||
|
|
||||||
def _seg(i: int) -> Path:
|
def _seg(i: int) -> Path:
|
||||||
return directory / segment_name(i, _VIDEO_VARIANT if variants else None)
|
return directory / segment_name(i, _VIDEO_VARIANT if variants else None)
|
||||||
@@ -672,9 +750,9 @@ def start_session(
|
|||||||
# platform without fork (the API always runs in a Linux container in prod) it's simply omitted.
|
# platform without fork (the API always runs in a Linux container in prod) it's simply omitted.
|
||||||
preexec = _sw_child_preexec if (transcode and not hw and os.name == "posix") else None
|
preexec = _sw_child_preexec if (transcode and not hw and os.name == "posix") else None
|
||||||
with _lock:
|
with _lock:
|
||||||
if replace_only is not None and _sessions.get(key) is not replace_only:
|
if replace_only is not None and _sessions.get(reg) is not replace_only:
|
||||||
return None # superseded/culled while we waited out of lock — don't clobber the newer one
|
return None # superseded/culled while we waited out of lock — don't clobber the newer one
|
||||||
old = _sessions.pop(key, None)
|
old = _sessions.pop(reg, None)
|
||||||
if old is not None:
|
if old is not None:
|
||||||
_kill(old)
|
_kill(old)
|
||||||
shutil.rmtree(directory, ignore_errors=True)
|
shutil.rmtree(directory, ignore_errors=True)
|
||||||
@@ -708,11 +786,31 @@ def start_session(
|
|||||||
transcode=transcode,
|
transcode=transcode,
|
||||||
hw=hw,
|
hw=hw,
|
||||||
tonemap=tonemap,
|
tonemap=tonemap,
|
||||||
|
user_id=user_id,
|
||||||
|
)
|
||||||
|
_sessions[reg] = s
|
||||||
|
# The one line that says which pipeline a session actually got. Everything downstream
|
||||||
|
# — the cap, the HW→SW fallback, the subtitle offset — is about this choice, and until
|
||||||
|
# now it was only inferable from the ffmpeg command nobody keeps.
|
||||||
|
log.info(
|
||||||
|
"an HLS session started",
|
||||||
|
extra=logs.event(
|
||||||
|
"plex.stream.session_started",
|
||||||
|
key=key,
|
||||||
|
transcode=transcode,
|
||||||
|
hw=hw,
|
||||||
|
tonemap=tonemap,
|
||||||
|
variants=len(variants or ()),
|
||||||
|
start_s=round(start_s, 1),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
# Admin-configurable caps (Configuration → Plex); at least 1 each so playback can't be
|
||||||
|
# capped to zero.
|
||||||
|
_enforce_caps(
|
||||||
|
user_id,
|
||||||
|
max(1, sysconfig.get_int(db, "plex_max_transcodes")),
|
||||||
|
max(1, sysconfig.get_int(db, "plex_max_sessions")),
|
||||||
)
|
)
|
||||||
_sessions[key] = s
|
|
||||||
# Admin-configurable concurrency cap (Configuration → Plex → Max concurrent transcodes);
|
|
||||||
# at least 1 so playback can't be capped to zero.
|
|
||||||
_enforce_cap(max(1, sysconfig.get_int(db, "plex_max_transcodes")))
|
|
||||||
return s
|
return s
|
||||||
|
|
||||||
s = _spawn(*_select_encode(db, item, transcode, src))
|
s = _spawn(*_select_encode(db, item, transcode, src))
|
||||||
@@ -728,7 +826,8 @@ def start_session(
|
|||||||
# ONE lock in _spawn, so we can never tear down the newer session. s.hw is now False, so the route
|
# ONE lock in _spawn, so we can never tear down the newer session. s.hw is now False, so the route
|
||||||
# honestly reports "CPU" and the player's Preparing-stream state stays correct.
|
# honestly reports "CPU" and the player's Preparing-stream state stays correct.
|
||||||
log.warning(
|
log.warning(
|
||||||
"plex transcode: HW session produced no segments — falling back to software key=%s", key
|
"the hardware transcode produced no segments; falling back to software",
|
||||||
|
extra=logs.event("plex.stream.hw_fallback", key=key),
|
||||||
)
|
)
|
||||||
respawned = _spawn(False, False, replace_only=s)
|
respawned = _spawn(False, False, replace_only=s)
|
||||||
if respawned is None:
|
if respawned is None:
|
||||||
@@ -737,7 +836,7 @@ def start_session(
|
|||||||
# turns a None return into a "Media file not found on disk" 404 — dishonest for a file that
|
# turns a None return into a "Media file not found on disk" 404 — dishonest for a file that
|
||||||
# exists. A (dead) session instead degrades to a retryable 404 ("No active playback session" /
|
# exists. A (dead) session instead degrades to a retryable 404 ("No active playback session" /
|
||||||
# "Not available yet") the client re-requests.
|
# "Not available yet") the client re-requests.
|
||||||
return current_session(key) or s
|
return current_session(user_id, key) or s
|
||||||
s = respawned
|
s = respawned
|
||||||
ready = wait_for(_seg(0), timeout=25.0, proc=s.proc)
|
ready = wait_for(_seg(0), timeout=25.0, proc=s.proc)
|
||||||
if ready:
|
if ready:
|
||||||
@@ -768,9 +867,9 @@ def start_session(
|
|||||||
return s
|
return s
|
||||||
|
|
||||||
|
|
||||||
def current_session(key: str) -> HlsSession | None:
|
def current_session(user_id: int, key: str) -> HlsSession | None:
|
||||||
with _lock:
|
with _lock:
|
||||||
s = _sessions.get(key)
|
s = _sessions.get((user_id, key))
|
||||||
if s is not None:
|
if s is not None:
|
||||||
s.last_access = time.time()
|
s.last_access = time.time()
|
||||||
return s
|
return s
|
||||||
@@ -867,7 +966,23 @@ def artifact_may_arrive(s: "HlsSession", filename: str) -> bool:
|
|||||||
return True
|
return True
|
||||||
try:
|
try:
|
||||||
return (s.dir / segment_name(seq - 1, variant)).exists()
|
return (s.dir / segment_name(seq - 1, variant)).exists()
|
||||||
except OSError:
|
except OSError as exc:
|
||||||
|
# `exists()` answers False for a missing file without raising, so this is the session
|
||||||
|
# directory itself refusing — a full or unmounted disk. Every segment then 404s, which the
|
||||||
|
# player shows as a stall. Once per session: this runs for every segment request and
|
||||||
|
# every hls.js retry of one, and the sink's limiter still stores 30 a minute of a
|
||||||
|
# template — stdout would get them all.
|
||||||
|
if not s.probe_failure_reported:
|
||||||
|
s.probe_failure_reported = True
|
||||||
|
log.warning(
|
||||||
|
"a session segment could not be checked",
|
||||||
|
extra=logs.event(
|
||||||
|
"plex.stream.segment_probe_failed",
|
||||||
|
key=s.key,
|
||||||
|
seq=seq,
|
||||||
|
reason=type(exc).__name__,
|
||||||
|
),
|
||||||
|
)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
@@ -884,12 +999,16 @@ def wait_for(path: Path, timeout: float = 20.0, proc: subprocess.Popen | None =
|
|||||||
try:
|
try:
|
||||||
if path.exists() and path.stat().st_size > 0:
|
if path.exists() and path.stat().st_size > 0:
|
||||||
return True
|
return True
|
||||||
|
# silent-ok: this is a 150 ms poll — a line here is a line every 150 ms for the whole
|
||||||
|
# timeout. The final check below decides, and the 404 the caller then returns is honest.
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
if proc is not None and proc.poll() is not None:
|
if proc is not None and proc.poll() is not None:
|
||||||
# the producer exited — no more segments are coming; one last check then give up.
|
# the producer exited — no more segments are coming; one last check then give up.
|
||||||
try:
|
try:
|
||||||
return path.exists() and path.stat().st_size > 0
|
return path.exists() and path.stat().st_size > 0
|
||||||
|
# silent-ok: one last look after the producer exited. False here becomes a 404 the
|
||||||
|
# player recovers from, which is the honest answer either way.
|
||||||
except OSError:
|
except OSError:
|
||||||
return False
|
return False
|
||||||
time.sleep(0.15)
|
time.sleep(0.15)
|
||||||
@@ -922,7 +1041,16 @@ def sweep_orphans(older_than: float | None = None) -> int:
|
|||||||
dropped = 0
|
dropped = 0
|
||||||
try:
|
try:
|
||||||
entries = list(_HLS_ROOT.iterdir())
|
entries = list(_HLS_ROOT.iterdir())
|
||||||
except OSError: # the root doesn't exist yet — nothing to sweep
|
# silent-ok: nothing has been transcoded yet, so there is nothing to sweep.
|
||||||
|
except FileNotFoundError:
|
||||||
|
return 0
|
||||||
|
except OSError as exc:
|
||||||
|
# Any OTHER reason the root will not list is not "nothing to sweep": the sweep reports 0
|
||||||
|
# like a clean run while every crashed session's segments stay on disk forever.
|
||||||
|
log.warning(
|
||||||
|
"the HLS root could not be listed; no orphans were swept",
|
||||||
|
extra=logs.event("plex.stream.sweep_root_unreadable", reason=type(exc).__name__),
|
||||||
|
)
|
||||||
return 0
|
return 0
|
||||||
for p in entries:
|
for p in entries:
|
||||||
if not _SESSION_DIR_RE.match(p.name):
|
if not _SESSION_DIR_RE.match(p.name):
|
||||||
@@ -932,7 +1060,9 @@ def sweep_orphans(older_than: float | None = None) -> int:
|
|||||||
try:
|
try:
|
||||||
if p.stat().st_mtime >= cutoff:
|
if p.stat().st_mtime >= cutoff:
|
||||||
continue # this run made (or reused) it — never ours to delete
|
continue # this run made (or reused) it — never ours to delete
|
||||||
except OSError: # vanished under us (a concurrent restart of that session)
|
# silent-ok: vanished under us — a concurrent restart of that session removed it, which
|
||||||
|
# is the outcome this sweep wanted anyway.
|
||||||
|
except OSError:
|
||||||
continue
|
continue
|
||||||
with _lock:
|
with _lock:
|
||||||
if any(s.dir == p for s in _sessions.values()):
|
if any(s.dir == p for s in _sessions.values()):
|
||||||
@@ -968,21 +1098,21 @@ def reap_idle(finished_idle_s: float = _FINISHED_IDLE_S) -> int:
|
|||||||
now = time.time()
|
now = time.time()
|
||||||
dropped = 0
|
dropped = 0
|
||||||
with _lock:
|
with _lock:
|
||||||
for key, s in list(_sessions.items()):
|
for reg, s in list(_sessions.items()):
|
||||||
done = s.proc.poll() is not None
|
done = s.proc.poll() is not None
|
||||||
idle = now - s.last_access
|
idle = now - s.last_access
|
||||||
if idle > _SESSION_IDLE_S or (done and idle > finished_idle_s):
|
if idle > _SESSION_IDLE_S or (done and idle > finished_idle_s):
|
||||||
_kill(s)
|
_kill(s)
|
||||||
_sessions.pop(key, None)
|
_sessions.pop(reg, None)
|
||||||
dropped += 1
|
dropped += 1
|
||||||
return dropped
|
return dropped
|
||||||
|
|
||||||
|
|
||||||
def touch_session(key: str) -> None:
|
def touch_session(user_id: int, key: str) -> None:
|
||||||
"""Say a viewer is still there for `key`, without fetching anything. `last_access` used to move
|
"""Say this viewer is still there for `key`, without fetching anything. `last_access` used to move
|
||||||
only on an HLS file fetch, which a paused player does not do — so the only way to keep a paused
|
only on an HLS file fetch, which a paused player does not do — so the only way to keep a paused
|
||||||
viewer's session alive was to keep EVERY abandoned one just as long."""
|
viewer's session alive was to keep EVERY abandoned one just as long."""
|
||||||
with _lock:
|
with _lock:
|
||||||
s = _sessions.get(key)
|
s = _sessions.get((user_id, key))
|
||||||
if s is not None:
|
if s is not None:
|
||||||
s.last_access = time.time()
|
s.last_access = time.time()
|
||||||
|
|||||||
+263
-24
@@ -32,18 +32,20 @@ window still took the full 145 s and emitted the whole track), so windows are ex
|
|||||||
zero-based clock and shifted back to absolute time here with `shift_vtt`.
|
zero-based clock and shifted back to absolute time here with `shift_vtt`.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import codecs
|
||||||
import json
|
import json
|
||||||
import logging
|
|
||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
import logging
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app import logs
|
||||||
from app.db import SessionLocal
|
from app.db import SessionLocal
|
||||||
from app.models import PlexItem
|
from app.models import PlexItem
|
||||||
from app.plex import paths as plex_paths
|
from app.plex import paths as plex_paths
|
||||||
@@ -51,8 +53,7 @@ from app.plex.cache import CACHE_DIR
|
|||||||
from app.plex.client import PlexClient, PlexError
|
from app.plex.client import PlexClient, PlexError
|
||||||
from app.plex.lookups import by_rating_key
|
from app.plex.lookups import by_rating_key
|
||||||
|
|
||||||
log = logging.getLogger("siftlode.plex")
|
log = logs.get_logger(__name__)
|
||||||
|
|
||||||
# Image-based subtitle codecs (Blu-ray/DVD) — can't be turned into text WebVTT (would need burn-in).
|
# Image-based subtitle codecs (Blu-ray/DVD) — can't be turned into text WebVTT (would need burn-in).
|
||||||
BITMAP_SUBS = {
|
BITMAP_SUBS = {
|
||||||
"pgs",
|
"pgs",
|
||||||
@@ -104,6 +105,7 @@ def shift_vtt(vtt: str, offset: float) -> str:
|
|||||||
straddling 0 is clamped to start at 0."""
|
straddling 0 is clamped to start at 0."""
|
||||||
if not offset:
|
if not offset:
|
||||||
return vtt
|
return vtt
|
||||||
|
unshifted = 0
|
||||||
out_blocks: list[str] = []
|
out_blocks: list[str] = []
|
||||||
for block in re.split(r"\n[ \t]*\n", vtt):
|
for block in re.split(r"\n[ \t]*\n", vtt):
|
||||||
if "-->" not in block:
|
if "-->" not in block:
|
||||||
@@ -121,7 +123,12 @@ def shift_vtt(vtt: str, offset: float) -> str:
|
|||||||
st = _vtt_ts_to_s(left.strip()) - offset
|
st = _vtt_ts_to_s(left.strip()) - offset
|
||||||
en = _vtt_ts_to_s(rparts[0]) - offset
|
en = _vtt_ts_to_s(rparts[0]) - offset
|
||||||
except (ValueError, IndexError):
|
except (ValueError, IndexError):
|
||||||
break # unparseable timing → leave the block as-is
|
# silent-ok: left as-is means left UNSHIFTED — the viewer sees that one cue at
|
||||||
|
# the wrong moment. Counted rather than logged here: this is per cue, and the file
|
||||||
|
# being parsed is one ffmpeg wrote for us a moment ago, so a broken timestamp is
|
||||||
|
# a fact about the extraction, reported once below.
|
||||||
|
unshifted += 1
|
||||||
|
break
|
||||||
if en <= 0:
|
if en <= 0:
|
||||||
keep = False # whole cue is before the session start → drop the block
|
keep = False # whole cue is before the session start → drop the block
|
||||||
else:
|
else:
|
||||||
@@ -131,6 +138,11 @@ def shift_vtt(vtt: str, offset: float) -> str:
|
|||||||
break
|
break
|
||||||
if keep:
|
if keep:
|
||||||
out_blocks.append("\n".join(lines))
|
out_blocks.append("\n".join(lines))
|
||||||
|
if unshifted:
|
||||||
|
log.info(
|
||||||
|
"some cues could not be shifted and stay at their original time",
|
||||||
|
extra=logs.event("plex.subtitles.cues_unshifted", cues=unshifted, offset_s=offset),
|
||||||
|
)
|
||||||
return "\n\n".join(out_blocks)
|
return "\n\n".join(out_blocks)
|
||||||
|
|
||||||
|
|
||||||
@@ -162,7 +174,15 @@ def cache_read(key: str) -> str | None:
|
|||||||
try:
|
try:
|
||||||
p = CACHE_DIR / f"{key}.vtt"
|
p = CACHE_DIR / f"{key}.vtt"
|
||||||
return p.read_text(encoding="utf-8") if p.exists() else None
|
return p.read_text(encoding="utf-8") if p.exists() else None
|
||||||
except OSError:
|
except OSError as exc:
|
||||||
|
# None is the same answer as "never cached", so a cache we cannot read looks exactly like
|
||||||
|
# a cold one — and every request re-extracts instead of saying the disk is the problem.
|
||||||
|
log.warning(
|
||||||
|
"the cached subtitle could not be read",
|
||||||
|
extra=logs.event(
|
||||||
|
"plex.subtitles.cache_read_failed", key=key, reason=type(exc).__name__
|
||||||
|
),
|
||||||
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -175,7 +195,16 @@ def cache_write(key: str, vtt: str) -> bool:
|
|||||||
tmp.write_text(vtt, encoding="utf-8")
|
tmp.write_text(vtt, encoding="utf-8")
|
||||||
tmp.replace(CACHE_DIR / f"{key}.vtt") # atomic publish
|
tmp.replace(CACHE_DIR / f"{key}.vtt") # atomic publish
|
||||||
return True
|
return True
|
||||||
except OSError:
|
except OSError as exc:
|
||||||
|
# `publish` turns False into "the region was not claimed" and moves on, so the track just
|
||||||
|
# never appears. Which errno it was — full disk, read-only mount, permissions — is the
|
||||||
|
# whole diagnosis, and it exists only here.
|
||||||
|
log.warning(
|
||||||
|
"the subtitle cache could not be written",
|
||||||
|
extra=logs.event(
|
||||||
|
"plex.subtitles.cache_write_failed", key=key, reason=type(exc).__name__
|
||||||
|
),
|
||||||
|
)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
@@ -189,7 +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:
|
||||||
@@ -206,7 +239,17 @@ def read_meta(key: str) -> tuple[list[list[float]], bool]:
|
|||||||
try:
|
try:
|
||||||
raw = json.loads(_meta_path(key).read_text(encoding="utf-8"))
|
raw = json.loads(_meta_path(key).read_text(encoding="utf-8"))
|
||||||
return [[float(a), float(b)] for a, b in raw.get("ranges", [])], bool(raw.get("complete"))
|
return [[float(a), float(b)] for a, b in raw.get("ranges", [])], bool(raw.get("complete"))
|
||||||
except (OSError, ValueError, TypeError):
|
# silent-ok: no sidecar at all is the documented older-build case the fallback exists for.
|
||||||
|
except FileNotFoundError:
|
||||||
|
return ([[0.0, _FULL_END_S]], True) if _exists(_vtt_path(key)) else ([], False)
|
||||||
|
except (OSError, ValueError, TypeError) as exc:
|
||||||
|
# A sidecar that EXISTS and will not parse is different, and the fallback is the dangerous
|
||||||
|
# answer to it: beside a cached VTT it reports the track as complete, so a partial track is
|
||||||
|
# never extended again and the viewer loses every cue past the window that was cached.
|
||||||
|
log.warning(
|
||||||
|
"the subtitle sidecar could not be read; the cached track is taken as complete",
|
||||||
|
extra=logs.event("plex.subtitles.meta_unreadable", key=key, reason=type(exc).__name__),
|
||||||
|
)
|
||||||
return ([[0.0, _FULL_END_S]], True) if _exists(_vtt_path(key)) else ([], False)
|
return ([[0.0, _FULL_END_S]], True) if _exists(_vtt_path(key)) else ([], False)
|
||||||
|
|
||||||
|
|
||||||
@@ -216,6 +259,8 @@ def _exists(p: Path) -> bool:
|
|||||||
escaped as a 500 from the status route."""
|
escaped as a 500 from the status route."""
|
||||||
try:
|
try:
|
||||||
return p.exists()
|
return p.exists()
|
||||||
|
# silent-ok: this function's whole job is to answer False for a path the OS refuses. The
|
||||||
|
# refusal IS the return value; a caller that needs to report one reports it itself.
|
||||||
except (OSError, ValueError):
|
except (OSError, ValueError):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -226,8 +271,18 @@ def _write_meta(key: str, ranges: list[list[float]], complete: bool) -> None:
|
|||||||
tmp = CACHE_DIR / f"{key}.json.tmp"
|
tmp = CACHE_DIR / f"{key}.json.tmp"
|
||||||
tmp.write_text(json.dumps({"ranges": ranges, "complete": complete}), encoding="utf-8")
|
tmp.write_text(json.dumps({"ranges": ranges, "complete": complete}), encoding="utf-8")
|
||||||
tmp.replace(_meta_path(key))
|
tmp.replace(_meta_path(key))
|
||||||
except OSError:
|
except OSError as exc:
|
||||||
pass
|
# Without the sidecar beside it, a cached window reads back as a COMPLETE track (see
|
||||||
|
# `read_meta`) — so one failed write here permanently costs the rest of that subtitle.
|
||||||
|
log.warning(
|
||||||
|
"the subtitle sidecar could not be written",
|
||||||
|
extra=logs.event(
|
||||||
|
"plex.subtitles.meta_write_failed",
|
||||||
|
key=key,
|
||||||
|
complete=complete,
|
||||||
|
reason=type(exc).__name__,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def generation(key: str) -> int:
|
def generation(key: str) -> int:
|
||||||
@@ -235,7 +290,9 @@ def generation(key: str) -> int:
|
|||||||
window is superseded by a wider one the browser refetches instead of keeping the short VTT."""
|
window is superseded by a wider one the browser refetches instead of keeping the short VTT."""
|
||||||
try:
|
try:
|
||||||
return _vtt_path(key).stat().st_mtime_ns
|
return _vtt_path(key).stat().st_mtime_ns
|
||||||
except (OSError, ValueError): # ValueError: a NUL in the key — `stat` refuses the path outright
|
# silent-ok: 0 for a track that is not cached yet, which is the ordinary case on every first
|
||||||
|
# request. (ValueError: a NUL in the key — `stat` refuses the path outright.)
|
||||||
|
except (OSError, ValueError):
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
@@ -267,16 +324,27 @@ def merge_vtt(*sources: str) -> str:
|
|||||||
happen to share a time (two speakers, a sign over dialogue) are both kept."""
|
happen to share a time (two speakers, a sign over dialogue) are both kept."""
|
||||||
seen: set[str] = set()
|
seen: set[str] = set()
|
||||||
cues: list[tuple[float, str]] = []
|
cues: list[tuple[float, str]] = []
|
||||||
|
dropped = 0
|
||||||
for src in sources:
|
for src in sources:
|
||||||
for block in re.split(r"\n[ \t]*\n", src or ""):
|
for block in re.split(r"\n[ \t]*\n", src or ""):
|
||||||
canon = _canonical_cue(block.strip())
|
stripped = block.strip()
|
||||||
|
canon = _canonical_cue(stripped)
|
||||||
if canon is None:
|
if canon is None:
|
||||||
|
# A block WITH a timing line that still would not parse is a cue we are throwing
|
||||||
|
# away, not a header — and the merged track would simply be missing those lines.
|
||||||
|
if "-->" in stripped:
|
||||||
|
dropped += 1
|
||||||
continue # header / NOTE / STYLE — the merged file gets its own single header
|
continue # header / NOTE / STYLE — the merged file gets its own single header
|
||||||
start, key, text = canon
|
start, key, text = canon
|
||||||
if key in seen:
|
if key in seen:
|
||||||
continue
|
continue
|
||||||
seen.add(key)
|
seen.add(key)
|
||||||
cues.append((start, text))
|
cues.append((start, text))
|
||||||
|
if dropped:
|
||||||
|
log.info(
|
||||||
|
"some cues were dropped from the merged subtitle: their timing would not parse",
|
||||||
|
extra=logs.event("plex.subtitles.cues_dropped", cues=dropped, kept=len(cues)),
|
||||||
|
)
|
||||||
cues.sort(key=lambda c: c[0])
|
cues.sort(key=lambda c: c[0])
|
||||||
return "WEBVTT\n\n" + "\n\n".join(b for _, b in cues) + "\n"
|
return "WEBVTT\n\n" + "\n\n".join(b for _, b in cues) + "\n"
|
||||||
|
|
||||||
@@ -295,6 +363,8 @@ def _canonical_cue(block: str) -> tuple[float, str, str] | None:
|
|||||||
try:
|
try:
|
||||||
st = _vtt_ts_to_s(left.strip())
|
st = _vtt_ts_to_s(left.strip())
|
||||||
en = _vtt_ts_to_s(rparts[0])
|
en = _vtt_ts_to_s(rparts[0])
|
||||||
|
# silent-ok: None here means "not a cue", and the caller tells a header apart from a
|
||||||
|
# broken timing line by whether the block had a `-->` at all — see `merge_vtt`.
|
||||||
except (ValueError, IndexError):
|
except (ValueError, IndexError):
|
||||||
return None
|
return None
|
||||||
lines[i] = f"{_vtt_s_to_ts(st)} --> {_vtt_s_to_ts(en)}{settings}"
|
lines[i] = f"{_vtt_s_to_ts(st)} --> {_vtt_s_to_ts(en)}{settings}"
|
||||||
@@ -310,6 +380,8 @@ def _last_cue_end(vtt: str) -> float:
|
|||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
end = max(end, _vtt_ts_to_s(ln.partition("-->")[2].strip().split(None, 1)[0]))
|
end = max(end, _vtt_ts_to_s(ln.partition("-->")[2].strip().split(None, 1)[0]))
|
||||||
|
# silent-ok: this is a floor — "how far the pass provably got". Skipping a line it cannot
|
||||||
|
# read makes the answer smaller, never larger, so the error is always on the safe side.
|
||||||
except (ValueError, IndexError):
|
except (ValueError, IndexError):
|
||||||
continue
|
continue
|
||||||
return end
|
return end
|
||||||
@@ -394,11 +466,18 @@ _FULL_POOL = ThreadPoolExecutor(max_workers=2, thread_name_prefix="plex-subs-ful
|
|||||||
# which time something has finished.
|
# which time something has finished.
|
||||||
_MAX_WINDOWS_INFLIGHT = 8
|
_MAX_WINDOWS_INFLIGHT = 8
|
||||||
_MAX_FULL_INFLIGHT = 6
|
_MAX_FULL_INFLIGHT = 6
|
||||||
|
# …of which one account may hold this many. The full-pass slots are minutes each, and a hostile (or
|
||||||
|
# merely curious) account opening subtitles on film after film could otherwise hold all of them while
|
||||||
|
# everyone else's tracks never completed. A viewer over their share still gets windows — the part
|
||||||
|
# that makes a track watchable now — just not another whole-file read until one of theirs ends.
|
||||||
|
_MAX_FULL_PER_USER = 3
|
||||||
_MAX_TRACK_WINDOWS = 2
|
_MAX_TRACK_WINDOWS = 2
|
||||||
|
|
||||||
_LOCK = threading.Lock()
|
_LOCK = threading.Lock()
|
||||||
# Queued or running jobs → the absolute span a window job reads (None for a full pass).
|
# Queued or running jobs → the absolute span a window job reads (None for a full pass).
|
||||||
_INFLIGHT: dict[tuple, tuple[float, float] | None] = {}
|
_INFLIGHT: dict[tuple, tuple[float, float] | None] = {}
|
||||||
|
# rating_key → the account whose poll queued that item's full pass (see _MAX_FULL_PER_USER).
|
||||||
|
_FULL_OWNERS: dict[str, int] = {}
|
||||||
# Track-level failures: a track that cannot be served at all (no item, a bitmap track, Plex down…).
|
# Track-level failures: a track that cannot be served at all (no item, a bitmap track, Plex down…).
|
||||||
_FAILURES: dict[str, tuple[str, float]] = {}
|
_FAILURES: dict[str, tuple[str, float]] = {}
|
||||||
# rating_key → when its full pass last failed (see FULL_RETRY_S).
|
# rating_key → when its full pass last failed (see FULL_RETRY_S).
|
||||||
@@ -448,6 +527,7 @@ def reset_state() -> None:
|
|||||||
global _STOPPING
|
global _STOPPING
|
||||||
with _LOCK:
|
with _LOCK:
|
||||||
_INFLIGHT.clear()
|
_INFLIGHT.clear()
|
||||||
|
_FULL_OWNERS.clear()
|
||||||
_FAILURES.clear()
|
_FAILURES.clear()
|
||||||
_FULL_FAILED.clear()
|
_FULL_FAILED.clear()
|
||||||
_WIN_FAILED.clear()
|
_WIN_FAILED.clear()
|
||||||
@@ -531,7 +611,7 @@ def _window_failed(key: str, at: float) -> bool:
|
|||||||
return any(a <= at <= b for a, b, _ in _live_window_failures(key))
|
return any(a <= at <= b for a, b, _ in _live_window_failures(key))
|
||||||
|
|
||||||
|
|
||||||
def ensure(rating_key: str, sub_ord: int, at: float = 0.0) -> dict:
|
def ensure(rating_key: str, sub_ord: int, at: float = 0.0, viewer: int = 0) -> dict:
|
||||||
"""State of one subtitle track, scheduling whatever extraction is still missing. NEVER blocks.
|
"""State of one subtitle track, scheduling whatever extraction is still missing. NEVER blocks.
|
||||||
|
|
||||||
``ready`` = fully extracted · ``partial`` = usable, covers ``at``, still growing · ``preparing``
|
``ready`` = fully extracted · ``partial`` = usable, covers ``at``, still growing · ``preparing``
|
||||||
@@ -574,8 +654,23 @@ def ensure(rating_key: str, sub_ord: int, at: float = 0.0) -> dict:
|
|||||||
if edge - at <= LOOKAHEAD_S and edge < _FULL_END_S:
|
if edge - at <= LOOKAHEAD_S and edge < _FULL_END_S:
|
||||||
probe = edge + 1.0
|
probe = edge + 1.0
|
||||||
want_window = not _window_failed(key, probe)
|
want_window = not _window_failed(key, probe)
|
||||||
_schedule(rating_key, sub_ord, probe, want_window=want_window, want_full=not full_failed)
|
_schedule(
|
||||||
return {"state": "partial" if covered else "preparing", "code": None, "gen": gen}
|
rating_key,
|
||||||
|
sub_ord,
|
||||||
|
probe,
|
||||||
|
want_window=want_window,
|
||||||
|
want_full=not full_failed,
|
||||||
|
viewer=viewer,
|
||||||
|
)
|
||||||
|
# `growing`: whether the track will fill in on its own. False once the full pass has failed and
|
||||||
|
# is backed off — a partial track then stays partial until a window is asked for, so the player
|
||||||
|
# may poll slowly (only to read ahead of an edge) instead of every two seconds for the whole film.
|
||||||
|
return {
|
||||||
|
"state": "partial" if covered else "preparing",
|
||||||
|
"code": None,
|
||||||
|
"gen": gen,
|
||||||
|
"growing": not full_failed,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def cached_track(rating_key: str, sub_ord: int) -> tuple[str | None, str | None]:
|
def cached_track(rating_key: str, sub_ord: int) -> tuple[str | None, str | None]:
|
||||||
@@ -592,7 +687,12 @@ def cached_track(rating_key: str, sub_ord: int) -> tuple[str | None, str | None]
|
|||||||
|
|
||||||
|
|
||||||
def _schedule(
|
def _schedule(
|
||||||
rating_key: str, sub_ord: int, at: float, want_window: bool, want_full: bool = True
|
rating_key: str,
|
||||||
|
sub_ord: int,
|
||||||
|
at: float,
|
||||||
|
want_window: bool,
|
||||||
|
want_full: bool = True,
|
||||||
|
viewer: int = 0,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Kick off the jobs this track still needs: the window that makes it watchable now, and the one
|
"""Kick off the jobs this track still needs: the window that makes it watchable now, and the one
|
||||||
full pass that makes it complete. A 1 s status poll (or a second viewer) joins a job already
|
full pass that makes it complete. A 1 s status poll (or a second viewer) joins a job already
|
||||||
@@ -625,8 +725,15 @@ def _schedule(
|
|||||||
ident = ("win", rating_key, sub_ord, w_start)
|
ident = ("win", rating_key, sub_ord, w_start)
|
||||||
_INFLIGHT[ident] = (w_start, w_start + WINDOW_PRE_S + WINDOW_LEN_S)
|
_INFLIGHT[ident] = (w_start, w_start + WINDOW_PRE_S + WINDOW_LEN_S)
|
||||||
jobs.append((ident, w_start))
|
jobs.append((ident, w_start))
|
||||||
if want_full and n_full < _MAX_FULL_INFLIGHT and ("full", rating_key) not in _INFLIGHT:
|
mine = sum(1 for owner in _FULL_OWNERS.values() if owner == viewer)
|
||||||
|
if (
|
||||||
|
want_full
|
||||||
|
and n_full < _MAX_FULL_INFLIGHT
|
||||||
|
and ("full", rating_key) not in _INFLIGHT
|
||||||
|
and mine < _MAX_FULL_PER_USER
|
||||||
|
):
|
||||||
_INFLIGHT[("full", rating_key)] = None
|
_INFLIGHT[("full", rating_key)] = None
|
||||||
|
_FULL_OWNERS[rating_key] = viewer
|
||||||
jobs.append((("full", rating_key), None))
|
jobs.append((("full", rating_key), None))
|
||||||
for ident, window_start in jobs:
|
for ident, window_start in jobs:
|
||||||
pool = _FULL_POOL if window_start is None else _WIN_POOL
|
pool = _FULL_POOL if window_start is None else _WIN_POOL
|
||||||
@@ -652,12 +759,35 @@ def _run(
|
|||||||
return
|
return
|
||||||
_extract_and_publish(rating_key, sub_ord, window_start)
|
_extract_and_publish(rating_key, sub_ord, window_start)
|
||||||
except SubtitleUnavailable as e:
|
except SubtitleUnavailable as e:
|
||||||
|
# The player's own state was the only place this ever showed. `_pass_failed` backs the item
|
||||||
|
# off for an hour and `_fail` records a code, but neither leaves a line, so "my subtitles
|
||||||
|
# never appear" had nothing on the server to read.
|
||||||
|
# A final code is a fact about the file (no such track, a bitmap subtitle); anything else
|
||||||
|
# is this instance failing at something it could do.
|
||||||
|
log.log(
|
||||||
|
logging.INFO if e.code in FINAL_CODES else logging.WARNING,
|
||||||
|
"subtitle extraction gave up",
|
||||||
|
extra=logs.event(
|
||||||
|
"plex.subtitles.pass_failed",
|
||||||
|
rating_key=rating_key,
|
||||||
|
sub_ord=sub_ord,
|
||||||
|
code=e.code,
|
||||||
|
windowed=window_start is not None,
|
||||||
|
),
|
||||||
|
)
|
||||||
if e.code == "extract_failed":
|
if e.code == "extract_failed":
|
||||||
_pass_failed(rating_key, [key], window_start)
|
_pass_failed(rating_key, [key], window_start)
|
||||||
else:
|
else:
|
||||||
_fail(key, e.code)
|
_fail(key, e.code)
|
||||||
except PlexError as e:
|
except PlexError as e:
|
||||||
log.warning("subtitle extraction failed for %s/%s: %s", rating_key, sub_ord, e)
|
# No traceback on purpose: a PlexError carries httpx's own message, which names the method,
|
||||||
|
# the URL and the status — the stack under it is this `except` and the client call, and
|
||||||
|
# says nothing the message does not. (The URL holds no token; it travels as a header.)
|
||||||
|
log.warning(
|
||||||
|
"subtitle extraction failed talking to Plex: %s",
|
||||||
|
e,
|
||||||
|
extra=logs.event("plex.subtitles.plex_error", rating_key=rating_key, sub_ord=sub_ord),
|
||||||
|
)
|
||||||
_fail(key, "plex_error")
|
_fail(key, "plex_error")
|
||||||
except (
|
except (
|
||||||
Exception
|
Exception
|
||||||
@@ -667,6 +797,8 @@ def _run(
|
|||||||
finally:
|
finally:
|
||||||
with _LOCK:
|
with _LOCK:
|
||||||
_INFLIGHT.pop(ident, None)
|
_INFLIGHT.pop(ident, None)
|
||||||
|
if ident[0] == "full":
|
||||||
|
_FULL_OWNERS.pop(rating_key, None)
|
||||||
|
|
||||||
|
|
||||||
def _pass_failed(
|
def _pass_failed(
|
||||||
@@ -732,10 +864,13 @@ def _extract_and_publish(rating_key: str, sub_ord: int, window_start: float | No
|
|||||||
for i, s in enumerate(subs)
|
for i, s in enumerate(subs)
|
||||||
if s.get("index") is not None and not _is_bitmap(s)
|
if s.get("index") is not None and not _is_bitmap(s)
|
||||||
}
|
}
|
||||||
start = window_start or 0.0
|
# A full pass starts where every track's leading coverage ends: a pass that ran out of time
|
||||||
|
# (`EXTRACT_TIMEOUT_S`) kept what it reached, and restarting from 0 each `FULL_RETRY_S` meant a file
|
||||||
|
# too big to read in one go never completed. Windows handle an offset start already; so does this.
|
||||||
|
start = window_start if window_start is not None else _full_pass_start(rating_key, targets)
|
||||||
length = (WINDOW_LEN_S + WINDOW_PRE_S) if window_start is not None else None
|
length = (WINDOW_LEN_S + WINDOW_PRE_S) if window_start is not None else None
|
||||||
end = _FULL_END_S if length is None else start + length
|
end = _FULL_END_S if length is None else start + length
|
||||||
texts, clean = _ffmpeg_extract(str(src), sorted(targets), window_start, length)
|
texts, clean = _ffmpeg_extract(str(src), sorted(targets), start or None, length)
|
||||||
for emb_ord, vtt in texts.items():
|
for emb_ord, vtt in texts.items():
|
||||||
key = cache_key(rating_key, targets[emb_ord])
|
key = cache_key(rating_key, targets[emb_ord])
|
||||||
# A pass that exited non-zero holds only what ffmpeg reached before it died. Claim no more
|
# A pass that exited non-zero holds only what ffmpeg reached before it died. Claim no more
|
||||||
@@ -756,15 +891,108 @@ def _extract_and_publish(rating_key: str, sub_ord: int, window_start: float | No
|
|||||||
_pass_failed(rating_key, [key], window_start, (covered_end, end))
|
_pass_failed(rating_key, [key], window_start, (covered_end, end))
|
||||||
|
|
||||||
|
|
||||||
|
def _full_pass_start(rating_key: str, targets: dict[int, int]) -> float:
|
||||||
|
"""Where the full pass may begin: the end of the span from 0 that EVERY target track already has
|
||||||
|
(one read feeds them all, so the least-covered track decides), backed off by `WINDOW_PRE_S` so a
|
||||||
|
cue running across the join is read whole — `merge_vtt` folds the overlap. 0 when any track has
|
||||||
|
nothing from the start."""
|
||||||
|
ends = []
|
||||||
|
for ord_ in targets.values():
|
||||||
|
key = cache_key(rating_key, ord_)
|
||||||
|
ranges, _ = read_meta(key) if _exists(_vtt_path(key)) else ([], False)
|
||||||
|
lead = [b for a, b in ranges if a <= 0.0]
|
||||||
|
if not lead:
|
||||||
|
return 0.0
|
||||||
|
ends.append(max(lead))
|
||||||
|
if not ends:
|
||||||
|
return 0.0
|
||||||
|
return max(0.0, float(f"{min(ends) - WINDOW_PRE_S:.3f}"))
|
||||||
|
|
||||||
|
|
||||||
|
# The legacy code page a subtitle in this language (Plex's ISO 639-2 `languageCode`) was most likely
|
||||||
|
# saved in. Serbian sits here, not with Cyrillic: its releases are overwhelmingly Latin script in
|
||||||
|
# Windows-1250, like Croatian and Bosnian.
|
||||||
|
_CP1250 = {
|
||||||
|
"hun",
|
||||||
|
"ces",
|
||||||
|
"cze",
|
||||||
|
"pol",
|
||||||
|
"slk",
|
||||||
|
"slo",
|
||||||
|
"slv",
|
||||||
|
"hrv",
|
||||||
|
"bos",
|
||||||
|
"srp",
|
||||||
|
"ron",
|
||||||
|
"rum",
|
||||||
|
"sqi",
|
||||||
|
"alb",
|
||||||
|
}
|
||||||
|
_CP1251 = {"rus", "ukr", "bel", "bul", "mkd", "mac"}
|
||||||
|
# Western European languages keep Windows-1252 (cp1250 has no à/è/ñ). An UNTAGGED file falls to
|
||||||
|
# Windows-1250: its ASCII and á/é/í/ó/ö/ú/ü match Windows-1252 byte for byte, and it also gets ő/ű
|
||||||
|
# right, which Windows-1252 turns into õ/û — the library this reads is mostly Hungarian.
|
||||||
|
_CP1252 = {
|
||||||
|
"eng",
|
||||||
|
"fre",
|
||||||
|
"fra",
|
||||||
|
"ger",
|
||||||
|
"deu",
|
||||||
|
"spa",
|
||||||
|
"ita",
|
||||||
|
"por",
|
||||||
|
"dut",
|
||||||
|
"nld",
|
||||||
|
"swe",
|
||||||
|
"dan",
|
||||||
|
"nor",
|
||||||
|
"fin",
|
||||||
|
"ice",
|
||||||
|
"isl",
|
||||||
|
"cat",
|
||||||
|
}
|
||||||
|
# A file that is almost all valid UTF-8 is UTF-8 with a few stray bytes (a pasted Windows-1252 quote),
|
||||||
|
# not a legacy file: accents in a legacy code page are nearly never valid UTF-8 sequences, so a real
|
||||||
|
# legacy file has hardly any. At least this many valid multi-byte characters per invalid byte.
|
||||||
|
_UTF8_VALID_PER_STRAY = 10
|
||||||
|
|
||||||
|
|
||||||
|
def decode_subtitle(raw: bytes, language_code: str | None) -> str:
|
||||||
|
"""A sidecar subtitle file as text. SubRip carries no charset, and a Hungarian release is
|
||||||
|
typically Windows-1250: decoded as UTF-8 with `replace`, every ő/ű/é came out as U+FFFD (found
|
||||||
|
live, 1391 of them in one episode). So: a BOM decides; else valid UTF-8 stays UTF-8 (a legacy
|
||||||
|
code page with accents is almost never valid UTF-8), and so does NEARLY valid UTF-8 — one stray
|
||||||
|
byte must not send a whole UTF-8 file to a legacy code page; else the language's legacy code page."""
|
||||||
|
if raw.startswith(codecs.BOM_UTF8):
|
||||||
|
return raw[len(codecs.BOM_UTF8) :].decode("utf-8", "replace")
|
||||||
|
if raw.startswith((codecs.BOM_UTF16_LE, codecs.BOM_UTF16_BE)):
|
||||||
|
return raw.decode("utf-16", "replace")
|
||||||
|
try:
|
||||||
|
return raw.decode("utf-8")
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
# silent-ok: not strictly UTF-8 — nearly-UTF-8 or a legacy code page, told apart below.
|
||||||
|
pass
|
||||||
|
lenient = raw.decode("utf-8", "replace")
|
||||||
|
stray = lenient.count("�")
|
||||||
|
valid = sum(1 for ch in lenient if ch > "" and ch != "�")
|
||||||
|
if valid >= stray * _UTF8_VALID_PER_STRAY:
|
||||||
|
return lenient
|
||||||
|
lang = (language_code or "").lower()
|
||||||
|
legacy = "cp1251" if lang in _CP1251 else "cp1252" if lang in _CP1252 else "cp1250"
|
||||||
|
return raw.decode(legacy, "replace")
|
||||||
|
|
||||||
|
|
||||||
def _sidecar_vtt(db: Session, stream: dict) -> str:
|
def _sidecar_vtt(db: Session, stream: dict) -> str:
|
||||||
"""An external subtitle file, fetched through Plex and converted to WebVTT."""
|
"""An external subtitle file, fetched through Plex and converted to WebVTT. Its bytes are made
|
||||||
|
UTF-8 first (`decode_subtitle`), on the ffmpeg path too: ffmpeg reads a text subtitle as UTF-8."""
|
||||||
with PlexClient(db) as plex:
|
with PlexClient(db) as plex:
|
||||||
raw = plex.raw_get(stream["key"])
|
raw = plex.raw_get(stream["key"])
|
||||||
|
text = decode_subtitle(raw, stream.get("languageCode"))
|
||||||
codec = (stream.get("codec") or stream.get("format") or "").lower()
|
codec = (stream.get("codec") or stream.get("format") or "").lower()
|
||||||
if codec in ("srt", "subrip", ""):
|
if codec in ("srt", "subrip", ""):
|
||||||
return srt_to_vtt(raw.decode("utf-8", "replace"))
|
return srt_to_vtt(text)
|
||||||
with tempfile.NamedTemporaryFile(suffix="." + (codec or "sub"), delete=False) as fh:
|
with tempfile.NamedTemporaryFile(suffix="." + (codec or "sub"), delete=False) as fh:
|
||||||
fh.write(raw)
|
fh.write(text.encode("utf-8"))
|
||||||
tmp = fh.name
|
tmp = fh.name
|
||||||
try:
|
try:
|
||||||
return ffmpeg_to_vtt(tmp, None)
|
return ffmpeg_to_vtt(tmp, None)
|
||||||
@@ -794,9 +1022,10 @@ def _ffmpeg_extract(
|
|||||||
cmd += ["-map", f"0:s:{emb_ord}"]
|
cmd += ["-map", f"0:s:{emb_ord}"]
|
||||||
if length is not None:
|
if length is not None:
|
||||||
cmd += ["-t", f"{length:.3f}"]
|
cmd += ["-t", f"{length:.3f}"]
|
||||||
|
if start:
|
||||||
# The input seek lands on the keyframe at or BEFORE `start`, so a cue already running
|
# The input seek lands on the keyframe at or BEFORE `start`, so a cue already running
|
||||||
# at `start` comes out at a negative time — and by default the muxer then shifts the
|
# at `start` comes out at a negative time — and by default the muxer then shifts the
|
||||||
# WHOLE output so that cue sits at 0: every cue of the window late by the distance to
|
# WHOLE output so that cue sits at 0: every cue of the read late by the distance to
|
||||||
# that keyframe (1–5 s measured on a real library file, a whole GOP on a synthetic
|
# that keyframe (1–5 s measured on a real library file, a whole GOP on a synthetic
|
||||||
# one), and doubled for good once the full pass merged in beside it. Kept negative, the
|
# one), and doubled for good once the full pass merged in beside it. Kept negative, the
|
||||||
# times are exact on the zero-based clock (`_vtt_ts_to_s` reads the sign).
|
# times are exact on the zero-based clock (`_vtt_ts_to_s` reads the sign).
|
||||||
@@ -829,6 +1058,16 @@ def _ffmpeg_extract(
|
|||||||
try:
|
try:
|
||||||
_, err = proc.communicate(timeout=_TERM_GRACE_S)
|
_, err = proc.communicate(timeout=_TERM_GRACE_S)
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
|
# TERM is what makes a timed-out pass worth keeping: ffmpeg closes its outputs
|
||||||
|
# on it. An ffmpeg that ignores TERM leaves only whole blocks already flushed,
|
||||||
|
# so the partial track is short for a reason the later line cannot tell apart.
|
||||||
|
log.info(
|
||||||
|
"the subtitle extraction ignored TERM and was killed",
|
||||||
|
extra=logs.event(
|
||||||
|
"plex.subtitles.extract_killed_after_term",
|
||||||
|
windowed=start is not None,
|
||||||
|
),
|
||||||
|
)
|
||||||
proc.kill()
|
proc.kill()
|
||||||
_, err = proc.communicate()
|
_, err = proc.communicate()
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
+24
-12
@@ -15,20 +15,18 @@ dead `enabled` column) is a separate follow-up. Cast + intro/credit markers are
|
|||||||
item-detail/player path (P2), not here.
|
item-detail/player path (P2), not here.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app import sysconfig
|
from app import logs, sysconfig
|
||||||
from app.models import PlexCollection, PlexItem, PlexLibrary, PlexSeason, PlexShow
|
from app.models import PlexCollection, PlexItem, PlexLibrary, PlexSeason, PlexShow
|
||||||
from app.plex import detail, people
|
from app.plex import detail, people
|
||||||
from app.plex.client import PlexClient, PlexError, PlexNotConfigured
|
from app.plex.client import PlexClient, PlexError, PlexNotConfigured
|
||||||
from app.plex.lookups import by_rating_key
|
from app.plex.lookups import by_rating_key
|
||||||
|
|
||||||
log = logging.getLogger("siftlode.plex")
|
log = logs.get_logger(__name__)
|
||||||
|
|
||||||
# Codecs/containers a browser can play in a native <video> without any transcoding.
|
# Codecs/containers a browser can play in a native <video> without any transcoding.
|
||||||
_BROWSER_VIDEO = {"h264"}
|
_BROWSER_VIDEO = {"h264"}
|
||||||
_BROWSER_AUDIO = {"aac", "mp3", "opus", "vorbis"}
|
_BROWSER_AUDIO = {"aac", "mp3", "opus", "vorbis"}
|
||||||
@@ -102,6 +100,9 @@ def _video_stream_props(meta: dict) -> tuple[int | None, bool]:
|
|||||||
bd = s.get("bitDepth")
|
bd = s.get("bitDepth")
|
||||||
try:
|
try:
|
||||||
depth = int(bd) if bd else None
|
depth = int(bd) if bd else None
|
||||||
|
# silent-ok: unknown bit depth, which is also what most listings report. The
|
||||||
|
# playback class then falls back to the codec alone, and per-stream noise over a
|
||||||
|
# whole library would drown the sync's own lines.
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
depth = None
|
depth = None
|
||||||
hdr = str(s.get("colorTrc") or "").lower() in _HDR_TRANSFERS
|
hdr = str(s.get("colorTrc") or "").lower() in _HDR_TRANSFERS
|
||||||
@@ -112,6 +113,8 @@ def _video_stream_props(meta: dict) -> tuple[int | None, bool]:
|
|||||||
def _epoch(v) -> datetime | None:
|
def _epoch(v) -> datetime | None:
|
||||||
try:
|
try:
|
||||||
return datetime.fromtimestamp(int(v), tz=timezone.utc) if v else None
|
return datetime.fromtimestamp(int(v), tz=timezone.utc) if v else None
|
||||||
|
# silent-ok: a field Plex leaves out of most listing rows. None is a legitimate stored value,
|
||||||
|
# and every caller treats it as "not known" rather than as an error.
|
||||||
except (TypeError, ValueError, OSError):
|
except (TypeError, ValueError, OSError):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -120,6 +123,7 @@ def _date(v):
|
|||||||
"""Parse a Plex 'YYYY-MM-DD' string (originallyAvailableAt) → date, or None."""
|
"""Parse a Plex 'YYYY-MM-DD' string (originallyAvailableAt) → date, or None."""
|
||||||
try:
|
try:
|
||||||
return datetime.strptime(v, "%Y-%m-%d").date() if v else None
|
return datetime.strptime(v, "%Y-%m-%d").date() if v else None
|
||||||
|
# silent-ok: as `_epoch` above — an absent or unparsable air date is stored as no air date.
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -135,6 +139,7 @@ def _tags(meta: dict, key: str, limit: int | None = None) -> list | None:
|
|||||||
def _rating(meta: dict) -> float | None:
|
def _rating(meta: dict) -> float | None:
|
||||||
try:
|
try:
|
||||||
return float(meta.get("audienceRating") or meta.get("rating"))
|
return float(meta.get("audienceRating") or meta.get("rating"))
|
||||||
|
# silent-ok: TypeError on `float(None)` is the ordinary path — most items carry no rating.
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -266,15 +271,19 @@ def sync(db: Session) -> dict:
|
|||||||
# becomes filterable over successive runs (the cheap listing above only stored the top ~3).
|
# becomes filterable over successive runs (the cheap listing above only stored the top ~3).
|
||||||
try:
|
try:
|
||||||
stats["people_enriched"] = _enrich_people_batch(db, plex)
|
stats["people_enriched"] = _enrich_people_batch(db, plex)
|
||||||
except Exception as e: # best-effort — never fail the sync over the backfill
|
except Exception: # noqa: BLE001 — best-effort; never fail the sync over a backfill
|
||||||
log.warning("Plex people enrich batch failed: %s", e)
|
# With the traceback: a blind catch over a batch that fetches AND writes can be
|
||||||
|
# a Plex shape change, a DB error or our own bug, and `%s` told them apart badly.
|
||||||
|
log.exception("the Plex people-enrich batch failed")
|
||||||
# Probe a bounded batch of HW-decodable transcode items for bit-depth/HDR, so the
|
# Probe a bounded batch of HW-decodable transcode items for bit-depth/HDR, so the
|
||||||
# playback-class filter's HW/SW split is precise (a 10-bit/HDR HEVC → software) over
|
# playback-class filter's HW/SW split is precise (a 10-bit/HDR HEVC → software) over
|
||||||
# successive runs. Only HW-decodable-codec transcodes are probed — the rest are SW by codec.
|
# successive runs. Only HW-decodable-codec transcodes are probed — the rest are SW by codec.
|
||||||
try:
|
try:
|
||||||
stats["media_probed"] = _probe_media_batch(db, plex)
|
stats["media_probed"] = _probe_media_batch(db, plex)
|
||||||
except Exception as e: # best-effort — never fail the sync over the backfill
|
except Exception: # noqa: BLE001 — best-effort; never fail the sync over a backfill
|
||||||
log.warning("Plex media probe batch failed: %s", e)
|
log.exception("the Plex media-probe batch failed")
|
||||||
|
# silent-ok: the return value IS the report. `{"skipped": …}` goes to the scheduler, which
|
||||||
|
# logs the job result, and to the admin route, which answers it to the caller.
|
||||||
except PlexNotConfigured as e:
|
except PlexNotConfigured as e:
|
||||||
return {"skipped": str(e)}
|
return {"skipped": str(e)}
|
||||||
except PlexError as e:
|
except PlexError as e:
|
||||||
@@ -285,8 +294,8 @@ def sync(db: Session) -> dict:
|
|||||||
# appear in ANY item's cast/directors — drop those rows, mirroring how Plex drops orphaned metadata.
|
# appear in ANY item's cast/directors — drop those rows, mirroring how Plex drops orphaned metadata.
|
||||||
try:
|
try:
|
||||||
stats["people_pruned"] = people.prune_orphan_people(db)
|
stats["people_pruned"] = people.prune_orphan_people(db)
|
||||||
except Exception as e: # best-effort housekeeping — never fail the sync over it
|
except Exception: # noqa: BLE001 — best-effort housekeeping; never fail the sync over it
|
||||||
log.warning("Plex people prune failed: %s", e)
|
log.exception("the Plex people prune failed")
|
||||||
log.info("Plex sync done: %s", stats)
|
log.info("Plex sync done: %s", stats)
|
||||||
return stats
|
return stats
|
||||||
|
|
||||||
@@ -325,7 +334,9 @@ def _probe_media_batch(db: Session, plex: PlexClient, limit: int = _PROBE_BATCH)
|
|||||||
for row in rows:
|
for row in rows:
|
||||||
try:
|
try:
|
||||||
meta = plex.metadata(row.rating_key) or {}
|
meta = plex.metadata(row.rating_key) or {}
|
||||||
except Exception as e: # transient — leave media_probed_at NULL so it retries next run
|
# No traceback and only debug: this is per item over a whole library, and the stack is
|
||||||
|
# the same `plex.metadata` call every time. The retry next run is the handling.
|
||||||
|
except Exception as e: # noqa: BLE001 — transient; leave media_probed_at NULL to retry
|
||||||
log.debug("Plex media probe fetch failed for %s: %s", row.rating_key, e)
|
log.debug("Plex media probe fetch failed for %s: %s", row.rating_key, e)
|
||||||
continue
|
continue
|
||||||
bit_depth, hdr = _video_stream_props(meta)
|
bit_depth, hdr = _video_stream_props(meta)
|
||||||
@@ -372,7 +383,8 @@ def _enrich_people_batch(db: Session, plex: PlexClient, limit: int = _ENRICH_BAT
|
|||||||
for row in [*movies, *shows]:
|
for row in [*movies, *shows]:
|
||||||
try:
|
try:
|
||||||
meta = plex.metadata(row.rating_key) or {}
|
meta = plex.metadata(row.rating_key) or {}
|
||||||
except Exception as e:
|
# As in `_probe_media_batch` above: per item, so the message without the stack.
|
||||||
|
except Exception as e: # noqa: BLE001 — transient; leave people_enriched_at NULL
|
||||||
log.debug("Plex people enrich fetch failed for %s: %s", row.rating_key, e)
|
log.debug("Plex people enrich fetch failed for %s: %s", row.rating_key, e)
|
||||||
continue # leave people_enriched_at NULL so it's retried on a later run
|
continue # leave people_enriched_at NULL so it's retried on a later run
|
||||||
people.enrich_row_people(db, row, people.cast_from_meta(meta))
|
people.enrich_row_people(db, row, people.cast_from_meta(meta))
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ One-parser rule (mirrors app/plex/detail.py): `parse_tmdb` is the pure parser, `
|
|||||||
the row. The enrich batch + scheduler entry live here too so the whole TMDB integration is one module.
|
the row. The enrich batch + scheduler entry live here too so the whole TMDB integration is one module.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
|
||||||
import time
|
import time
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
@@ -19,12 +18,11 @@ from sqlalchemy import func, select
|
|||||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app import sysconfig
|
from app import logs, sysconfig
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.models import PlexItem, PlexPerson, PlexShow
|
from app.models import PlexItem, PlexPerson, PlexShow
|
||||||
|
|
||||||
log = logging.getLogger("siftlode.tmdb")
|
log = logs.get_logger(__name__)
|
||||||
|
|
||||||
# Politeness throttle: TMDB tolerates ~50 req/s; ~33/s leaves comfortable headroom for a shared host.
|
# Politeness throttle: TMDB tolerates ~50 req/s; ~33/s leaves comfortable headroom for a shared host.
|
||||||
_MIN_INTERVAL = 0.03
|
_MIN_INTERVAL = 0.03
|
||||||
|
|
||||||
@@ -142,6 +140,12 @@ def guid_to_tmdb_id(guid: str | None, media_type: str, tmdb: TmdbClient) -> int
|
|||||||
try:
|
try:
|
||||||
return int(ext)
|
return int(ext)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
|
# The caller stamps the watermark on a None, so this title is never looked at again:
|
||||||
|
# one unreadable guid costs it its artwork and its cast permanently.
|
||||||
|
log.info(
|
||||||
|
"a Plex guid named TMDB but did not carry a numeric id",
|
||||||
|
extra=logs.event("plex.tmdb.guid_unparsable", provider=provider),
|
||||||
|
)
|
||||||
return None
|
return None
|
||||||
source = {"imdb": "imdb_id", "tvdb": "tvdb_id"}.get(provider)
|
source = {"imdb": "imdb_id", "tvdb": "tvdb_id"}.get(provider)
|
||||||
if not source:
|
if not source:
|
||||||
@@ -499,7 +503,17 @@ def enrich_person(db: Session, person: PlexPerson) -> bool:
|
|||||||
# doesn't tie up a threadpool worker for the default 30s.
|
# doesn't tie up a threadpool worker for the default 30s.
|
||||||
with TmdbClient(db, timeout=8.0) as tmdb:
|
with TmdbClient(db, timeout=8.0) as tmdb:
|
||||||
payload = tmdb.fetch_person(person.tmdb_person_id)
|
payload = tmdb.fetch_person(person.tmdb_person_id)
|
||||||
except TmdbError:
|
except TmdbError as exc:
|
||||||
|
# False leaves the person un-enriched and unstamped, so it is retried — but the page in
|
||||||
|
# front of the user right now shows an empty biography with nothing to say why.
|
||||||
|
log.warning(
|
||||||
|
"the TMDB person lookup failed",
|
||||||
|
extra=logs.event(
|
||||||
|
"plex.tmdb.person_enrich_failed",
|
||||||
|
person_id=person.id,
|
||||||
|
reason=type(exc).__name__,
|
||||||
|
),
|
||||||
|
)
|
||||||
return False
|
return False
|
||||||
if payload is None:
|
if payload is None:
|
||||||
person.tmdb_enriched_at = datetime.now(timezone.utc)
|
person.tmdb_enriched_at = datetime.now(timezone.utc)
|
||||||
|
|||||||
@@ -17,14 +17,17 @@ and the incremental pull resolves a genuine divergence to Plex's state (see `_pu
|
|||||||
running a last-write-wins comparison.
|
running a last-write-wins comparison.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
# On the PlexError lines in this module: none of them carries a traceback, deliberately. A
|
||||||
|
# PlexError is httpx's own message — the method, the URL and the status of one call — and the stack
|
||||||
|
# beneath it is always this `except` plus `PlexClient._get`. The attrs beside the line say which
|
||||||
|
# user and which phase, which is the part the message cannot know.
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from sqlalchemy import func, select, update
|
from sqlalchemy import func, select, update
|
||||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app import sysconfig
|
from app import logs, sysconfig
|
||||||
from app.db import SessionLocal
|
from app.db import SessionLocal
|
||||||
from app.models import PlexItem, PlexLink, PlexState
|
from app.models import PlexItem, PlexLink, PlexState
|
||||||
from app.plex.client import PlexClient, PlexError, PlexNotConfigured
|
from app.plex.client import PlexClient, PlexError, PlexNotConfigured
|
||||||
@@ -36,8 +39,7 @@ from app.plex.sync import (
|
|||||||
_paginate,
|
_paginate,
|
||||||
)
|
)
|
||||||
|
|
||||||
log = logging.getLogger("siftlode.plex.watch")
|
log = logs.get_logger(__name__)
|
||||||
|
|
||||||
# A resume position below this many seconds isn't worth mirroring — matches the player's own
|
# A resume position below this many seconds isn't worth mirroring — matches the player's own
|
||||||
# _PROGRESS_MIN_S (a stray few-second seek is not "in progress").
|
# _PROGRESS_MIN_S (a stray few-second seek is not "in progress").
|
||||||
_PROGRESS_MIN_S = 5
|
_PROGRESS_MIN_S = 5
|
||||||
@@ -148,6 +150,8 @@ def import_owner_watch_state(db: Session, link: PlexLink) -> dict:
|
|||||||
link.initial_import_done = True
|
link.initial_import_done = True
|
||||||
link.last_watch_sync_at = datetime.now(timezone.utc)
|
link.last_watch_sync_at = datetime.now(timezone.utc)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
# silent-ok: the `{"skipped": …}` return is the report — the admin route answers it and the
|
||||||
|
# scheduler logs it on the job line.
|
||||||
except PlexNotConfigured as e:
|
except PlexNotConfigured as e:
|
||||||
return {"skipped": str(e)}
|
return {"skipped": str(e)}
|
||||||
except PlexError as e:
|
except PlexError as e:
|
||||||
@@ -326,8 +330,18 @@ def _resolve_account_id(db: Session, plex: PlexClient, link: PlexLink) -> int:
|
|||||||
if int(a.get("id") or -1) == 1:
|
if int(a.get("id") or -1) == 1:
|
||||||
username = a.get("name")
|
username = a.get("name")
|
||||||
break
|
break
|
||||||
except PlexError:
|
except PlexError as exc:
|
||||||
pass
|
# The fallback account id 1 is right for the admin-token setup, so sync keeps working —
|
||||||
|
# but the username is never stored, and the UI then has no name to show for the link,
|
||||||
|
# exactly as if the server had not offered one.
|
||||||
|
log.info(
|
||||||
|
"the Plex account list could not be read; the link keeps the default account",
|
||||||
|
extra=logs.event(
|
||||||
|
"plex.watch.accounts_probe_failed",
|
||||||
|
user_id=link.user_id,
|
||||||
|
reason=type(exc).__name__,
|
||||||
|
),
|
||||||
|
)
|
||||||
link.plex_account_id = acct_id
|
link.plex_account_id = acct_id
|
||||||
if username:
|
if username:
|
||||||
link.plex_username = username
|
link.plex_username = username
|
||||||
@@ -566,6 +580,7 @@ def run_plex_watch_sync(db: Session) -> dict:
|
|||||||
total["repushed"] += _repush_dirty(db, plex, link)
|
total["repushed"] += _repush_dirty(db, plex, link)
|
||||||
link.last_watch_sync_at = now
|
link.last_watch_sync_at = now
|
||||||
db.commit()
|
db.commit()
|
||||||
|
# silent-ok: the `{"skipped": …}` return is the report, as above.
|
||||||
except PlexNotConfigured as e:
|
except PlexNotConfigured as e:
|
||||||
return {"skipped": str(e)}
|
return {"skipped": str(e)}
|
||||||
except PlexError as e:
|
except PlexError as e:
|
||||||
@@ -617,6 +632,7 @@ def run_plex_watch_reconcile(db: Session) -> dict:
|
|||||||
total["repushed"] += _repush_dirty(db, plex, link)
|
total["repushed"] += _repush_dirty(db, plex, link)
|
||||||
link.last_watch_sync_at = datetime.now(timezone.utc)
|
link.last_watch_sync_at = datetime.now(timezone.utc)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
# silent-ok: the `{"skipped": …}` return is the report, as above.
|
||||||
except PlexNotConfigured as e:
|
except PlexNotConfigured as e:
|
||||||
return {"skipped": str(e)}
|
return {"skipped": str(e)}
|
||||||
except PlexError as e:
|
except PlexError as e:
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ class QuotaAction:
|
|||||||
PLAYLISTS_DELETE = "playlists_delete"
|
PLAYLISTS_DELETE = "playlists_delete"
|
||||||
VIDEOS_BACKFILL_RECENT = "videos_backfill_recent"
|
VIDEOS_BACKFILL_RECENT = "videos_backfill_recent"
|
||||||
VIDEOS_BACKFILL_FULL = "videos_backfill_full"
|
VIDEOS_BACKFILL_FULL = "videos_backfill_full"
|
||||||
|
VIDEOS_FEED_FALLBACK = "videos_feed_fallback"
|
||||||
VIDEOS_ENRICH = "videos_enrich"
|
VIDEOS_ENRICH = "videos_enrich"
|
||||||
VIDEOS_LOOKUP = "videos_lookup"
|
VIDEOS_LOOKUP = "videos_lookup"
|
||||||
VIDEOS_SEARCH = "videos_search"
|
VIDEOS_SEARCH = "videos_search"
|
||||||
|
|||||||
@@ -73,6 +73,8 @@ def _norm_ip(s: str) -> ipaddress.IPv4Address | ipaddress.IPv6Address | None:
|
|||||||
plain-IPv4 allowlist still matches a peer that surfaces in mapped form. None for a non-IP string."""
|
plain-IPv4 allowlist still matches a peer that surfaces in mapped form. None for a non-IP string."""
|
||||||
try:
|
try:
|
||||||
ip = ipaddress.ip_address(s.strip())
|
ip = ipaddress.ip_address(s.strip())
|
||||||
|
# silent-ok: parsing an untrusted header value, where "not an IP" is a documented answer this
|
||||||
|
# function returns — and a line per request is what an attacker would aim for.
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return None
|
return None
|
||||||
return ip.ipv4_mapped if getattr(ip, "ipv4_mapped", None) else ip
|
return ip.ipv4_mapped if getattr(ip, "ipv4_mapped", None) else ip
|
||||||
|
|||||||
+13
-6
@@ -13,12 +13,12 @@ mutated from a single thread.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
|
|
||||||
from starlette.websockets import WebSocket
|
from starlette.websockets import WebSocket
|
||||||
|
from app import logs
|
||||||
|
|
||||||
log = logging.getLogger("siftlode.realtime")
|
log = logs.get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class ConnectionManager:
|
class ConnectionManager:
|
||||||
@@ -42,9 +42,16 @@ class ConnectionManager:
|
|||||||
for ws in list(self._conns.get(user_id, ())):
|
for ws in list(self._conns.get(user_id, ())):
|
||||||
try:
|
try:
|
||||||
await ws.send_json(payload)
|
await ws.send_json(payload)
|
||||||
except Exception:
|
except Exception as exc: # noqa: BLE001 — one dead socket must not stop the others
|
||||||
# Stale socket; it'll be cleaned up by its own disconnect handler.
|
# Stale socket; it'll be cleaned up by its own disconnect handler. But this is
|
||||||
pass
|
# also where a live update goes missing: the UI simply never changes, and there
|
||||||
|
# is no request behind it whose failure anyone could look up.
|
||||||
|
log.debug(
|
||||||
|
"a live update could not be pushed to a socket",
|
||||||
|
extra=logs.event(
|
||||||
|
"realtime.push_failed", user_id=user_id, reason=type(exc).__name__
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
def push(self, user_id: int, payload: dict) -> None:
|
def push(self, user_id: int, payload: dict) -> None:
|
||||||
"""Schedule a push onto the WebSocket event loop. Safe to call from a sync route
|
"""Schedule a push onto the WebSocket event loop. Safe to call from a sync route
|
||||||
@@ -54,7 +61,7 @@ class ConnectionManager:
|
|||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
asyncio.run_coroutine_threadsafe(self._push(user_id, payload), loop)
|
asyncio.run_coroutine_threadsafe(self._push(user_id, payload), loop)
|
||||||
except Exception:
|
except Exception: # noqa: BLE001 — a closed loop or socket must not fail the caller
|
||||||
log.debug("ws push failed", exc_info=True)
|
log.debug("ws push failed", exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"""A request the app refuses, said in a way the UI can put in the reader's language.
|
||||||
|
|
||||||
|
Every refusal the API answers carries a structured `detail` — `{code, reason, …params}` — and the client
|
||||||
|
translates it (`lib/api/core.ts` `localizeDetail`). A bare English string reached the Hungarian UI
|
||||||
|
verbatim: the blocking error dialog, and every inline surface that printed `HttpError.detail`, showed
|
||||||
|
whatever sentence the route happened to hand-write. `refused` is the generic family
|
||||||
|
(`errors.refused.<reason>`, the params interpolated into the sentence); the older specific codes
|
||||||
|
(`not_found`, `youtube`, `playlist`, the admin refusals, …) stay, since each is already translated.
|
||||||
|
|
||||||
|
A test (`test_http_details.py`) reads every `HTTPException` in the app and fails on a string detail,
|
||||||
|
and the frontend's contract test requires every `refused(…, "<reason>")` to be named in both
|
||||||
|
languages — so the rule is enforced, not described.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
|
||||||
|
def refused(status: int, reason: str, **params: object) -> HTTPException:
|
||||||
|
"""The refusal to raise: `raise refused(409, "view_name_taken", name=name)`. `params` travel in
|
||||||
|
the detail for the sentence to interpolate (`{{name}}`); keep them to values the reader may see."""
|
||||||
|
return HTTPException(status_code=status, detail={"code": "refused", "reason": reason, **params})
|
||||||
|
|
||||||
|
|
||||||
|
# `sysconfig.InvalidSetting.reason` → the refusal it answers as. A dict of literals, so the frontend's
|
||||||
|
# contract test can read every reason this can produce.
|
||||||
|
_SETTING_REASONS = {
|
||||||
|
"not_a_number": "setting_not_a_number",
|
||||||
|
"too_low": "setting_too_low",
|
||||||
|
"too_high": "setting_too_high",
|
||||||
|
"not_a_choice": "setting_not_a_choice",
|
||||||
|
"no_encryption_key": "no_encryption_key",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def setting_refused(exc) -> HTTPException:
|
||||||
|
"""A value the admin entered that `sysconfig.set_value` refused (`exc`: its `InvalidSetting`)."""
|
||||||
|
return refused(400, _SETTING_REASONS[exc.reason], key=exc.key, **exc.params)
|
||||||
@@ -6,13 +6,16 @@ and "fetch a row this user owns or 404" idioms have one definition, one status c
|
|||||||
one existence-leak policy.
|
one existence-leak policy.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Any, TypeVar
|
from typing import IO, Any, TypeVar
|
||||||
|
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException, Request
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.db import Base
|
from app.db import Base
|
||||||
from app.models import User
|
from app.models import User
|
||||||
|
from app.issues import gitea
|
||||||
|
from app.refusal import refused
|
||||||
|
from app.youtube.client import YouTubeError, refusal_reason
|
||||||
|
|
||||||
M = TypeVar("M", bound=Base)
|
M = TypeVar("M", bound=Base)
|
||||||
|
|
||||||
@@ -25,10 +28,13 @@ def not_found(what: str = "generic") -> HTTPException:
|
|||||||
return HTTPException(status_code=404, detail={"code": "not_found", "reason": what})
|
return HTTPException(status_code=404, detail={"code": "not_found", "reason": what})
|
||||||
|
|
||||||
|
|
||||||
def get_or_404(db: Session, model: type[M], id_: Any, *, what: str = "generic") -> M:
|
def get_or_404(
|
||||||
|
db: Session, model: type[M], id_: Any, *, what: str = "generic", lock: bool = False
|
||||||
|
) -> M:
|
||||||
"""Fetch a row by primary key or raise 404. Replaces the `db.get(Model, id); if None: 404`
|
"""Fetch a row by primary key or raise 404. Replaces the `db.get(Model, id); if None: 404`
|
||||||
idiom repeated across routes."""
|
idiom repeated across routes. `lock` takes it FOR UPDATE (bypassing the identity map), so two
|
||||||
obj = db.get(model, id_)
|
requests that read-then-write its children (a playlist's item positions) run one after the other."""
|
||||||
|
obj = db.get(model, id_, with_for_update=True if lock else None)
|
||||||
if obj is None:
|
if obj is None:
|
||||||
raise not_found(what)
|
raise not_found(what)
|
||||||
return obj
|
return obj
|
||||||
@@ -42,12 +48,98 @@ def owned_or_404(
|
|||||||
*,
|
*,
|
||||||
owner_attr: str = "user_id",
|
owner_attr: str = "user_id",
|
||||||
what: str = "generic",
|
what: str = "generic",
|
||||||
|
lock: bool = False,
|
||||||
) -> M:
|
) -> M:
|
||||||
"""Fetch a row by primary key that `user` owns, or raise 404. A row owned by someone
|
"""Fetch a row by primary key that `user` owns, or raise 404 (`lock`: see `get_or_404`). A row owned by someone
|
||||||
else 404s (NOT 403) on purpose — a 403 would confirm the id exists to a stranger.
|
else 404s (NOT 403) on purpose — a 403 would confirm the id exists to a stranger.
|
||||||
Generalizes the near-identical per-route ownership helpers (e.g. plex's
|
It replaced the near-identical per-route ownership helpers, and is called directly — a thin
|
||||||
`_own_playlist_or_404`, the saved-views owner check)."""
|
`_own_x` wrapper around it is the ad-hoc shape CLAUDE.md's patterns table rules out."""
|
||||||
obj = get_or_404(db, model, id_, what=what)
|
obj = get_or_404(db, model, id_, what=what, lock=lock)
|
||||||
if getattr(obj, owner_attr) != user.id:
|
if getattr(obj, owner_attr) != user.id:
|
||||||
raise not_found(what)
|
raise not_found(what)
|
||||||
return obj
|
return obj
|
||||||
|
|
||||||
|
|
||||||
|
def youtube_refusal(exc: Exception) -> HTTPException:
|
||||||
|
"""The answer to an action YouTube refused: a 422 whose reason the UI translates
|
||||||
|
(`errors.youtube.<reason>`). One definition, because the routes had grown three shapes of it —
|
||||||
|
a bare English string quoting the upstream message, a playlist-scoped reason, and nothing at
|
||||||
|
all (a raw 500 from the manual syncs). The upstream failure is already logged where it
|
||||||
|
happened (`youtube.api.error`). A failure that is not the Data API's (the search scraper's) is
|
||||||
|
YouTube being unavailable to us — the retryable answer."""
|
||||||
|
reason = refusal_reason(exc) if isinstance(exc, YouTubeError) else "unavailable"
|
||||||
|
return HTTPException(status_code=422, detail={"code": "youtube", "reason": reason})
|
||||||
|
|
||||||
|
|
||||||
|
async def capped_body(request: Request, limit: int) -> bytes:
|
||||||
|
"""The request body, refused (413 `too_large`) the moment it passes `limit` bytes while still
|
||||||
|
arriving. Counted, not taken from Content-Length: a declared length can be absent (chunked) or
|
||||||
|
wrong, and counting stops reading at the same point a declared-length check would have."""
|
||||||
|
body = bytearray()
|
||||||
|
async for chunk in request.stream():
|
||||||
|
body += chunk
|
||||||
|
if len(body) > limit:
|
||||||
|
raise HTTPException(status_code=413, detail={"code": "too_large"})
|
||||||
|
return bytes(body)
|
||||||
|
|
||||||
|
|
||||||
|
async def capped_stream_to(
|
||||||
|
request: Request, out: IO[bytes], limit: int, refusal: HTTPException
|
||||||
|
) -> int:
|
||||||
|
"""`capped_body`'s sibling for a body too large to hold: the same counting rule, written
|
||||||
|
straight to `out` instead of accumulated, returning the size.
|
||||||
|
|
||||||
|
Two deliberate differences from `capped_body`. It never builds a `bytearray` — the upload route
|
||||||
|
admits 500 MiB by default, so holding the body would trade a disk problem for a memory one. And
|
||||||
|
the refusal is the CALLER'S, passed in rather than assumed: `capped_body`'s flat 413 is right
|
||||||
|
for a screenshot, while the upload route answers 422 `upload_too_large` carrying the admin
|
||||||
|
knob's own number, which is what its dialog shows the user. One counting rule, two answers,
|
||||||
|
neither of them a second copy of the loop.
|
||||||
|
|
||||||
|
The caller owns `out` and owns cleaning it up: a body refused half-way has already written what
|
||||||
|
arrived before the limit was passed.
|
||||||
|
|
||||||
|
**The refusal DRAINS the rest of the body before it is raised, and that is not politeness.**
|
||||||
|
Answering mid-upload and closing the connection is what the client sees as a network failure,
|
||||||
|
not as a refusal: the browser's XHR fires `onerror` before it can read the response, so a file
|
||||||
|
over the limit reported itself as "the server couldn't be reached" instead of naming the limit —
|
||||||
|
measured in a real browser, not reasoned about. Reading the remainder to nowhere lets the client
|
||||||
|
finish sending, read the 422 and say the true thing.
|
||||||
|
|
||||||
|
The bytes are discarded, never written, and the whole request is already bounded by
|
||||||
|
`bodylimit`'s per-route ceiling — so the worst case is bandwidth an authenticated caller was
|
||||||
|
sending anyway, against the old behaviour which wrote exactly those bytes to disk."""
|
||||||
|
size = 0
|
||||||
|
over = False
|
||||||
|
async for chunk in request.stream():
|
||||||
|
size += len(chunk)
|
||||||
|
if over:
|
||||||
|
continue # draining — see the docstring; counted, not stored
|
||||||
|
if size > limit:
|
||||||
|
over = True
|
||||||
|
continue
|
||||||
|
out.write(chunk)
|
||||||
|
if over:
|
||||||
|
raise refusal
|
||||||
|
return size
|
||||||
|
|
||||||
|
|
||||||
|
def issue_refused(exc: gitea.GiteaError) -> HTTPException:
|
||||||
|
"""The refusal an issue-tracker failure (E16) answers as — the send, the preview and the
|
||||||
|
admin's connection test alike. Literal reasons, one per case: the frontend's contract test reads
|
||||||
|
them to require a sentence for each in both languages."""
|
||||||
|
match exc.reason:
|
||||||
|
case "not_configured":
|
||||||
|
return refused(400, "issues_not_configured")
|
||||||
|
case "unreachable":
|
||||||
|
return refused(422, "issues_unreachable")
|
||||||
|
case "bad_token":
|
||||||
|
return refused(422, "issues_bad_token")
|
||||||
|
case "repo_not_found":
|
||||||
|
return refused(422, "issues_repo_not_found")
|
||||||
|
case "public_repo":
|
||||||
|
return refused(422, "issues_public_repo")
|
||||||
|
case "issues_disabled":
|
||||||
|
return refused(422, "issues_disabled")
|
||||||
|
case _:
|
||||||
|
return refused(422, "issues_rejected")
|
||||||
|
|||||||
+27
-25
@@ -39,6 +39,7 @@ from app.routes.auth import (
|
|||||||
send_verification_link,
|
send_verification_link,
|
||||||
)
|
)
|
||||||
from app.utils import canonical_email, iso, valid_email
|
from app.utils import canonical_email, iso, valid_email
|
||||||
|
from app.refusal import refused
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/admin", tags=["admin"])
|
router = APIRouter(prefix="/api/admin", tags=["admin"])
|
||||||
|
|
||||||
@@ -169,7 +170,7 @@ def _activate_user_by_email(db: Session, email: str) -> None:
|
|||||||
user = next((u for u in candidates if u.email == email), None) or next(iter(candidates), None)
|
user = next((u for u in candidates if u.email == email), None) or next(iter(candidates), None)
|
||||||
if user is not None and not user.is_active:
|
if user is not None and not user.is_active:
|
||||||
user.is_active = True
|
user.is_active = True
|
||||||
db.commit()
|
db.flush() # the handler commits (CLAUDE.md: helpers flush)
|
||||||
|
|
||||||
|
|
||||||
def _decide(db: Session, invite_id: int, admin: User, approved: bool) -> Invite:
|
def _decide(db: Session, invite_id: int, admin: User, approved: bool) -> Invite:
|
||||||
@@ -179,7 +180,7 @@ def _decide(db: Session, invite_id: int, admin: User, approved: bool) -> Invite:
|
|||||||
# sending a duplicate decision mail, even flipping an already-approved invite to "denied" and
|
# sending a duplicate decision mail, even flipping an already-approved invite to "denied" and
|
||||||
# mailing a rejection to an already-active account.
|
# mailing a rejection to an already-active account.
|
||||||
if inv.status != "pending":
|
if inv.status != "pending":
|
||||||
raise HTTPException(status_code=409, detail="Invite already decided")
|
raise refused(409, "invite_decided")
|
||||||
inv.status = "approved" if approved else "denied"
|
inv.status = "approved" if approved else "denied"
|
||||||
inv.decided_at = datetime.now(timezone.utc)
|
inv.decided_at = datetime.now(timezone.utc)
|
||||||
inv.decided_by = admin.email
|
inv.decided_by = admin.email
|
||||||
@@ -191,7 +192,7 @@ def _decide(db: Session, invite_id: int, admin: User, approved: bool) -> Invite:
|
|||||||
target_id=inv.id,
|
target_id=inv.id,
|
||||||
summary=inv.email,
|
summary=inv.email,
|
||||||
)
|
)
|
||||||
db.commit()
|
db.flush() # the handler commits, with the activation that goes with it
|
||||||
return inv
|
return inv
|
||||||
|
|
||||||
|
|
||||||
@@ -210,6 +211,9 @@ def approve_invite(
|
|||||||
link, has_account, hours = _welcome_mail_parts(db, inv.email)
|
link, has_account, hours = _welcome_mail_parts(db, inv.email)
|
||||||
lang = mailtext.lang_for_email(db, inv.email)
|
lang = mailtext.lang_for_email(db, inv.email)
|
||||||
background.add_task(email_mod.send_access_approved, inv.email, lang, link, has_account, hours)
|
background.add_task(email_mod.send_access_approved, inv.email, lang, link, has_account, hours)
|
||||||
|
# Before the response, so the mail (a background task, after it) never describes a decision
|
||||||
|
# the database does not hold yet.
|
||||||
|
db.commit()
|
||||||
return _serialize(inv, has_account=bool(_accounts_for(db, [inv.email])))
|
return _serialize(inv, has_account=bool(_accounts_for(db, [inv.email])))
|
||||||
|
|
||||||
|
|
||||||
@@ -226,6 +230,7 @@ def deny_invite(
|
|||||||
background.add_task(
|
background.add_task(
|
||||||
email_mod.send_access_denied, inv.email, mailtext.lang_for_email(db, inv.email)
|
email_mod.send_access_denied, inv.email, mailtext.lang_for_email(db, inv.email)
|
||||||
)
|
)
|
||||||
|
db.commit()
|
||||||
return _serialize(inv, has_account=bool(_accounts_for(db, [inv.email])))
|
return _serialize(inv, has_account=bool(_accounts_for(db, [inv.email])))
|
||||||
|
|
||||||
|
|
||||||
@@ -242,7 +247,7 @@ def add_invite(
|
|||||||
"""Manually whitelist an email (approved straight away). Convenience for the admin."""
|
"""Manually whitelist an email (approved straight away). Convenience for the admin."""
|
||||||
email = body.email.strip().lower()
|
email = body.email.strip().lower()
|
||||||
if not valid_email(email):
|
if not valid_email(email):
|
||||||
raise HTTPException(status_code=400, detail="Enter a valid email address.")
|
raise refused(400, "invalid_email")
|
||||||
# Reuse a canonically-equal row instead of adding a second one for the same mailbox. Prod carries
|
# Reuse a canonically-equal row instead of adding a second one for the same mailbox. Prod carries
|
||||||
# exactly that pair for one person from 2026-06-16 (hartmann.peter1986@ / hartmannpeter1986@),
|
# exactly that pair for one person from 2026-06-16 (hartmann.peter1986@ / hartmannpeter1986@),
|
||||||
# which is how an admin ends up approving the same human twice and still seeing them bounce.
|
# which is how an admin ends up approving the same human twice and still seeing them bounce.
|
||||||
@@ -259,11 +264,13 @@ def add_invite(
|
|||||||
inv.status = "approved"
|
inv.status = "approved"
|
||||||
inv.decided_at = datetime.now(timezone.utc)
|
inv.decided_at = datetime.now(timezone.utc)
|
||||||
inv.decided_by = admin.email
|
inv.decided_by = admin.email
|
||||||
|
db.flush() # a new invite's id, for the audit row — before it, the row named no target
|
||||||
audit.record(
|
audit.record(
|
||||||
db, admin.id, AuditAction.INVITE_ADD, target_type="invite", target_id=inv.id, summary=email
|
db, admin.id, AuditAction.INVITE_ADD, target_type="invite", target_id=inv.id, summary=email
|
||||||
)
|
)
|
||||||
db.commit()
|
# One transaction: the whitelisting and the activation it implies land together or not at all.
|
||||||
_activate_user_by_email(db, email)
|
_activate_user_by_email(db, email)
|
||||||
|
db.commit()
|
||||||
return _serialize(inv, has_account=bool(_accounts_for(db, [email])))
|
return _serialize(inv, has_account=bool(_accounts_for(db, [email])))
|
||||||
|
|
||||||
|
|
||||||
@@ -366,10 +373,7 @@ def delete_invite(
|
|||||||
promoted to admin on its next Google sign-in). The admin has to hear that, not guess it."""
|
promoted to admin on its next Google sign-in). The admin has to hear that, not guess it."""
|
||||||
inv = get_or_404(db, Invite, invite_id, what="invite")
|
inv = get_or_404(db, Invite, invite_id, what="invite")
|
||||||
if _accounts_for(db, [inv.email]):
|
if _accounts_for(db, [inv.email]):
|
||||||
raise HTTPException(
|
raise refused(409, "address_has_account")
|
||||||
status_code=409,
|
|
||||||
detail="That address already has an account — delete the account instead.",
|
|
||||||
)
|
|
||||||
still_allowed_by_env = _env_allowed(inv.email)
|
still_allowed_by_env = _env_allowed(inv.email)
|
||||||
audit.record(
|
audit.record(
|
||||||
db,
|
db,
|
||||||
@@ -435,19 +439,19 @@ def set_user_role(
|
|||||||
account, and can't remove the last remaining admin. Emails the user when it actually changes."""
|
account, and can't remove the last remaining admin. Emails the user when it actually changes."""
|
||||||
role = body.role
|
role = body.role
|
||||||
if role not in ("user", "admin"):
|
if role not in ("user", "admin"):
|
||||||
raise HTTPException(status_code=400, detail="role must be 'user' or 'admin'")
|
raise refused(400, "invalid_role")
|
||||||
target = get_or_404(db, User, user_id, what="user")
|
target = get_or_404(db, User, user_id, what="user")
|
||||||
if target.is_demo:
|
if target.is_demo:
|
||||||
raise HTTPException(status_code=400, detail="The demo account's role can't be changed.")
|
raise refused(400, "demo_role")
|
||||||
if target.id == admin.id:
|
if target.id == admin.id:
|
||||||
raise HTTPException(status_code=400, detail="You can't change your own role.")
|
raise refused(400, "own_role")
|
||||||
if (
|
if (
|
||||||
target.role == "admin"
|
target.role == "admin"
|
||||||
and role == "user"
|
and role == "user"
|
||||||
and not target.is_suspended
|
and not target.is_suspended
|
||||||
and count_admins(db, active_only=True) <= 1
|
and count_admins(db, active_only=True) <= 1
|
||||||
):
|
):
|
||||||
raise HTTPException(status_code=400, detail="Can't remove the last admin.")
|
raise refused(400, "last_admin")
|
||||||
changed = target.role != role
|
changed = target.role != role
|
||||||
if changed:
|
if changed:
|
||||||
audit.record(
|
audit.record(
|
||||||
@@ -493,16 +497,16 @@ def set_user_suspended(
|
|||||||
suspended = body.suspended
|
suspended = body.suspended
|
||||||
target = get_or_404(db, User, user_id, what="user")
|
target = get_or_404(db, User, user_id, what="user")
|
||||||
if target.is_demo:
|
if target.is_demo:
|
||||||
raise HTTPException(status_code=400, detail="The demo account can't be suspended.")
|
raise refused(400, "demo_suspend")
|
||||||
if target.id == admin.id:
|
if target.id == admin.id:
|
||||||
raise HTTPException(status_code=400, detail="You can't suspend your own account.")
|
raise refused(400, "own_suspend")
|
||||||
if (
|
if (
|
||||||
suspended
|
suspended
|
||||||
and target.role == "admin"
|
and target.role == "admin"
|
||||||
and not target.is_suspended
|
and not target.is_suspended
|
||||||
and count_admins(db, active_only=True) <= 1
|
and count_admins(db, active_only=True) <= 1
|
||||||
):
|
):
|
||||||
raise HTTPException(status_code=400, detail="Can't suspend the last active admin.")
|
raise refused(400, "last_active_admin")
|
||||||
was_suspended = target.is_suspended
|
was_suspended = target.is_suspended
|
||||||
if was_suspended != suspended:
|
if was_suspended != suspended:
|
||||||
audit.record(
|
audit.record(
|
||||||
@@ -537,17 +541,15 @@ def admin_delete_user(
|
|||||||
not the demo account, not yourself (use Settings → Account), and not the last admin."""
|
not the demo account, not yourself (use Settings → Account), and not the last admin."""
|
||||||
target = get_or_404(db, User, user_id, what="user")
|
target = get_or_404(db, User, user_id, what="user")
|
||||||
if target.is_demo:
|
if target.is_demo:
|
||||||
raise HTTPException(status_code=400, detail="The demo account can't be deleted.")
|
raise refused(400, "demo_delete")
|
||||||
if target.id == admin.id:
|
if target.id == admin.id:
|
||||||
raise HTTPException(
|
raise refused(400, "own_delete")
|
||||||
status_code=400, detail="Delete your own account from Settings → Account."
|
|
||||||
)
|
|
||||||
if (
|
if (
|
||||||
target.role == "admin"
|
target.role == "admin"
|
||||||
and not target.is_suspended
|
and not target.is_suspended
|
||||||
and count_admins(db, active_only=True) <= 1
|
and count_admins(db, active_only=True) <= 1
|
||||||
):
|
):
|
||||||
raise HTTPException(status_code=400, detail="Can't delete the last admin.")
|
raise refused(400, "last_admin")
|
||||||
# Record BEFORE the purge — the target row (and its email) is gone afterwards.
|
# Record BEFORE the purge — the target row (and its email) is gone afterwards.
|
||||||
audit.record(
|
audit.record(
|
||||||
db,
|
db,
|
||||||
@@ -691,7 +693,7 @@ def activate_user(
|
|||||||
`_activate_user_by_email`). What was missing was any way to know they had to."""
|
`_activate_user_by_email`). What was missing was any way to know they had to."""
|
||||||
target = get_or_404(db, User, user_id, what="user")
|
target = get_or_404(db, User, user_id, what="user")
|
||||||
if target.is_demo:
|
if target.is_demo:
|
||||||
raise HTTPException(status_code=400, detail="The demo account can't be activated.")
|
raise refused(400, "demo_activate")
|
||||||
if target.is_active:
|
if target.is_active:
|
||||||
return _serialize_user(target) # idempotent: a double click is not an error
|
return _serialize_user(target) # idempotent: a double click is not an error
|
||||||
if not is_allowed(db, target.email):
|
if not is_allowed(db, target.email):
|
||||||
@@ -788,7 +790,7 @@ def add_demo_whitelist(
|
|||||||
sign-in). Idempotent — re-adding an existing email just returns it."""
|
sign-in). Idempotent — re-adding an existing email just returns it."""
|
||||||
email = body.email.strip().lower()
|
email = body.email.strip().lower()
|
||||||
if not valid_email(email):
|
if not valid_email(email):
|
||||||
raise HTTPException(status_code=400, detail="Enter a valid email address.")
|
raise refused(400, "invalid_email")
|
||||||
row = db.execute(select(DemoWhitelist).where(DemoWhitelist.email == email)).scalar_one_or_none()
|
row = db.execute(select(DemoWhitelist).where(DemoWhitelist.email == email)).scalar_one_or_none()
|
||||||
if row is None:
|
if row is None:
|
||||||
row = DemoWhitelist(
|
row = DemoWhitelist(
|
||||||
@@ -835,7 +837,7 @@ def reset_demo(admin: User = Depends(admin_user), db: Session = Depends(get_db))
|
|||||||
db, admin.id, AuditAction.DEMO_RESET, summary=f"{seeded} sample playlists re-seeded"
|
db, admin.id, AuditAction.DEMO_RESET, summary=f"{seeded} sample playlists re-seeded"
|
||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"reset": True, "playlists_seeded": seeded}
|
return {"ok": True, "reset": True, "playlists_seeded": seeded}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/purge-discovery")
|
@router.post("/purge-discovery")
|
||||||
@@ -854,4 +856,4 @@ def purge_discovery(admin: User = Depends(admin_user), db: Session = Depends(get
|
|||||||
after=result,
|
after=result,
|
||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
return result
|
return {"ok": True, **result}
|
||||||
|
|||||||
@@ -0,0 +1,216 @@
|
|||||||
|
"""The admin diagnostics console's read side (E22.6): query `log_events`, the server's stored log.
|
||||||
|
|
||||||
|
Admin-only and read-only — rows leave the table through the retention job (`log_retention_days`),
|
||||||
|
never through here. Reading is not audited: it changes nothing, and the console's live tail polls,
|
||||||
|
so a row per read would bury the audit log's real entries.
|
||||||
|
|
||||||
|
What a row holds was redacted when it was written (`logs.record_fields`, and the ingest route for a
|
||||||
|
browser's rows), so this serves the columns as they are; a value the redactor took out reads as
|
||||||
|
`[redacted]` / `[email]`, which is the point — the console shows that something was there.
|
||||||
|
|
||||||
|
Paging is a keyset over `(ts desc, id desc)`: a browser row's `ts` is clamped into its consent
|
||||||
|
window, so `ts` alone is not unique, and an offset would skip or repeat rows while the writer adds
|
||||||
|
new ones at the top. The live tail re-reads the first page rather than asking for "ids after N":
|
||||||
|
several processes write this table, and a batch's ids are taken when it inserts, not when it
|
||||||
|
commits, so a slower batch can land below an id the tail already passed — and would never show.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import binascii
|
||||||
|
import json
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from sqlalchemy import Text, and_, cast, func, or_, select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app import logsink
|
||||||
|
from app.auth import admin_user
|
||||||
|
from app.db import get_db
|
||||||
|
from app.models import LogEvent, User
|
||||||
|
from app.utils import like_escape
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/admin/logs", tags=["admin"])
|
||||||
|
|
||||||
|
PAGE_MAX = 200
|
||||||
|
# A facet list is a chip row, not a report: past this many distinct events the rest are reachable
|
||||||
|
# through the free-text search, and the response stays small whatever a self-host logs.
|
||||||
|
FACET_EVENTS_MAX = 100
|
||||||
|
|
||||||
|
Level = Literal["TRACE", "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
|
||||||
|
# The filters a facet can leave out of its own count (`LogFilters.conditions`).
|
||||||
|
Facet = Literal["levels", "sources", "user", "event"]
|
||||||
|
# `none` selects the rows that have no account / no event code (a scheduler line, an anonymous
|
||||||
|
# request) — a real value to filter on, not the absence of a filter.
|
||||||
|
NONE = "none"
|
||||||
|
|
||||||
|
|
||||||
|
def _utc(value: datetime | None) -> datetime | None:
|
||||||
|
"""A bound without a zone is UTC — the column is `timestamptz`, and a naive value would be read
|
||||||
|
in the database session's zone instead."""
|
||||||
|
if value is None or value.tzinfo is not None:
|
||||||
|
return value
|
||||||
|
return value.replace(tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _encode_cursor(row: LogEvent) -> str:
|
||||||
|
return base64.urlsafe_b64encode(json.dumps([row.ts.isoformat(), row.id]).encode()).decode()
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_cursor(cursor: str) -> tuple[datetime, int]:
|
||||||
|
try:
|
||||||
|
ts, row_id = json.loads(base64.urlsafe_b64decode(cursor.encode()))
|
||||||
|
return _utc(datetime.fromisoformat(ts)), int(row_id)
|
||||||
|
except (binascii.Error, json.JSONDecodeError, ValueError, TypeError) as exc:
|
||||||
|
raise HTTPException(status_code=400, detail={"code": "bad_cursor"}) from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _accounts(db: Session, user_ids: set[int]) -> dict[str, str]:
|
||||||
|
"""The accounts a page names, by id → address. An id with no account left (erased after the
|
||||||
|
line was written) is simply absent, and the client shows the bare id."""
|
||||||
|
if not user_ids:
|
||||||
|
return {}
|
||||||
|
rows = db.execute(select(User.id, User.email).where(User.id.in_(user_ids))).all()
|
||||||
|
return {str(uid): email for uid, email in rows}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class LogFilters:
|
||||||
|
"""The console's filters, shared by the page and its facet counts."""
|
||||||
|
|
||||||
|
levels: list[str]
|
||||||
|
sources: list[str]
|
||||||
|
user: str | None
|
||||||
|
event: str | None
|
||||||
|
request_id: str | None
|
||||||
|
job_id: int | None
|
||||||
|
q: str | None
|
||||||
|
since: datetime | None
|
||||||
|
until: datetime | None
|
||||||
|
|
||||||
|
def conditions(self, skip: Facet | None = None) -> list:
|
||||||
|
"""The WHERE conditions, ANDed by the caller. `skip` leaves one filter out: a facet counts
|
||||||
|
its own values under every OTHER filter, so picking a level does not zero the other
|
||||||
|
levels' counts (the way back), while a request pivot does narrow them (honest counts)."""
|
||||||
|
out = []
|
||||||
|
if self.levels and skip != "levels":
|
||||||
|
out.append(LogEvent.level.in_(self.levels))
|
||||||
|
if self.sources and skip != "sources":
|
||||||
|
out.append(LogEvent.source.in_(self.sources))
|
||||||
|
if self.user is not None and skip != "user":
|
||||||
|
out.append(
|
||||||
|
LogEvent.user_id.is_(None)
|
||||||
|
if self.user == NONE
|
||||||
|
else LogEvent.user_id == int(self.user)
|
||||||
|
)
|
||||||
|
if self.event is not None and skip != "event":
|
||||||
|
out.append(
|
||||||
|
LogEvent.event.is_(None) if self.event == NONE else LogEvent.event == self.event
|
||||||
|
)
|
||||||
|
if self.request_id is not None:
|
||||||
|
out.append(LogEvent.request_id == self.request_id)
|
||||||
|
if self.job_id is not None:
|
||||||
|
out.append(LogEvent.job_id == self.job_id)
|
||||||
|
if self.since is not None:
|
||||||
|
out.append(LogEvent.ts >= _utc(self.since))
|
||||||
|
if self.until is not None:
|
||||||
|
out.append(LogEvent.ts < _utc(self.until))
|
||||||
|
if self.q and self.q.strip():
|
||||||
|
like = f"%{like_escape(self.q.strip())}%"
|
||||||
|
out.append(
|
||||||
|
or_(
|
||||||
|
LogEvent.msg.ilike(like),
|
||||||
|
LogEvent.event.ilike(like),
|
||||||
|
LogEvent.request_id.ilike(like),
|
||||||
|
LogEvent.exc.ilike(like),
|
||||||
|
cast(LogEvent.attrs, Text).ilike(like),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def log_filters(
|
||||||
|
levels: list[Level] = Query(default=[]),
|
||||||
|
sources: list[str] = Query(default=[], max_length=8),
|
||||||
|
user: str | None = Query(default=None, pattern=r"^(none|[0-9]{1,10})$"),
|
||||||
|
event: str | None = Query(
|
||||||
|
default=None, max_length=64, pattern=r"^(none|[a-z][a-z0-9_]*(\.[a-z0-9_]+)*)$"
|
||||||
|
),
|
||||||
|
request_id: str | None = Query(default=None, pattern=r"^[A-Za-z0-9._-]{8,64}$"),
|
||||||
|
job_id: int | None = Query(default=None, ge=1),
|
||||||
|
q: str | None = Query(default=None, max_length=200),
|
||||||
|
since: datetime | None = None,
|
||||||
|
until: datetime | None = None,
|
||||||
|
) -> LogFilters:
|
||||||
|
return LogFilters(list(levels), list(sources), user, event, request_id, job_id, q, since, until)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
def list_logs(
|
||||||
|
filters: LogFilters = Depends(log_filters),
|
||||||
|
before: str | None = Query(default=None, max_length=200),
|
||||||
|
limit: int = Query(default=100, ge=1, le=PAGE_MAX),
|
||||||
|
_: User = Depends(admin_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
"""One page of rows, newest first, matching every filter given (they AND together); `before`
|
||||||
|
continues from a previous page's `next_cursor`."""
|
||||||
|
query = select(LogEvent).where(*filters.conditions())
|
||||||
|
if before is not None:
|
||||||
|
ts, row_id = _decode_cursor(before)
|
||||||
|
# The `ts <=` conjunct is implied by the OR; it is there so the planner can bound the
|
||||||
|
# `ts` index scan instead of filtering every newer row first.
|
||||||
|
query = query.where(
|
||||||
|
and_(
|
||||||
|
LogEvent.ts <= ts,
|
||||||
|
or_(LogEvent.ts < ts, and_(LogEvent.ts == ts, LogEvent.id < row_id)),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# One more than asked, to know whether there is a next page without a second count query.
|
||||||
|
rows = (
|
||||||
|
db.execute(query.order_by(LogEvent.ts.desc(), LogEvent.id.desc()).limit(limit + 1))
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
more = len(rows) > limit
|
||||||
|
rows = rows[:limit]
|
||||||
|
return {
|
||||||
|
"items": [logsink.serialize(r) for r in rows],
|
||||||
|
"accounts": _accounts(db, {r.user_id for r in rows if r.user_id is not None}),
|
||||||
|
"next_cursor": _encode_cursor(rows[-1]) if more else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/facets")
|
||||||
|
def log_facets(
|
||||||
|
filters: LogFilters = Depends(log_filters),
|
||||||
|
_: User = Depends(admin_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
"""What the filter chips offer, with counts: the levels, sources and accounts present, and the
|
||||||
|
most frequent events — each counted under every filter but its own (`LogFilters.conditions`).
|
||||||
|
`total` is the "all levels" count: every filter but the level one."""
|
||||||
|
|
||||||
|
def counts(col, skip: Facet, cap: int | None = None) -> list[dict]:
|
||||||
|
n = func.count().label("n")
|
||||||
|
stmt = select(col, n).where(*filters.conditions(skip)).group_by(col).order_by(n.desc(), col)
|
||||||
|
if cap is not None:
|
||||||
|
stmt = stmt.limit(cap)
|
||||||
|
return [
|
||||||
|
{"value": NONE if value is None else value, "count": count}
|
||||||
|
for value, count in db.execute(stmt).all()
|
||||||
|
]
|
||||||
|
|
||||||
|
users = counts(LogEvent.user_id, "user")
|
||||||
|
total = select(func.count()).select_from(LogEvent).where(*filters.conditions("levels"))
|
||||||
|
return {
|
||||||
|
"levels": counts(LogEvent.level, "levels"),
|
||||||
|
"sources": counts(LogEvent.source, "sources"),
|
||||||
|
"events": counts(LogEvent.event, "event", FACET_EVENTS_MAX),
|
||||||
|
"users": [{**u, "value": str(u["value"])} for u in users],
|
||||||
|
"accounts": _accounts(db, {u["value"] for u in users if u["value"] != NONE}),
|
||||||
|
"total": db.scalar(total) or 0,
|
||||||
|
}
|
||||||
@@ -68,4 +68,4 @@ def clear_audit(admin: User = Depends(admin_user), db: Session = Depends(get_db)
|
|||||||
n = db.execute(delete(AuditLog)).rowcount or 0
|
n = db.execute(delete(AuditLog)).rowcount or 0
|
||||||
audit.record(db, admin.id, AuditAction.AUDIT_CLEAR, summary=f"Cleared {n} audit entries")
|
audit.record(db, admin.id, AuditAction.AUDIT_CLEAR, summary=f"Cleared {n} audit entries")
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"cleared": n}
|
return {"ok": True, "cleared": n}
|
||||||
|
|||||||
+61
-37
@@ -17,6 +17,7 @@ from sqlalchemy.exc import IntegrityError
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app import email as email_mod
|
from app import email as email_mod
|
||||||
|
from app import logs
|
||||||
from app import mailtext
|
from app import mailtext
|
||||||
from app import sysconfig
|
from app import sysconfig
|
||||||
from app.auth import (
|
from app.auth import (
|
||||||
@@ -38,10 +39,11 @@ from app.auth import (
|
|||||||
)
|
)
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.db import SessionLocal, get_db
|
from app.db import SessionLocal, get_db
|
||||||
from app.models import AuthToken, DemoWhitelist, Invite, OAuthToken, User
|
from app.models import AuthToken, DemoWhitelist, Invite, OAuthToken, User, display_text
|
||||||
from app.ratelimit import RateLimiter, client_key
|
from app.ratelimit import RateLimiter, client_key
|
||||||
from app.security import encrypt, hash_password, hash_token, verify_password
|
from app.security import encrypt, hash_password, hash_token, verify_password
|
||||||
from app.utils import canonical_email, valid_email
|
from app.utils import canonical_email, valid_email
|
||||||
|
from app.refusal import refused
|
||||||
|
|
||||||
VERIFY_TTL = timedelta(hours=24)
|
VERIFY_TTL = timedelta(hours=24)
|
||||||
# A confirmation link that rides an APPROVAL or an invitation lives longer than one the person is
|
# A confirmation link that rides an APPROVAL or an invitation lives longer than one the person is
|
||||||
@@ -377,7 +379,7 @@ async def callback(
|
|||||||
|
|
||||||
userinfo = token.get("userinfo")
|
userinfo = token.get("userinfo")
|
||||||
if not userinfo or not userinfo.get("sub"):
|
if not userinfo or not userinfo.get("sub"):
|
||||||
raise HTTPException(status_code=400, detail="No user info returned by Google")
|
raise refused(400, "google_no_userinfo")
|
||||||
|
|
||||||
sub = userinfo["sub"]
|
sub = userinfo["sub"]
|
||||||
email = (userinfo.get("email") or "").lower()
|
email = (userinfo.get("email") or "").lower()
|
||||||
@@ -386,7 +388,15 @@ async def callback(
|
|||||||
return _complete_link(request, db, link_uid, link_explicit, sub, userinfo, token)
|
return _complete_link(request, db, link_uid, link_explicit, sub, userinfo, token)
|
||||||
|
|
||||||
if not is_allowed(db, email):
|
if not is_allowed(db, email):
|
||||||
log.warning("Login denied (not approved): %s", email or "<no email>")
|
# No uid to name — having no account here is the branch. And the address is left out on
|
||||||
|
# purpose: `redact_text` turns it into `[email]` before either sink sees it, so a line
|
||||||
|
# whose only variable is always `[email]` says nothing. WHO asked is in the pending
|
||||||
|
# invite recorded just below, which is where an admin looks anyway.
|
||||||
|
log.warning(
|
||||||
|
"Google sign-in denied: address not approved",
|
||||||
|
# log-pii-ok: whether an address came back, not what it was
|
||||||
|
extra=logs.event("auth.login.denied", reason="not_approved", has_email=bool(email)),
|
||||||
|
)
|
||||||
# A denied Google login doubles as an access request: record it for the admin and
|
# A denied Google login doubles as an access request: record it for the admin and
|
||||||
# land the user on a friendly "request received" screen instead of a raw 403.
|
# land the user on a friendly "request received" screen instead of a raw 403.
|
||||||
if email:
|
if email:
|
||||||
@@ -419,15 +429,20 @@ async def callback(
|
|||||||
if existing.google_sub is not None and existing.google_sub != sub:
|
if existing.google_sub is not None and existing.google_sub != sub:
|
||||||
# A different Google identity is already bound to this email — refuse rather than
|
# A different Google identity is already bound to this email — refuse rather than
|
||||||
# hijack it. (Near-impossible with real Google accounts: email↔sub is stable.)
|
# hijack it. (Near-impossible with real Google accounts: email↔sub is stable.)
|
||||||
log.warning("Google login email collision (different sub): %s", email)
|
log.warning(
|
||||||
|
"Google sign-in refused: a different Google identity holds this address",
|
||||||
|
extra=logs.event("auth.login.denied", reason="sub_collision", uid=existing.id),
|
||||||
|
)
|
||||||
return RedirectResponse(url=_access_notice_url("denied"))
|
return RedirectResponse(url=_access_notice_url("denied"))
|
||||||
if not userinfo.get("email_verified", False):
|
if not userinfo.get("email_verified", False):
|
||||||
# Adopting (and activating) a pre-existing password account requires Google to
|
# Adopting (and activating) a pre-existing password account requires Google to
|
||||||
# actually attest the address — otherwise an unverified Google email that merely
|
# actually attest the address — otherwise an unverified Google email that merely
|
||||||
# collides with a pending registration could seize that account. Defense-in-depth.
|
# collides with a pending registration could seize that account. Defense-in-depth.
|
||||||
log.warning(
|
log.warning(
|
||||||
"Google login: unverified email, refusing to adopt account: %s",
|
"Google sign-in refused: unverified address, not adopting the account",
|
||||||
email,
|
extra=logs.event(
|
||||||
|
"auth.login.denied", reason="email_unverified", uid=existing.id
|
||||||
|
),
|
||||||
)
|
)
|
||||||
return RedirectResponse(url=_access_notice_url("denied"))
|
return RedirectResponse(url=_access_notice_url("denied"))
|
||||||
user = existing
|
user = existing
|
||||||
@@ -445,7 +460,10 @@ async def callback(
|
|||||||
# A suspended account can't sign in by any method. Block before establishing the session and
|
# A suspended account can't sign in by any method. Block before establishing the session and
|
||||||
# tell the (verified) owner why — a successful Google auth proves they control this account.
|
# tell the (verified) owner why — a successful Google auth proves they control this account.
|
||||||
if user.is_suspended:
|
if user.is_suspended:
|
||||||
log.info("Suspended account blocked at Google login: %s", email)
|
log.info(
|
||||||
|
"Google sign-in blocked: the account is suspended",
|
||||||
|
extra=logs.event("auth.login.denied", reason="suspended", uid=user.id),
|
||||||
|
)
|
||||||
_notify_suspended(background, user)
|
_notify_suspended(background, user)
|
||||||
return RedirectResponse(url="/?login=suspended")
|
return RedirectResponse(url="/?login=suspended")
|
||||||
# Record what Google actually answers with, beside the address the user registered with. The two
|
# Record what Google actually answers with, beside the address the user registered with. The two
|
||||||
@@ -454,7 +472,7 @@ async def callback(
|
|||||||
if email:
|
if email:
|
||||||
user.google_email = email
|
user.google_email = email
|
||||||
_sync_google_email(db, user, email)
|
_sync_google_email(db, user, email)
|
||||||
user.display_name = userinfo.get("name")
|
user.display_name = display_text(userinfo.get("name"))
|
||||||
user.avatar_url = userinfo.get("picture")
|
user.avatar_url = userinfo.get("picture")
|
||||||
# ADMIN_EMAILS (env) is the bootstrap admin list and always wins, so the configured admin can't
|
# ADMIN_EMAILS (env) is the bootstrap admin list and always wins, so the configured admin can't
|
||||||
# be locked out. Everyone else's role is managed in the admin UI and stored in the DB, so a
|
# be locked out. Everyone else's role is managed in the admin UI and stored in the DB, so a
|
||||||
@@ -481,7 +499,10 @@ async def callback(
|
|||||||
_store_token(db, user, token)
|
_store_token(db, user, token)
|
||||||
_establish_session(request, user)
|
_establish_session(request, user)
|
||||||
db.commit()
|
db.commit()
|
||||||
log.info("Login: %s (id=%s, role=%s)", email, user.id, user.role)
|
log.info(
|
||||||
|
"signed in",
|
||||||
|
extra=logs.event("auth.login.ok", method="google", uid=user.id, role=user.role),
|
||||||
|
)
|
||||||
return RedirectResponse(url="/")
|
return RedirectResponse(url="/")
|
||||||
|
|
||||||
|
|
||||||
@@ -573,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)
|
||||||
@@ -633,15 +654,15 @@ async def request_access(
|
|||||||
endpoint can't be probed for throttling either."""
|
endpoint can't be probed for throttling either."""
|
||||||
email = (body.email or "").strip().lower()
|
email = (body.email or "").strip().lower()
|
||||||
if not valid_email(email):
|
if not valid_email(email):
|
||||||
raise HTTPException(status_code=400, detail="Enter a valid email address.")
|
raise refused(400, "invalid_email")
|
||||||
if not _request_access_limiter.allow(client_key(request)):
|
if not _request_access_limiter.allow(client_key(request)):
|
||||||
return {"status": "pending"}
|
return {"ok": True, "status": "pending"}
|
||||||
if is_allowed(db, email):
|
if is_allowed(db, email):
|
||||||
return {"status": "pending"} # already allowed — don't reveal it
|
return {"ok": True, "status": "pending"} # already allowed — don't reveal it
|
||||||
req = upsert_pending_invite(db, email, body.lang)
|
req = upsert_pending_invite(db, email, body.lang)
|
||||||
if req.is_new:
|
if req.is_new:
|
||||||
notify_admins_of_request(db, background, email)
|
notify_admins_of_request(db, background, email)
|
||||||
return {"status": "pending"}
|
return {"ok": True, "status": "pending"}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/demo")
|
@router.post("/demo")
|
||||||
@@ -652,7 +673,7 @@ def demo_login(body: EmailIn, request: Request, db: Session = Depends(get_db)) -
|
|||||||
exactly like a non-match (authenticated=false) so the field can't be hammered as an
|
exactly like a non-match (authenticated=false) so the field can't be hammered as an
|
||||||
enumeration oracle. On a match it sets the session and the client reloads into the app."""
|
enumeration oracle. On a match it sets the session and the client reloads into the app."""
|
||||||
if not _demo_limiter.allow(client_key(request)):
|
if not _demo_limiter.allow(client_key(request)):
|
||||||
return {"authenticated": False}
|
return {"ok": True, "authenticated": False}
|
||||||
email = (body.email or "").strip().lower()
|
email = (body.email or "").strip().lower()
|
||||||
if valid_email(email) and is_demo_allowed(db, email):
|
if valid_email(email) and is_demo_allowed(db, email):
|
||||||
demo = get_or_create_demo_user(db)
|
demo = get_or_create_demo_user(db)
|
||||||
@@ -661,9 +682,10 @@ def demo_login(body: EmailIn, request: Request, db: Session = Depends(get_db)) -
|
|||||||
request.session.clear()
|
request.session.clear()
|
||||||
request.session["user_id"] = demo.id
|
request.session["user_id"] = demo.id
|
||||||
_remember_epoch(request, demo)
|
_remember_epoch(request, demo)
|
||||||
log.info("Demo login (key=%s, demo_id=%s)", email, demo.id)
|
# The demo key IS an address, so it is not in the line — see the denied branch above.
|
||||||
return {"authenticated": True}
|
log.info("signed in", extra=logs.event("auth.login.ok", method="demo", uid=demo.id))
|
||||||
return {"authenticated": False}
|
return {"ok": True, "authenticated": True}
|
||||||
|
return {"ok": True, "authenticated": False}
|
||||||
|
|
||||||
|
|
||||||
def _establish_session(request: Request, user: User) -> None:
|
def _establish_session(request: Request, user: User) -> None:
|
||||||
@@ -794,23 +816,20 @@ def register(
|
|||||||
password = body.password or ""
|
password = body.password or ""
|
||||||
# Input validation is safe to reveal (independent of whether the email exists).
|
# Input validation is safe to reveal (independent of whether the email exists).
|
||||||
if not valid_email(email):
|
if not valid_email(email):
|
||||||
raise HTTPException(status_code=400, detail="Enter a valid email address.")
|
raise refused(400, "invalid_email")
|
||||||
if len(password) < PASSWORD_MIN_LEN:
|
if len(password) < PASSWORD_MIN_LEN:
|
||||||
raise HTTPException(
|
raise refused(400, "weak_password", min=PASSWORD_MIN_LEN)
|
||||||
status_code=400,
|
|
||||||
detail=f"Password must be at least {PASSWORD_MIN_LEN} characters.",
|
|
||||||
)
|
|
||||||
if not sysconfig.get_bool(db, "allow_registration"):
|
if not sysconfig.get_bool(db, "allow_registration"):
|
||||||
raise HTTPException(status_code=403, detail="Registration is currently closed.")
|
raise refused(403, "registration_closed")
|
||||||
if not _register_limiter.allow(client_key(request)):
|
if not _register_limiter.allow(client_key(request)):
|
||||||
return {"status": "ok"} # silently throttle; uniform response
|
return {"ok": True, "status": "ok"} # silently throttle; uniform response
|
||||||
|
|
||||||
# Do the existence check + account creation OFF the response path (a background task with its own
|
# Do the existence check + account creation OFF the response path (a background task with its own
|
||||||
# session). The create path hashes the password + writes several rows + schedules emails; doing it
|
# session). The create path hashes the password + writes several rows + schedules emails; doing it
|
||||||
# inline would make a NEW email respond measurably slower than an already-registered one (which
|
# inline would make a NEW email respond measurably slower than an already-registered one (which
|
||||||
# skips all that) — a timing oracle for enumeration. Off-path, any valid email responds the same.
|
# skips all that) — a timing oracle for enumeration. Off-path, any valid email responds the same.
|
||||||
background.add_task(_register_account, email, password, body.lang)
|
background.add_task(_register_account, email, password, body.lang)
|
||||||
return {"status": "ok"}
|
return {"ok": True, "status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
def _register_account(email: str, password: str, lang: str | None = None) -> None:
|
def _register_account(email: str, password: str, lang: str | None = None) -> None:
|
||||||
@@ -880,7 +899,12 @@ def _register_account(email: str, password: str, lang: str | None = None) -> Non
|
|||||||
except IntegrityError:
|
except IntegrityError:
|
||||||
# A concurrent registration for the same new email won the race between the pre-check
|
# A concurrent registration for the same new email won the race between the pre-check
|
||||||
# above and this flush (the unique-email constraint fires). Same already-registered
|
# above and this flush (the unique-email constraint fires). Same already-registered
|
||||||
# no-op as the pre-check hit — roll back and stop.
|
# no-op as the pre-check hit — roll back and stop. The line carries nothing: the
|
||||||
|
# answer to the caller is deliberately identical either way, and so is this.
|
||||||
|
log.info(
|
||||||
|
"a registration lost the race for its address",
|
||||||
|
extra=logs.event("auth.register.race_lost"),
|
||||||
|
)
|
||||||
db.rollback()
|
db.rollback()
|
||||||
return
|
return
|
||||||
upsert_pending_invite(db, email) # admin-approval gate
|
upsert_pending_invite(db, email) # admin-approval gate
|
||||||
@@ -971,11 +995,11 @@ def verify_email_resend(
|
|||||||
mail had no way back. Uniform response + timing regardless of whether the email has a pending
|
mail had no way back. Uniform response + timing regardless of whether the email has a pending
|
||||||
account (the lookup runs off the response path), so it can't probe for registered emails."""
|
account (the lookup runs off the response path), so it can't probe for registered emails."""
|
||||||
if not _verify_resend_limiter.allow(client_key(request)):
|
if not _verify_resend_limiter.allow(client_key(request)):
|
||||||
return {"status": "ok"} # silently throttle; uniform response
|
return {"ok": True, "status": "ok"} # silently throttle; uniform response
|
||||||
email = (body.email or "").strip().lower()
|
email = (body.email or "").strip().lower()
|
||||||
if valid_email(email):
|
if valid_email(email):
|
||||||
background.add_task(_resend_verify_if_eligible, email)
|
background.add_task(_resend_verify_if_eligible, email)
|
||||||
return {"status": "ok"}
|
return {"ok": True, "status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
def _resend_verify_if_eligible(email: str) -> None:
|
def _resend_verify_if_eligible(email: str) -> None:
|
||||||
@@ -1049,7 +1073,10 @@ def password_login(
|
|||||||
raise _login_refused(403, "pending_approval", user)
|
raise _login_refused(403, "pending_approval", user)
|
||||||
_establish_session(request, user)
|
_establish_session(request, user)
|
||||||
db.commit()
|
db.commit()
|
||||||
log.info("Password login: %s (id=%s, role=%s)", email, user.id, user.role)
|
log.info(
|
||||||
|
"signed in",
|
||||||
|
extra=logs.event("auth.login.ok", method="password", uid=user.id, role=user.role),
|
||||||
|
)
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
@@ -1062,14 +1089,14 @@ def password_reset_request(
|
|||||||
"""Request a password-reset link. Uniform response + timing regardless of whether the email has a
|
"""Request a password-reset link. Uniform response + timing regardless of whether the email has a
|
||||||
password account (the lookup runs off the response path), so it can't probe for registered emails."""
|
password account (the lookup runs off the response path), so it can't probe for registered emails."""
|
||||||
if not _reset_limiter.allow(client_key(request)):
|
if not _reset_limiter.allow(client_key(request)):
|
||||||
return {"status": "ok"}
|
return {"ok": True, "status": "ok"}
|
||||||
email = (body.email or "").strip().lower()
|
email = (body.email or "").strip().lower()
|
||||||
# Do the account lookup + token issue + email OFF the response path (a background task with its
|
# Do the account lookup + token issue + email OFF the response path (a background task with its
|
||||||
# own session), so the endpoint takes the same time for any valid email whether or not it has a
|
# own session), so the endpoint takes the same time for any valid email whether or not it has a
|
||||||
# password account — no timing-based enumeration. Any valid email schedules the same task.
|
# password account — no timing-based enumeration. Any valid email schedules the same task.
|
||||||
if valid_email(email):
|
if valid_email(email):
|
||||||
background.add_task(_send_reset_if_eligible, email)
|
background.add_task(_send_reset_if_eligible, email)
|
||||||
return {"status": "ok"}
|
return {"ok": True, "status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
def _send_reset_if_eligible(email: str) -> None:
|
def _send_reset_if_eligible(email: str) -> None:
|
||||||
@@ -1264,13 +1291,10 @@ def set_password(
|
|||||||
raise HTTPException(status_code=403, detail={"code": "demo"})
|
raise HTTPException(status_code=403, detail={"code": "demo"})
|
||||||
new_password = body.password or ""
|
new_password = body.password or ""
|
||||||
if len(new_password) < PASSWORD_MIN_LEN:
|
if len(new_password) < PASSWORD_MIN_LEN:
|
||||||
raise HTTPException(
|
raise refused(400, "weak_password", min=PASSWORD_MIN_LEN)
|
||||||
status_code=400,
|
|
||||||
detail=f"Password must be at least {PASSWORD_MIN_LEN} characters.",
|
|
||||||
)
|
|
||||||
if user.password_hash:
|
if user.password_hash:
|
||||||
if not verify_password(body.current_password or "", user.password_hash):
|
if not verify_password(body.current_password or "", user.password_hash):
|
||||||
raise HTTPException(status_code=403, detail="Current password is incorrect.")
|
raise refused(403, "wrong_password")
|
||||||
user.password_hash = hash_password(new_password)
|
user.password_hash = hash_password(new_password)
|
||||||
user.password_revoked_at = None # same as the reset path: a password exists again
|
user.password_revoked_at = None # same as the reset path: a password exists again
|
||||||
# SA4: a password change logs out every OTHER session; re-stamp this cookie at the new epoch so
|
# SA4: a password change logs out every OTHER session; re-stamp this cookie at the new epoch so
|
||||||
|
|||||||
@@ -2,15 +2,14 @@
|
|||||||
hidden, tags) and per-channel sync state. Local reads/writes plus YouTube-side writes
|
hidden, tags) and per-channel sync state. Local reads/writes plus YouTube-side writes
|
||||||
(subscribe / unsubscribe) that require the optional write scope."""
|
(subscribe / unsubscribe) that require the optional write scope."""
|
||||||
|
|
||||||
import logging
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
from fastapi import APIRouter, BackgroundTasks, Depends
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy import func, select, update
|
from sqlalchemy import func, select, update
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app import quota
|
from app import logs, quota
|
||||||
from app.auth import admin_user, current_user, require_human, require_write_scope
|
from app.auth import admin_user, current_user, require_human, require_write_scope
|
||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.models import (
|
from app.models import (
|
||||||
@@ -26,16 +25,17 @@ from app.models import (
|
|||||||
User,
|
User,
|
||||||
Video,
|
Video,
|
||||||
)
|
)
|
||||||
from app.routes._common import get_or_404, not_found, owned_or_404
|
from app.routes._common import get_or_404, not_found, owned_or_404, youtube_refusal
|
||||||
from app.sync.explore import explore_ingest_page
|
from app.sync.explore import explore_ingest_page
|
||||||
from app.sync.runner import backfill_channel_now, run_recent_backfill
|
from app.sync.runner import backfill_channel_now, run_recent_backfill
|
||||||
from app.sync.subscriptions import apply_channel_details
|
from app.sync.subscriptions import apply_channel_details, details_stale
|
||||||
from app.utils import iso
|
from app.utils import iso
|
||||||
from app.youtube.client import YouTubeClient, YouTubeError
|
from app.youtube.client import YouTubeClient, YouTubeError
|
||||||
|
from app.refusal import refused
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/channels", tags=["channels"])
|
router = APIRouter(prefix="/api/channels", tags=["channels"])
|
||||||
|
|
||||||
log = logging.getLogger("siftlode.api")
|
log = logs.get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@router.get("")
|
@router.get("")
|
||||||
@@ -166,7 +166,15 @@ def discover_channels(
|
|||||||
user.yt_channel_id = own
|
user.yt_channel_id = own
|
||||||
db.commit()
|
db.commit()
|
||||||
except YouTubeError as exc:
|
except YouTubeError as exc:
|
||||||
log.warning("discovery: own-channel resolve failed (user %s): %s", user.id, exc)
|
# No traceback anywhere in this file's YouTube handlers: for an upstream answer
|
||||||
|
# `_upstream_failure` already logged the structured `youtube.api.error`, and the
|
||||||
|
# message names the call, its status and its reason (or, with no answer at all, the
|
||||||
|
# transport failure). The stack under it is this `except` and the client call.
|
||||||
|
log.warning(
|
||||||
|
"discovery: own-channel resolve failed: %s",
|
||||||
|
exc,
|
||||||
|
extra=logs.event("channels.discovery.own_channel_failed", reason=exc.reason),
|
||||||
|
)
|
||||||
|
|
||||||
rows = _discovery_rows(db, user)
|
rows = _discovery_rows(db, user)
|
||||||
|
|
||||||
@@ -182,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).
|
||||||
@@ -190,7 +198,11 @@ def discover_channels(
|
|||||||
rows, key=lambda r: (-int(r[1]), r[0].title is None, (r[0].title or "").lower())
|
rows, key=lambda r: (-int(r[1]), r[0].title is None, (r[0].title or "").lower())
|
||||||
)
|
)
|
||||||
except YouTubeError as exc:
|
except YouTubeError as exc:
|
||||||
log.warning("discovery: channel enrich failed (user %s): %s", user.id, exc)
|
log.warning(
|
||||||
|
"discovery: channel enrich failed: %s",
|
||||||
|
exc,
|
||||||
|
extra=logs.event("channels.discovery.enrich_failed", reason=exc.reason),
|
||||||
|
)
|
||||||
db.rollback()
|
db.rollback()
|
||||||
|
|
||||||
return [
|
return [
|
||||||
@@ -284,21 +296,30 @@ def channel_detail(
|
|||||||
"""One channel's "About" detail for the channel page header/About tab, plus this user's
|
"""One channel's "About" detail for the channel page header/About tab, plus this user's
|
||||||
relationship to it (subscribed / explored) and how many of its videos are in the catalog.
|
relationship to it (subscribed / explored) and how many of its videos are in the catalog.
|
||||||
Lazily fills the About metadata (view count, join date, banner, links) the first time it's
|
Lazily fills the About metadata (view count, join date, banner, links) the first time it's
|
||||||
needed — a stub, or a channel last enriched before those fields existed (published_at is a
|
needed (a stub) and refreshes it once it is stale (`DETAILS_MAX_AGE`), which is how a channel nobody subscribes to (an explored one, outside the
|
||||||
reliable sentinel: every real channel has a creation date). The enrich uses the API key
|
subscription import) gets a banner that still resolves. The enrich uses the API key (1 unit,
|
||||||
(1 unit, cached after), so it's skipped for the quota-less demo account."""
|
cached after), so it's skipped for the quota-less demo account."""
|
||||||
channel = get_or_404(db, Channel, channel_id, what="channel")
|
channel = get_or_404(db, Channel, channel_id, what="channel")
|
||||||
if channel.published_at is None and not user.is_demo:
|
# No `published_at is None` sentinel: a terminated channel never gets one, so it re-fetched on
|
||||||
|
# every view; rows enriched before that field existed are long past DETAILS_MAX_AGE anyway.
|
||||||
|
stale = details_stale(channel, datetime.now(timezone.utc))
|
||||||
|
if stale and not user.is_demo:
|
||||||
try:
|
try:
|
||||||
with (
|
with (
|
||||||
quota.attribute(user.id, quota.QuotaAction.CHANNELS_DISCOVER),
|
quota.attribute(user.id, quota.QuotaAction.CHANNELS_DISCOVER),
|
||||||
YouTubeClient(db, user) as yt,
|
YouTubeClient(db, user) as yt,
|
||||||
):
|
):
|
||||||
apply_channel_details(db, yt.get_channels([channel_id]))
|
apply_channel_details(db, yt.get_channels([channel_id]), [channel_id])
|
||||||
db.commit()
|
db.commit()
|
||||||
channel = db.get(Channel, channel_id)
|
channel = db.get(Channel, channel_id)
|
||||||
except YouTubeError as exc:
|
except YouTubeError as exc:
|
||||||
log.warning("channel detail enrich failed (%s): %s", channel_id, exc)
|
log.warning(
|
||||||
|
"channel detail enrich failed: %s",
|
||||||
|
exc,
|
||||||
|
extra=logs.event(
|
||||||
|
"channels.detail.enrich_failed", channel_id=channel_id, reason=exc.reason
|
||||||
|
),
|
||||||
|
)
|
||||||
db.rollback()
|
db.rollback()
|
||||||
sub = db.execute(
|
sub = db.execute(
|
||||||
select(Subscription).where(
|
select(Subscription).where(
|
||||||
@@ -366,12 +387,9 @@ def explore_channel(
|
|||||||
backfill quota reserve; blocked for the demo account (it spends shared quota)."""
|
backfill quota reserve; blocked for the demo account (it spends shared quota)."""
|
||||||
channel = get_or_404(db, Channel, channel_id, what="channel")
|
channel = get_or_404(db, Channel, channel_id, what="channel")
|
||||||
if _is_blocked(db, user, channel_id):
|
if _is_blocked(db, user, channel_id):
|
||||||
raise HTTPException(status_code=403, detail="You've blocked this channel.")
|
raise refused(403, "channel_blocked")
|
||||||
if quota.backfill_reserve_reached():
|
if quota.backfill_reserve_reached():
|
||||||
raise HTTPException(
|
raise refused(429, "quota_reserve")
|
||||||
status_code=429,
|
|
||||||
detail="The shared daily quota reserve is reached — try exploring later.",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Record/refresh the exploration (DB only) before ingesting, so the explorer can see the
|
# Record/refresh the exploration (DB only) before ingesting, so the explorer can see the
|
||||||
# videos as soon as they land. Flag the channel ephemeral unless it's organically followed.
|
# videos as soon as they land. Flag the channel ephemeral unless it's organically followed.
|
||||||
@@ -397,18 +415,17 @@ def explore_channel(
|
|||||||
YouTubeClient(db, user) as yt,
|
YouTubeClient(db, user) as yt,
|
||||||
):
|
):
|
||||||
# Enrich for the About panel + the uploads playlist id (needed to ingest videos).
|
# Enrich for the About panel + the uploads playlist id (needed to ingest videos).
|
||||||
if (
|
# Stale, not "a field is missing": a terminated channel never gets an uploads playlist
|
||||||
channel.details_synced_at is None
|
# or a creation date, and asking again on every explore would spend quota on a miss.
|
||||||
or channel.uploads_playlist_id is None
|
if details_stale(channel, datetime.now(timezone.utc)):
|
||||||
or channel.published_at is None
|
apply_channel_details(db, yt.get_channels([channel_id]), [channel_id])
|
||||||
):
|
|
||||||
apply_channel_details(db, yt.get_channels([channel_id]))
|
|
||||||
db.commit()
|
db.commit()
|
||||||
channel = db.get(Channel, channel_id)
|
channel = db.get(Channel, channel_id)
|
||||||
result = explore_ingest_page(db, yt, channel, page_token)
|
result = explore_ingest_page(db, yt, channel, page_token)
|
||||||
except YouTubeError as exc:
|
except YouTubeError as exc:
|
||||||
raise HTTPException(status_code=422, detail=f"YouTube couldn't load this channel: {exc}")
|
raise youtube_refusal(exc)
|
||||||
return {
|
return {
|
||||||
|
"ok": True,
|
||||||
"channel_id": channel_id,
|
"channel_id": channel_id,
|
||||||
"ingested": result["ingested"],
|
"ingested": result["ingested"],
|
||||||
"next_page_token": result["next_page_token"],
|
"next_page_token": result["next_page_token"],
|
||||||
@@ -490,6 +507,7 @@ def update_channel(
|
|||||||
background.add_task(backfill_channel_now, channel_id, user.id)
|
background.add_task(backfill_channel_now, channel_id, user.id)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
"ok": True,
|
||||||
"id": channel_id,
|
"id": channel_id,
|
||||||
"priority": sub.priority,
|
"priority": sub.priority,
|
||||||
"hidden": sub.hidden,
|
"hidden": sub.hidden,
|
||||||
@@ -524,7 +542,7 @@ def reset_backfill(
|
|||||||
db.commit()
|
db.commit()
|
||||||
with quota.attribute(user.id, quota.QuotaAction.VIDEOS_BACKFILL_RECENT):
|
with quota.attribute(user.id, quota.QuotaAction.VIDEOS_BACKFILL_RECENT):
|
||||||
run_recent_backfill(db, [channel], max_channels=1)
|
run_recent_backfill(db, [channel], max_channels=1)
|
||||||
return {"id": channel_id, "reset": True}
|
return {"ok": True, "id": channel_id, "reset": True}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{channel_id}/subscribe")
|
@router.post("/{channel_id}/subscribe")
|
||||||
@@ -545,7 +563,7 @@ def subscribe(
|
|||||||
)
|
)
|
||||||
).scalar_one_or_none()
|
).scalar_one_or_none()
|
||||||
if existing is not None:
|
if existing is not None:
|
||||||
raise HTTPException(status_code=409, detail="Already subscribed to this channel.")
|
raise refused(409, "already_subscribed")
|
||||||
with (
|
with (
|
||||||
quota.attribute(user.id, quota.QuotaAction.CHANNELS_SUBSCRIBE),
|
quota.attribute(user.id, quota.QuotaAction.CHANNELS_SUBSCRIBE),
|
||||||
YouTubeClient(db, user) as yt,
|
YouTubeClient(db, user) as yt,
|
||||||
@@ -559,22 +577,31 @@ def subscribe(
|
|||||||
# subscription resync fills in the resource id. Any other YouTube error is a real
|
# subscription resync fills in the resource id. Any other YouTube error is a real
|
||||||
# problem — surface it with a clear message (422, not a 502 that the client would
|
# problem — surface it with a clear message (422, not a 502 that the client would
|
||||||
# mistake for a transient "connection lost").
|
# mistake for a transient "connection lost").
|
||||||
msg = str(exc).lower()
|
# On the reason code, never on the prose: the error no longer carries the body (it is
|
||||||
if "already exists" in msg or "duplicate" in msg:
|
# the request quoted back), and a message YouTube rewords would silently turn "already
|
||||||
|
# following" back into a 422 the user cannot act on. `reason` is comma-joined when the
|
||||||
|
# body listed several.
|
||||||
|
if "subscriptionDuplicate" in exc.reason.split(","):
|
||||||
yt_sub_id = ""
|
yt_sub_id = ""
|
||||||
else:
|
else:
|
||||||
raise HTTPException(
|
raise youtube_refusal(exc)
|
||||||
status_code=422, detail=f"YouTube couldn't subscribe you: {exc}"
|
|
||||||
)
|
|
||||||
# Enrich a stub channel (title/thumbnail/subscriber count) so it shows up properly right
|
# Enrich a stub channel (title/thumbnail/subscriber count) so it shows up properly right
|
||||||
# away, on BOTH the normal and the desync path — a resurfaced stub still needs metadata to
|
# away, on BOTH the normal and the desync path — a resurfaced stub still needs metadata to
|
||||||
# render. Keep the client open (we're still inside the `with`); a metadata failure here
|
# render. Keep the client open (we're still inside the `with`); a metadata failure here
|
||||||
# must not block recording the subscription the user asked for.
|
# must not block recording the subscription the user asked for.
|
||||||
if channel.details_synced_at is None:
|
if channel.details_synced_at is None:
|
||||||
try:
|
try:
|
||||||
apply_channel_details(db, yt.get_channels([channel_id]))
|
apply_channel_details(db, yt.get_channels([channel_id]), [channel_id])
|
||||||
except YouTubeError as exc:
|
except YouTubeError as exc:
|
||||||
log.warning("subscribe: stub enrich failed (%s): %s", channel_id, exc)
|
log.warning(
|
||||||
|
"subscribe: stub enrich failed: %s",
|
||||||
|
exc,
|
||||||
|
extra=logs.event(
|
||||||
|
"channels.subscribe.enrich_failed",
|
||||||
|
channel_id=channel_id,
|
||||||
|
reason=exc.reason,
|
||||||
|
),
|
||||||
|
)
|
||||||
db.add(
|
db.add(
|
||||||
Subscription(
|
Subscription(
|
||||||
user_id=user.id,
|
user_id=user.id,
|
||||||
@@ -594,7 +621,7 @@ def subscribe(
|
|||||||
.values(via_explore=False)
|
.values(via_explore=False)
|
||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"subscribed": channel_id}
|
return {"ok": True, "subscribed": channel_id}
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{channel_id}/subscription")
|
@router.delete("/{channel_id}/subscription")
|
||||||
@@ -608,10 +635,7 @@ def unsubscribe(
|
|||||||
should Hide instead."""
|
should Hide instead."""
|
||||||
sub = _user_subscription(db, user, channel_id)
|
sub = _user_subscription(db, user, channel_id)
|
||||||
if not sub.yt_subscription_id:
|
if not sub.yt_subscription_id:
|
||||||
raise HTTPException(
|
raise refused(400, "no_subscription_id")
|
||||||
status_code=400,
|
|
||||||
detail="No YouTube subscription id on record — sync subscriptions first.",
|
|
||||||
)
|
|
||||||
try:
|
try:
|
||||||
with (
|
with (
|
||||||
quota.attribute(user.id, quota.QuotaAction.CHANNELS_UNSUBSCRIBE),
|
quota.attribute(user.id, quota.QuotaAction.CHANNELS_UNSUBSCRIBE),
|
||||||
@@ -621,10 +645,10 @@ def unsubscribe(
|
|||||||
except YouTubeError as exc:
|
except YouTubeError as exc:
|
||||||
# 422 (a real, explainable error → speaking modal), not 502 which the client treats as
|
# 422 (a real, explainable error → speaking modal), not 502 which the client treats as
|
||||||
# a transient "connection lost" gateway blip.
|
# a transient "connection lost" gateway blip.
|
||||||
raise HTTPException(status_code=422, detail=f"YouTube couldn't unsubscribe you: {exc}")
|
raise youtube_refusal(exc)
|
||||||
db.delete(sub)
|
db.delete(sub)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"unsubscribed": channel_id}
|
return {"ok": True, "unsubscribed": channel_id}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/blocked/list")
|
@router.get("/blocked/list")
|
||||||
@@ -665,7 +689,7 @@ def block_channel(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"blocked": channel_id}
|
return {"ok": True, "blocked": channel_id}
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{channel_id}/block")
|
@router.delete("/{channel_id}/block")
|
||||||
@@ -680,7 +704,7 @@ def unblock_channel(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"unblocked": channel_id}
|
return {"ok": True, "unblocked": channel_id}
|
||||||
|
|
||||||
|
|
||||||
class AttachTagIn(BaseModel):
|
class AttachTagIn(BaseModel):
|
||||||
@@ -715,7 +739,7 @@ def attach_tag(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"channel_id": channel_id, "tag_id": body.tag_id}
|
return {"ok": True, "channel_id": channel_id, "tag_id": body.tag_id}
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{channel_id}/tags/{tag_id}")
|
@router.delete("/{channel_id}/tags/{tag_id}")
|
||||||
@@ -735,4 +759,4 @@ def detach_tag(
|
|||||||
if row is not None:
|
if row is not None:
|
||||||
db.delete(row)
|
db.delete(row)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"channel_id": channel_id, "tag_id": tag_id}
|
return {"ok": True, "channel_id": channel_id, "tag_id": tag_id}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ app.sysconfig. Secret values (e.g. SMTP password) are write-only — never retur
|
|||||||
import re
|
import re
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
@@ -17,6 +17,10 @@ from app.audit import AuditAction
|
|||||||
from app.auth import admin_user
|
from app.auth import admin_user
|
||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.models import User
|
from app.models import User
|
||||||
|
from app.refusal import refused
|
||||||
|
from app.issues import gitea
|
||||||
|
from app.routes._common import issue_refused, not_found
|
||||||
|
from app.refusal import setting_refused
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/admin/config", tags=["admin"])
|
router = APIRouter(prefix="/api/admin/config", tags=["admin"])
|
||||||
|
|
||||||
@@ -59,19 +63,16 @@ def set_config(
|
|||||||
) -> dict:
|
) -> dict:
|
||||||
s = sysconfig.spec(key)
|
s = sysconfig.spec(key)
|
||||||
if s is None:
|
if s is None:
|
||||||
raise HTTPException(status_code=404, detail="Unknown config key")
|
raise not_found("config_key")
|
||||||
if "value" not in body.model_fields_set:
|
if "value" not in body.model_fields_set:
|
||||||
raise HTTPException(status_code=400, detail="Missing 'value'")
|
raise refused(400, "value_missing")
|
||||||
if s.secret and not sysconfig.secrets_manageable():
|
if s.secret and not sysconfig.secrets_manageable():
|
||||||
raise HTTPException(
|
raise refused(400, "no_encryption_key")
|
||||||
status_code=400,
|
|
||||||
detail="Set TOKEN_ENCRYPTION_KEY to store secrets in the database.",
|
|
||||||
)
|
|
||||||
old = None if s.secret else sysconfig.get(db, key)
|
old = None if s.secret else sysconfig.get(db, key)
|
||||||
try:
|
try:
|
||||||
sysconfig.set_value(db, key, body.value)
|
sysconfig.set_value(db, key, body.value)
|
||||||
except (TypeError, ValueError) as exc:
|
except sysconfig.InvalidSetting as exc:
|
||||||
raise HTTPException(status_code=400, detail=str(exc) or "Invalid value")
|
raise setting_refused(exc) from None
|
||||||
# Never log a secret's value — only that it was changed, by whom. Non-secret values are logged
|
# Never log a secret's value — only that it was changed, by whom. Non-secret values are logged
|
||||||
# but with URL credentials redacted (a proxy/URL setting can embed user:pass). Skip the row when
|
# but with URL credentials redacted (a proxy/URL setting can embed user:pass). Skip the row when
|
||||||
# a non-secret value didn't actually change (a no-op re-save shouldn't add trail noise); secrets
|
# a non-secret value didn't actually change (a no-op re-save shouldn't add trail noise); secrets
|
||||||
@@ -101,7 +102,7 @@ def reset_config(
|
|||||||
) -> dict:
|
) -> dict:
|
||||||
s = sysconfig.spec(key)
|
s = sysconfig.spec(key)
|
||||||
if s is None:
|
if s is None:
|
||||||
raise HTTPException(status_code=404, detail="Unknown config key")
|
raise not_found("config_key")
|
||||||
old = None if s.secret else sysconfig.get(db, key)
|
old = None if s.secret else sysconfig.get(db, key)
|
||||||
sysconfig.reset(db, key)
|
sysconfig.reset(db, key)
|
||||||
audit.record(
|
audit.record(
|
||||||
@@ -126,8 +127,21 @@ def send_test_email(
|
|||||||
"""Send a test email to the requesting admin using the current (DB or env) SMTP config —
|
"""Send a test email to the requesting admin using the current (DB or env) SMTP config —
|
||||||
lets the admin verify the settings actually work."""
|
lets the admin verify the settings actually work."""
|
||||||
if not email_mod.email_enabled():
|
if not email_mod.email_enabled():
|
||||||
raise HTTPException(status_code=400, detail="SMTP is not configured.")
|
raise refused(400, "smtp_off")
|
||||||
sent = email_mod.send_test(user.email, mailtext.lang_for_user(user))
|
sent = email_mod.send_test(user.email, mailtext.lang_for_user(user))
|
||||||
if not sent:
|
if not sent:
|
||||||
raise HTTPException(status_code=422, detail="SMTP send failed — check the settings.")
|
raise refused(422, "smtp_failed")
|
||||||
return {"sent": True, "to": user.email}
|
return {"ok": True, "sent": True, "to": user.email}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/test-issues")
|
||||||
|
def test_issue_tracker(
|
||||||
|
_: User = Depends(admin_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
"""Check the configured issue tracker (E16): the repository answers with this token, takes
|
||||||
|
issues, and is PRIVATE — the one it must be, since an issue carries its reporter's log."""
|
||||||
|
try:
|
||||||
|
return {"ok": True, **gitea.check(db)}
|
||||||
|
except gitea.GiteaError as exc:
|
||||||
|
raise issue_refused(exc) from None
|
||||||
|
|||||||
@@ -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
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -11,9 +11,9 @@ thing it exists to remove.
|
|||||||
|
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy import select, update
|
from sqlalchemy import select
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
@@ -23,7 +23,7 @@ from app.discover import hero as hero_mod
|
|||||||
from app.discover import pins, rails
|
from app.discover import pins, rails
|
||||||
from app.discover.budget import Budget, DEFAULT_BUDGET
|
from app.discover.budget import Budget, DEFAULT_BUDGET
|
||||||
from app.models import ComfortPin, User
|
from app.models import ComfortPin, User
|
||||||
from app.routes._common import owned_or_404
|
from app.routes._common import owned_or_404, not_found
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/discover", tags=["discover"])
|
router = APIRouter(prefix="/api/discover", tags=["discover"])
|
||||||
|
|
||||||
@@ -53,7 +53,9 @@ def get_discover(
|
|||||||
`resume` and `shelf` are built once and handed to the hero, so the two rails the hero draws from
|
`resume` and `shelf` are built once and handed to the hero, so the two rails the hero draws from
|
||||||
are not queried twice for a single page load."""
|
are not queried twice for a single page load."""
|
||||||
resume = rails.continue_watching(db, user)
|
resume = rails.continue_watching(db, user)
|
||||||
shelf = pins.shelf(db, user)
|
# Plex off → the shelf rail (and so the hero's comfort tier) goes YouTube-only like the rails
|
||||||
|
# beside it; the pins are kept and `GET /pins` still lists them (`pins.shelf`).
|
||||||
|
shelf = pins.shelf(db, user, include_plex=rails.plex_available(db))
|
||||||
return {
|
return {
|
||||||
"budget": budget,
|
"budget": budget,
|
||||||
"hero": hero_mod.pick(db, user, budget, seed, resume=resume, shelf=shelf),
|
"hero": hero_mod.pick(db, user, budget, seed, resume=resume, shelf=shelf),
|
||||||
@@ -125,7 +127,7 @@ def add_pin(
|
|||||||
constraint is the mechanism, not the message."""
|
constraint is the mechanism, not the message."""
|
||||||
resolved = pins.resolve_ref(db, user, body.kind, body.ref)
|
resolved = pins.resolve_ref(db, user, body.kind, body.ref)
|
||||||
if resolved is None:
|
if resolved is None:
|
||||||
raise HTTPException(status_code=404, detail="Unknown item")
|
raise not_found("item")
|
||||||
column, value = resolved
|
column, value = resolved
|
||||||
|
|
||||||
existing = db.execute(
|
existing = db.execute(
|
||||||
@@ -157,7 +159,7 @@ def add_pin(
|
|||||||
# between `resolve_ref` confirming it and this INSERT (a Plex sync dropping a removed
|
# between `resolve_ref` confirming it and this INSERT (a Plex sync dropping a removed
|
||||||
# item), which trips the FK. Semantically that is the same "no such thing" the pre-check
|
# item), which trips the FK. Semantically that is the same "no such thing" the pre-check
|
||||||
# returns above — re-raising handed the user a 500 they could do nothing with.
|
# returns above — re-raising handed the user a 500 they could do nothing with.
|
||||||
raise HTTPException(status_code=404, detail="Unknown item")
|
raise not_found("item")
|
||||||
return {"ok": True, "id": winner.id, "created": False}
|
return {"ok": True, "id": winner.id, "created": False}
|
||||||
return {"ok": True, "id": pin.id, "created": True}
|
return {"ok": True, "id": pin.id, "created": True}
|
||||||
|
|
||||||
@@ -180,18 +182,11 @@ def reorder_pins(
|
|||||||
user: User = Depends(require_human),
|
user: User = Depends(require_human),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Rewrite the shelf order from a full list of pin ids.
|
"""Rewrite the shelf order from the list of pin ids the client shows.
|
||||||
|
|
||||||
Whole-list rather than a move-one delta: the shelf is short, and a full rewrite cannot leave two
|
Whole-list rather than a move-one delta: the shelf is short, and a full rewrite cannot leave two
|
||||||
tiles claiming the same slot the way an interrupted sequence of single moves can. Ids that are not
|
tiles claiming the same slot the way an interrupted sequence of single moves can. The list may be
|
||||||
this user's are ignored rather than 404'd — a stale tab replaying an order that includes a pin
|
PARTIAL (hidden Plex pins, a stale tab) — `pins.reorder` keeps what it leaves out in place."""
|
||||||
deleted meanwhile should reorder what still exists, not fail wholesale."""
|
pins.reorder(db, user, body.ids)
|
||||||
owned = {p.id for p in pins.list_pins(db, user)}
|
|
||||||
for position, pin_id in enumerate(i for i in body.ids if i in owned):
|
|
||||||
db.execute(
|
|
||||||
update(ComfortPin)
|
|
||||||
.where(ComfortPin.id == pin_id, ComfortPin.user_id == user.id)
|
|
||||||
.values(position=position)
|
|
||||||
)
|
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|||||||
+165
-93
@@ -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,11 +39,13 @@ from app.models import (
|
|||||||
DownloadShare,
|
DownloadShare,
|
||||||
MediaAsset,
|
MediaAsset,
|
||||||
User,
|
User,
|
||||||
|
display_text,
|
||||||
)
|
)
|
||||||
from app.routes._common import get_or_404, not_found
|
from app.routes._common import capped_stream_to, get_or_404, not_found, owned_or_404
|
||||||
from app.security import hash_password
|
from app.security import hash_password
|
||||||
from app.userscope import is_messageable_user, messageable_clauses
|
from app.userscope import is_messageable_user, messageable_clauses
|
||||||
from app.utils import iso
|
from app.utils import iso
|
||||||
|
from app.refusal import refused
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/downloads", tags=["downloads"])
|
router = APIRouter(prefix="/api/downloads", tags=["downloads"])
|
||||||
admin_router = APIRouter(prefix="/api/admin/downloads", tags=["admin-downloads"])
|
admin_router = APIRouter(prefix="/api/admin/downloads", tags=["admin-downloads"])
|
||||||
@@ -63,7 +67,7 @@ def resolve_source(raw: str) -> tuple[str, str]:
|
|||||||
as a raw URL for yt-dlp's generic extractor."""
|
as a raw URL for yt-dlp's generic extractor."""
|
||||||
raw = (raw or "").strip()
|
raw = (raw or "").strip()
|
||||||
if not raw:
|
if not raw:
|
||||||
raise HTTPException(status_code=400, detail="A video link or id is required.")
|
raise refused(400, "link_required")
|
||||||
if _VIDEO_ID.match(raw):
|
if _VIDEO_ID.match(raw):
|
||||||
return "youtube", raw
|
return "youtube", raw
|
||||||
if raw.startswith(("http://", "https://")):
|
if raw.startswith(("http://", "https://")):
|
||||||
@@ -93,7 +97,7 @@ def resolve_source(raw: str) -> tuple[str, str]:
|
|||||||
if _VIDEO_ID.match(vid):
|
if _VIDEO_ID.match(vid):
|
||||||
return "youtube", vid
|
return "youtube", vid
|
||||||
return "url", raw
|
return "url", raw
|
||||||
raise HTTPException(status_code=400, detail="That doesn't look like a valid video link.")
|
raise refused(400, "invalid_link")
|
||||||
|
|
||||||
|
|
||||||
def _serialize(job: DownloadJob, asset: MediaAsset | None) -> dict:
|
def _serialize(job: DownloadJob, asset: MediaAsset | None) -> dict:
|
||||||
@@ -166,15 +170,8 @@ def _serialize_job(db: Session, job: DownloadJob) -> dict:
|
|||||||
return _serialize(job, asset)
|
return _serialize(job, asset)
|
||||||
|
|
||||||
|
|
||||||
def _own_job(db: Session, user: User, job_id: int) -> DownloadJob:
|
|
||||||
job = db.get(DownloadJob, job_id)
|
|
||||||
if job is None or job.user_id != user.id:
|
|
||||||
raise HTTPException(status_code=404, detail="Unknown download")
|
|
||||||
return job
|
|
||||||
|
|
||||||
|
|
||||||
def _own_job_for_update(db: Session, user: User, job_id: int) -> DownloadJob:
|
def _own_job_for_update(db: Session, user: User, job_id: int) -> DownloadJob:
|
||||||
"""`_own_job` for a transition that moves its asset's ref count (resume, cancel, delete, redownload)
|
"""The ownership check (`owned_or_404`) for a transition that moves its asset's ref count (resume, cancel, delete, redownload)
|
||||||
or writes the status those decide on (pause). The job is re-read under a row lock, so two
|
or writes the status those decide on (pause). The job is re-read under a row lock, so two
|
||||||
overlapping requests for one job run one after the other and the second decides on the first's
|
overlapping requests for one job run one after the other and the second decides on the first's
|
||||||
committed result — never both releasing the same hold, and never a pause turning a job a cancel
|
committed result — never both releasing the same hold, and never a pause turning a job a cancel
|
||||||
@@ -183,7 +180,7 @@ def _own_job_for_update(db: Session, user: User, job_id: int) -> DownloadJob:
|
|||||||
The asset is locked BEFORE the job: the order a finishing worker child takes them in
|
The asset is locked BEFORE the job: the order a finishing worker child takes them in
|
||||||
(`service.finalize_if_running`), so the two can never deadlock."""
|
(`service.finalize_if_running`), so the two can never deadlock."""
|
||||||
while True:
|
while True:
|
||||||
asset_id = _own_job(db, user, job_id).asset_id
|
asset_id = owned_or_404(db, DownloadJob, job_id, user, what="download").asset_id
|
||||||
if asset_id is not None:
|
if asset_id is not None:
|
||||||
db.execute(select(MediaAsset.id).where(MediaAsset.id == asset_id).with_for_update())
|
db.execute(select(MediaAsset.id).where(MediaAsset.id == asset_id).with_for_update())
|
||||||
job = db.execute(
|
job = db.execute(
|
||||||
@@ -193,7 +190,7 @@ def _own_job_for_update(db: Session, user: User, job_id: int) -> DownloadJob:
|
|||||||
.execution_options(populate_existing=True)
|
.execution_options(populate_existing=True)
|
||||||
).scalar_one_or_none()
|
).scalar_one_or_none()
|
||||||
if job is None:
|
if job is None:
|
||||||
raise HTTPException(status_code=404, detail="Unknown download")
|
raise not_found("download")
|
||||||
if job.asset_id == asset_id:
|
if job.asset_id == asset_id:
|
||||||
return job
|
return job
|
||||||
db.rollback() # another request moved the job to another asset meanwhile: lock that one
|
db.rollback() # another request moved the job to another asset meanwhile: lock that one
|
||||||
@@ -240,7 +237,7 @@ def create_profile(
|
|||||||
name = body.name.strip()
|
name = body.name.strip()
|
||||||
spec = body.spec
|
spec = body.spec
|
||||||
if not name:
|
if not name:
|
||||||
raise HTTPException(status_code=400, detail="A profile name is required.")
|
raise refused(400, "profile_name_required")
|
||||||
maxpos = db.execute(
|
maxpos = db.execute(
|
||||||
select(func.coalesce(func.max(DownloadProfile.sort_order), 100)).where(
|
select(func.coalesce(func.max(DownloadProfile.sort_order), 100)).where(
|
||||||
DownloadProfile.user_id == user.id
|
DownloadProfile.user_id == user.id
|
||||||
@@ -268,16 +265,16 @@ def update_profile(
|
|||||||
) -> dict:
|
) -> dict:
|
||||||
p = db.get(DownloadProfile, profile_id)
|
p = db.get(DownloadProfile, profile_id)
|
||||||
if p is None or p.is_builtin or p.user_id != user.id:
|
if p is None or p.is_builtin or p.user_id != user.id:
|
||||||
raise HTTPException(status_code=404, detail="Unknown profile")
|
raise not_found("profile")
|
||||||
sent = body.model_fields_set
|
sent = body.model_fields_set
|
||||||
if "name" in sent:
|
if "name" in sent:
|
||||||
name = (body.name or "").strip()
|
name = (body.name or "").strip()
|
||||||
if not name:
|
if not name:
|
||||||
raise HTTPException(status_code=400, detail="A profile name is required.")
|
raise refused(400, "profile_name_required")
|
||||||
p.name = name[:80]
|
p.name = name[:80]
|
||||||
if "spec" in sent:
|
if "spec" in sent:
|
||||||
if body.spec is None:
|
if body.spec is None:
|
||||||
raise HTTPException(status_code=400, detail="spec must be an object")
|
raise refused(400, "spec_not_object")
|
||||||
p.spec = body.spec
|
p.spec = body.spec
|
||||||
db.commit()
|
db.commit()
|
||||||
return _serialize_profile(p)
|
return _serialize_profile(p)
|
||||||
@@ -289,7 +286,7 @@ def delete_profile(
|
|||||||
) -> dict:
|
) -> dict:
|
||||||
p = db.get(DownloadProfile, profile_id)
|
p = db.get(DownloadProfile, profile_id)
|
||||||
if p is None or p.is_builtin or p.user_id != user.id:
|
if p is None or p.is_builtin or p.user_id != user.id:
|
||||||
raise HTTPException(status_code=404, detail="Unknown profile")
|
raise not_found("profile")
|
||||||
db.delete(p)
|
db.delete(p)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"deleted": profile_id}
|
return {"deleted": profile_id}
|
||||||
@@ -319,7 +316,7 @@ def set_credential(
|
|||||||
"""Store (encrypted) this user's cookies.txt for one supported host. Write-only: the value is
|
"""Store (encrypted) this user's cookies.txt for one supported host. Write-only: the value is
|
||||||
never echoed back, logged, or audited — only the fact that it changed."""
|
never echoed back, logged, or audited — only the fact that it changed."""
|
||||||
if not dlcreds.is_supported_host(host_key):
|
if not dlcreds.is_supported_host(host_key):
|
||||||
raise HTTPException(status_code=404, detail="Unknown host")
|
raise not_found("host")
|
||||||
try:
|
try:
|
||||||
cleaned = dlcreds.validate_cookies(body.cookies)
|
cleaned = dlcreds.validate_cookies(body.cookies)
|
||||||
except dlcreds.InvalidCookies as e:
|
except dlcreds.InvalidCookies as e:
|
||||||
@@ -341,7 +338,7 @@ def delete_credential(
|
|||||||
host_key: str, user: User = Depends(require_human), db: Session = Depends(get_db)
|
host_key: str, user: User = Depends(require_human), db: Session = Depends(get_db)
|
||||||
) -> dict:
|
) -> dict:
|
||||||
if not dlcreds.is_supported_host(host_key):
|
if not dlcreds.is_supported_host(host_key):
|
||||||
raise HTTPException(status_code=404, detail="Unknown host")
|
raise not_found("host")
|
||||||
if dlcreds.delete_credential(db, user.id, host_key):
|
if dlcreds.delete_credential(db, user.id, host_key):
|
||||||
audit.record(
|
audit.record(
|
||||||
db,
|
db,
|
||||||
@@ -362,7 +359,7 @@ def _resolve_spec(
|
|||||||
if profile_id is not None:
|
if profile_id is not None:
|
||||||
p = db.get(DownloadProfile, profile_id)
|
p = db.get(DownloadProfile, profile_id)
|
||||||
if p is None or (not p.is_builtin and p.user_id != user.id):
|
if p is None or (not p.is_builtin and p.user_id != user.id):
|
||||||
raise HTTPException(status_code=404, detail="Unknown profile")
|
raise not_found("profile")
|
||||||
return p.spec, p.id
|
return p.spec, p.id
|
||||||
if spec is not None:
|
if spec is not None:
|
||||||
return spec, None
|
return spec, None
|
||||||
@@ -373,7 +370,7 @@ def _resolve_spec(
|
|||||||
.limit(1)
|
.limit(1)
|
||||||
).scalar_one_or_none()
|
).scalar_one_or_none()
|
||||||
if fallback is None:
|
if fallback is None:
|
||||||
raise HTTPException(status_code=400, detail="No download profile available.")
|
raise refused(400, "no_profile")
|
||||||
return fallback.spec, fallback.id
|
return fallback.spec, fallback.id
|
||||||
|
|
||||||
|
|
||||||
@@ -404,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:
|
||||||
@@ -412,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
|
||||||
@@ -484,9 +539,9 @@ def enqueue_edit(
|
|||||||
source file is only read, never modified, so editing a shared video is safe."""
|
source file is only read, never modified, so editing a shared video is safe."""
|
||||||
source_job = _accessible_job(db, user, body.source_job_id)
|
source_job = _accessible_job(db, user, body.source_job_id)
|
||||||
if source_job.status != "done" or source_job.asset_id is None:
|
if source_job.status != "done" or source_job.asset_id is None:
|
||||||
raise HTTPException(status_code=409, detail="You can only edit a finished download.")
|
raise refused(409, "edit_unfinished")
|
||||||
spec = body.edit_spec
|
spec = body.edit_spec
|
||||||
display_name = (body.display_name or "").strip() or None
|
display_name = display_text(body.display_name)
|
||||||
try:
|
try:
|
||||||
job = service.enqueue_edit(db, user.id, source_job, spec, display_name)
|
job = service.enqueue_edit(db, user.id, source_job, spec, display_name)
|
||||||
except quota.QuotaExceeded as e:
|
except quota.QuotaExceeded as e:
|
||||||
@@ -522,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).
|
||||||
@@ -587,6 +655,8 @@ def _clean_url(raw) -> str | None:
|
|||||||
# authority. This one already has a "not a plausible URL" answer, so the typo belongs in it.
|
# authority. This one already has a "not a plausible URL" answer, so the typo belongs in it.
|
||||||
try:
|
try:
|
||||||
parsed = urlparse(url)
|
parsed = urlparse(url)
|
||||||
|
# silent-ok: a typo in a link someone pasted. None is this function's "not a plausible URL"
|
||||||
|
# answer, the caller already has one, and any line here would carry the URL itself.
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return None
|
return None
|
||||||
if not parsed.netloc:
|
if not parsed.netloc:
|
||||||
@@ -612,12 +682,12 @@ def update_download_meta(
|
|||||||
) -> dict:
|
) -> dict:
|
||||||
"""Update a download's display metadata: title, channel name + link, and extra reference URLs.
|
"""Update a download's display metadata: title, channel name + link, and extra reference URLs.
|
||||||
All are display-only overrides — the on-disk file keeps its system-decided, id-bound name."""
|
All are display-only overrides — the on-disk file keeps its system-decided, id-bound name."""
|
||||||
job = _own_job(db, user, job_id)
|
job = owned_or_404(db, DownloadJob, job_id, user, what="download")
|
||||||
sent = body.model_fields_set
|
sent = body.model_fields_set
|
||||||
if "display_name" in sent:
|
if "display_name" in sent:
|
||||||
job.display_name = ((body.display_name or "").strip())[:255] or None
|
job.display_name = display_text(body.display_name)
|
||||||
if "display_uploader" in sent:
|
if "display_uploader" in sent:
|
||||||
job.display_uploader = ((body.display_uploader or "").strip())[:255] or None
|
job.display_uploader = display_text(body.display_uploader)
|
||||||
if "display_uploader_url" in sent:
|
if "display_uploader_url" in sent:
|
||||||
job.display_uploader_url = _clean_url(body.display_uploader_url)
|
job.display_uploader_url = _clean_url(body.display_uploader_url)
|
||||||
if "extra_links" in sent:
|
if "extra_links" in sent:
|
||||||
@@ -666,9 +736,9 @@ def redownload_download(
|
|||||||
if job.job_kind in ("edit", "upload"):
|
if job.job_kind in ("edit", "upload"):
|
||||||
# Neither has a re-fetchable source: an editor clip is derived locally, an upload IS the
|
# Neither has a re-fetchable source: an editor clip is derived locally, an upload IS the
|
||||||
# source. Re-running yt-dlp against source_kind='upload'/'edit' would just fail.
|
# source. Re-running yt-dlp against source_kind='upload'/'edit' would just fail.
|
||||||
raise HTTPException(status_code=409, detail="This item can't be re-downloaded.")
|
raise refused(409, "not_redownloadable")
|
||||||
if job.status in ("queued", "running", "paused"):
|
if job.status in ("queued", "running", "paused"):
|
||||||
raise HTTPException(status_code=409, detail="This download is still in progress.")
|
raise refused(409, "still_downloading")
|
||||||
service.redownload(db, job)
|
service.redownload(db, job)
|
||||||
return _serialize_job(db, job)
|
return _serialize_job(db, job)
|
||||||
|
|
||||||
@@ -697,7 +767,7 @@ def delete_download(
|
|||||||
def _accessible_job(db: Session, user: User, job_id: int) -> DownloadJob:
|
def _accessible_job(db: Session, user: User, job_id: int) -> DownloadJob:
|
||||||
job = db.get(DownloadJob, job_id)
|
job = db.get(DownloadJob, job_id)
|
||||||
if job is None:
|
if job is None:
|
||||||
raise HTTPException(status_code=404, detail="Unknown download")
|
raise not_found("download")
|
||||||
if job.user_id == user.id:
|
if job.user_id == user.id:
|
||||||
return job
|
return job
|
||||||
shared = db.execute(
|
shared = db.execute(
|
||||||
@@ -706,7 +776,7 @@ def _accessible_job(db: Session, user: User, job_id: int) -> DownloadJob:
|
|||||||
)
|
)
|
||||||
).first()
|
).first()
|
||||||
if shared is None:
|
if shared is None:
|
||||||
raise HTTPException(status_code=404, detail="Unknown download")
|
raise not_found("download")
|
||||||
return job
|
return job
|
||||||
|
|
||||||
|
|
||||||
@@ -720,10 +790,10 @@ def download_file(
|
|||||||
job = _accessible_job(db, user, job_id)
|
job = _accessible_job(db, user, job_id)
|
||||||
asset = db.get(MediaAsset, job.asset_id) if job.asset_id else None
|
asset = db.get(MediaAsset, job.asset_id) if job.asset_id else None
|
||||||
if asset is None or asset.status != "ready" or not asset.rel_path:
|
if asset is None or asset.status != "ready" or not asset.rel_path:
|
||||||
raise HTTPException(status_code=409, detail="This download isn't ready.")
|
raise refused(409, "download_not_ready")
|
||||||
path = storage.safe_abs_path(settings.download_root, asset.rel_path)
|
path = storage.safe_abs_path(settings.download_root, asset.rel_path)
|
||||||
if path is None:
|
if path is None:
|
||||||
raise HTTPException(status_code=404, detail="File is no longer available.")
|
raise not_found("file")
|
||||||
|
|
||||||
filename = storage.download_filename(
|
filename = storage.download_filename(
|
||||||
job.display_name or asset.title or asset.source_ref, asset.container, path
|
job.display_name or asset.title or asset.source_ref, asset.container, path
|
||||||
@@ -762,7 +832,7 @@ def storyboard_meta(
|
|||||||
job = _accessible_job(db, user, job_id)
|
job = _accessible_job(db, user, job_id)
|
||||||
asset = db.get(MediaAsset, job.asset_id) if job.asset_id else None
|
asset = db.get(MediaAsset, job.asset_id) if job.asset_id else None
|
||||||
if asset is None or asset.status != "ready" or not asset.rel_path:
|
if asset is None or asset.status != "ready" or not asset.rel_path:
|
||||||
raise HTTPException(status_code=409, detail="This download isn't ready.")
|
raise refused(409, "download_not_ready")
|
||||||
meta = editmod.ensure_storyboard(asset, settings.download_root)
|
meta = editmod.ensure_storyboard(asset, settings.download_root)
|
||||||
if meta is None:
|
if meta is None:
|
||||||
return {"available": False}
|
return {"available": False}
|
||||||
@@ -783,10 +853,10 @@ def poster_image(job_id: int, user: User = Depends(require_human), db: Session =
|
|||||||
job = _accessible_job(db, user, job_id)
|
job = _accessible_job(db, user, job_id)
|
||||||
asset = db.get(MediaAsset, job.asset_id) if job.asset_id else None
|
asset = db.get(MediaAsset, job.asset_id) if job.asset_id else None
|
||||||
if asset is None or not asset.poster_path:
|
if asset is None or not asset.poster_path:
|
||||||
raise HTTPException(status_code=404, detail="No poster.")
|
raise not_found("poster")
|
||||||
path = storage.safe_abs_path(settings.download_root, asset.poster_path)
|
path = storage.safe_abs_path(settings.download_root, asset.poster_path)
|
||||||
if path is None:
|
if path is None:
|
||||||
raise HTTPException(status_code=404, detail="No poster.")
|
raise not_found("poster")
|
||||||
return FileResponse(path, media_type="image/jpeg")
|
return FileResponse(path, media_type="image/jpeg")
|
||||||
|
|
||||||
|
|
||||||
@@ -797,10 +867,10 @@ def storyboard_image(
|
|||||||
job = _accessible_job(db, user, job_id)
|
job = _accessible_job(db, user, job_id)
|
||||||
asset = db.get(MediaAsset, job.asset_id) if job.asset_id else None
|
asset = db.get(MediaAsset, job.asset_id) if job.asset_id else None
|
||||||
if asset is None or not asset.storyboard_path:
|
if asset is None or not asset.storyboard_path:
|
||||||
raise HTTPException(status_code=404, detail="No filmstrip.")
|
raise not_found("filmstrip")
|
||||||
path = storage.safe_abs_path(settings.download_root, asset.storyboard_path)
|
path = storage.safe_abs_path(settings.download_root, asset.storyboard_path)
|
||||||
if path is None:
|
if path is None:
|
||||||
raise HTTPException(status_code=404, detail="No filmstrip.")
|
raise not_found("filmstrip")
|
||||||
return FileResponse(path, media_type="image/jpeg")
|
return FileResponse(path, media_type="image/jpeg")
|
||||||
|
|
||||||
|
|
||||||
@@ -818,12 +888,12 @@ def share_download(
|
|||||||
user: User = Depends(require_human),
|
user: User = Depends(require_human),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
job = _own_job(db, user, job_id)
|
job = owned_or_404(db, DownloadJob, job_id, user, what="download")
|
||||||
if job.status != "done" or job.asset_id is None:
|
if job.status != "done" or job.asset_id is None:
|
||||||
raise HTTPException(status_code=409, detail="Only a finished download can be shared.")
|
raise refused(409, "share_unfinished")
|
||||||
target = body.email.strip().lower()
|
target = body.email.strip().lower()
|
||||||
if not target:
|
if not target:
|
||||||
raise HTTPException(status_code=400, detail="A recipient email is required.")
|
raise refused(400, "recipient_required")
|
||||||
recipient = db.execute(
|
recipient = db.execute(
|
||||||
select(User).where(func.lower(User.email) == target)
|
select(User).where(func.lower(User.email) == target)
|
||||||
).scalar_one_or_none()
|
).scalar_one_or_none()
|
||||||
@@ -832,7 +902,7 @@ def share_download(
|
|||||||
if not is_messageable_user(recipient):
|
if not is_messageable_user(recipient):
|
||||||
raise not_found("recipient")
|
raise not_found("recipient")
|
||||||
if recipient.id == user.id:
|
if recipient.id == user.id:
|
||||||
raise HTTPException(status_code=400, detail="That's already your download.")
|
raise refused(400, "share_own")
|
||||||
exists = db.execute(
|
exists = db.execute(
|
||||||
select(DownloadShare.id).where(
|
select(DownloadShare.id).where(
|
||||||
DownloadShare.job_id == job_id, DownloadShare.to_user_id == recipient.id
|
DownloadShare.job_id == job_id, DownloadShare.to_user_id == recipient.id
|
||||||
@@ -851,7 +921,7 @@ def share_download(
|
|||||||
commit=False,
|
commit=False,
|
||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"shared_with": recipient.email}
|
return {"ok": True, "shared_with": recipient.email}
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{job_id}/share/{email}")
|
@router.delete("/{job_id}/share/{email}")
|
||||||
@@ -861,7 +931,9 @@ def unshare_download(
|
|||||||
user: User = Depends(require_human),
|
user: User = Depends(require_human),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
_own_job(db, user, job_id) # ownership guard (404s if not the caller's job)
|
owned_or_404(
|
||||||
|
db, DownloadJob, job_id, user, what="download"
|
||||||
|
) # ownership guard (404s if not the caller's job)
|
||||||
recipient = db.execute(
|
recipient = db.execute(
|
||||||
select(User).where(func.lower(User.email) == email.strip().lower())
|
select(User).where(func.lower(User.email) == email.strip().lower())
|
||||||
).scalar_one_or_none()
|
).scalar_one_or_none()
|
||||||
@@ -893,7 +965,7 @@ def remove_shared_with_me(
|
|||||||
if row is not None:
|
if row is not None:
|
||||||
db.delete(row)
|
db.delete(row)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"removed": job_id}
|
return {"ok": True, "removed": job_id}
|
||||||
|
|
||||||
|
|
||||||
# --- recipients (for the internal "share with a user" picker) ------------------------------
|
# --- recipients (for the internal "share with a user" picker) ------------------------------
|
||||||
@@ -923,10 +995,10 @@ def share_recipients(
|
|||||||
def _own_link(db: Session, user: User, link_id: int) -> DownloadLink:
|
def _own_link(db: Session, user: User, link_id: int) -> DownloadLink:
|
||||||
link = db.get(DownloadLink, link_id)
|
link = db.get(DownloadLink, link_id)
|
||||||
if link is None:
|
if link is None:
|
||||||
raise HTTPException(status_code=404, detail="Unknown link")
|
raise not_found("link")
|
||||||
job = db.get(DownloadJob, link.job_id)
|
job = db.get(DownloadJob, link.job_id)
|
||||||
if job is None or job.user_id != user.id:
|
if job is None or job.user_id != user.id:
|
||||||
raise HTTPException(status_code=404, detail="Unknown link")
|
raise not_found("link")
|
||||||
return link
|
return link
|
||||||
|
|
||||||
|
|
||||||
@@ -942,7 +1014,7 @@ def _link_expiry(days: int | None) -> datetime | None:
|
|||||||
def list_links(
|
def list_links(
|
||||||
job_id: int, user: User = Depends(require_human), db: Session = Depends(get_db)
|
job_id: int, user: User = Depends(require_human), db: Session = Depends(get_db)
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
_own_job(db, user, job_id)
|
owned_or_404(db, DownloadJob, job_id, user, what="download")
|
||||||
rows = (
|
rows = (
|
||||||
db.execute(
|
db.execute(
|
||||||
select(DownloadLink)
|
select(DownloadLink)
|
||||||
@@ -973,9 +1045,9 @@ def create_link(
|
|||||||
user: User = Depends(require_human),
|
user: User = Depends(require_human),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
job = _own_job(db, user, job_id)
|
job = owned_or_404(db, DownloadJob, job_id, user, what="download")
|
||||||
if job.status != "done" or job.asset_id is None:
|
if job.status != "done" or job.asset_id is None:
|
||||||
raise HTTPException(status_code=409, detail="Only a finished download can be shared.")
|
raise refused(409, "share_unfinished")
|
||||||
pw = body.password.strip()
|
pw = body.password.strip()
|
||||||
link = DownloadLink(
|
link = DownloadLink(
|
||||||
job_id=job_id,
|
job_id=job_id,
|
||||||
|
|||||||
+17
-12
@@ -1,6 +1,6 @@
|
|||||||
from datetime import date, datetime, timezone
|
from datetime import date, datetime, timezone
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, Query
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
from sqlalchemy import and_, func, or_, select
|
from sqlalchemy import and_, func, or_, select
|
||||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||||
@@ -29,10 +29,11 @@ from app.models import (
|
|||||||
Video,
|
Video,
|
||||||
VideoState,
|
VideoState,
|
||||||
)
|
)
|
||||||
from app.routes._common import get_or_404
|
from app.routes._common import get_or_404, youtube_refusal, not_found
|
||||||
from app.sync.videos import parse_iso8601_duration
|
from app.sync.videos import parse_iso8601_duration
|
||||||
from app.utils import iso
|
from app.utils import iso
|
||||||
from app.youtube.client import YouTubeClient, YouTubeError
|
from app.youtube.client import YouTubeClient, YouTubeError
|
||||||
|
from app.refusal import refused
|
||||||
|
|
||||||
router = APIRouter(prefix="/api", tags=["feed"])
|
router = APIRouter(prefix="/api", tags=["feed"])
|
||||||
|
|
||||||
@@ -120,7 +121,7 @@ def get_feed(
|
|||||||
vals, cursor_id = decode_cursor(keys, cursor)
|
vals, cursor_id = decode_cursor(keys, cursor)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
# `from None`: the ValueError is an expected bad-input signal, not an error to chain.
|
# `from None`: the ValueError is an expected bad-input signal, not an error to chain.
|
||||||
raise HTTPException(status_code=400, detail="Invalid feed cursor") from None
|
raise refused(400, "invalid_cursor") from None
|
||||||
for k, v in zip(keys, vals):
|
for k, v in zip(keys, vals):
|
||||||
k["value"] = v
|
k["value"] = v
|
||||||
query = query.where(keyset_predicate(keys, cursor_id))
|
query = query.where(keyset_predicate(keys, cursor_id))
|
||||||
@@ -235,7 +236,7 @@ def set_video_state(
|
|||||||
) -> dict:
|
) -> dict:
|
||||||
status = body.status
|
status = body.status
|
||||||
if status not in VALID_STATES:
|
if status not in VALID_STATES:
|
||||||
raise HTTPException(status_code=400, detail=f"status must be one of {VALID_STATES}")
|
raise refused(400, "invalid_status")
|
||||||
get_or_404(db, Video, video_id, what="video")
|
get_or_404(db, Video, video_id, what="video")
|
||||||
|
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
@@ -256,9 +257,11 @@ def set_video_state(
|
|||||||
db.delete(row)
|
db.delete(row)
|
||||||
try:
|
try:
|
||||||
db.commit()
|
db.commit()
|
||||||
|
# silent-ok: the documented lost race — a concurrent player checkpoint already removed
|
||||||
|
# or moved this row. The state the caller asked for is the state that holds either way.
|
||||||
except StaleDataError:
|
except StaleDataError:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
return {"video_id": video_id, "status": "new"}
|
return {"ok": True, "video_id": video_id, "status": "new"}
|
||||||
|
|
||||||
# watched / hidden: atomic upsert. The player marks "watched" at end-of-playback at
|
# watched / hidden: atomic upsert. The player marks "watched" at end-of-playback at
|
||||||
# the same instant a progress checkpoint may DELETE the in-progress row; a plain
|
# the same instant a progress checkpoint may DELETE the in-progress row; a plain
|
||||||
@@ -279,7 +282,7 @@ def set_video_state(
|
|||||||
)
|
)
|
||||||
db.execute(stmt)
|
db.execute(stmt)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"video_id": video_id, "status": status}
|
return {"ok": True, "video_id": video_id, "status": status}
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/videos/{video_id}/state")
|
@router.delete("/videos/{video_id}/state")
|
||||||
@@ -298,9 +301,10 @@ def clear_video_state(
|
|||||||
db.delete(row)
|
db.delete(row)
|
||||||
try:
|
try:
|
||||||
db.commit()
|
db.commit()
|
||||||
|
# silent-ok: the row went under the delete, which is the outcome the delete wanted.
|
||||||
except StaleDataError:
|
except StaleDataError:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
return {"video_id": video_id, "status": "new", "position_seconds": 0}
|
return {"ok": True, "video_id": video_id, "status": "new", "position_seconds": 0}
|
||||||
|
|
||||||
|
|
||||||
class VideoProgressIn(BaseModel):
|
class VideoProgressIn(BaseModel):
|
||||||
@@ -355,9 +359,10 @@ def set_video_progress(
|
|||||||
# have removed/updated this row already — tolerate the lost race.
|
# have removed/updated this row already — tolerate the lost race.
|
||||||
try:
|
try:
|
||||||
db.commit()
|
db.commit()
|
||||||
|
# silent-ok: exactly the race the comment above describes; the response stays true.
|
||||||
except StaleDataError:
|
except StaleDataError:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
return {"video_id": video_id, "position_seconds": 0}
|
return {"ok": True, "video_id": video_id, "position_seconds": 0}
|
||||||
|
|
||||||
if row is None:
|
if row is None:
|
||||||
row = VideoState(user_id=user.id, video_id=video_id)
|
row = VideoState(user_id=user.id, video_id=video_id)
|
||||||
@@ -365,7 +370,7 @@ def set_video_progress(
|
|||||||
row.position_seconds = position
|
row.position_seconds = position
|
||||||
row.progress_updated_at = datetime.now(timezone.utc)
|
row.progress_updated_at = datetime.now(timezone.utc)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"video_id": video_id, "position_seconds": position}
|
return {"ok": True, "video_id": video_id, "position_seconds": position}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/videos/{video_id}")
|
@router.get("/videos/{video_id}")
|
||||||
@@ -398,7 +403,7 @@ def get_video_detail(
|
|||||||
# The DB path above is free; the off-catalog YouTube lookup below spends a quota unit, so the
|
# The DB path above is free; the off-catalog YouTube lookup below spends a quota unit, so the
|
||||||
# demo account can't reach it — it just sees "unknown" for a video we don't already store.
|
# demo account can't reach it — it just sees "unknown" for a video we don't already store.
|
||||||
if user.is_demo:
|
if user.is_demo:
|
||||||
raise HTTPException(status_code=404, detail="Unknown video")
|
raise not_found("video")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with (
|
with (
|
||||||
@@ -407,9 +412,9 @@ def get_video_detail(
|
|||||||
):
|
):
|
||||||
items = yt.get_videos([video_id])
|
items = yt.get_videos([video_id])
|
||||||
except YouTubeError as exc:
|
except YouTubeError as exc:
|
||||||
raise HTTPException(status_code=422, detail=f"YouTube lookup failed: {exc}")
|
raise youtube_refusal(exc)
|
||||||
if not items:
|
if not items:
|
||||||
raise HTTPException(status_code=404, detail="Unknown video")
|
raise not_found("video")
|
||||||
|
|
||||||
snippet = items[0].get("snippet", {})
|
snippet = items[0].get("snippet", {})
|
||||||
stats = items[0].get("statistics", {})
|
stats = items[0].get("statistics", {})
|
||||||
|
|||||||
+25
-14
@@ -17,12 +17,16 @@ 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
|
||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.models import Invite, User
|
from app.models import Invite, User
|
||||||
|
from app.utils import iso
|
||||||
|
from app.refusal import refused
|
||||||
|
from app.routes._common import not_found
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/me", tags=["me"])
|
router = APIRouter(prefix="/api/me", tags=["me"])
|
||||||
|
|
||||||
@@ -69,7 +73,7 @@ def switch_account(
|
|||||||
here) are allowed, and access is re-checked in case the invite was revoked since."""
|
here) are allowed, and access is re-checked in case the invite was revoked since."""
|
||||||
target = body.user_id
|
target = body.user_id
|
||||||
if target not in (request.session.get("account_ids") or []):
|
if target not in (request.session.get("account_ids") or []):
|
||||||
raise HTTPException(status_code=403, detail="Not an account you've signed into here.")
|
raise refused(403, "not_your_account")
|
||||||
# An account `_session_user` would refuse — gone, deactivated, suspended, or its session revoked — is
|
# An account `_session_user` would refuse — gone, deactivated, suspended, or its session revoked — is
|
||||||
# refused here too, and dropped from the switch list the way `_session_user` drops it. Made the session
|
# refused here too, and dropped from the switch list the way `_session_user` drops it. Made the session
|
||||||
# default instead, the next request's current_user would refuse it as the DEFAULT and clear the whole
|
# default instead, the next request's current_user would refuse it as the DEFAULT and clear the whole
|
||||||
@@ -77,22 +81,20 @@ def switch_account(
|
|||||||
u = db.get(User, target)
|
u = db.get(User, target)
|
||||||
if u is None:
|
if u is None:
|
||||||
drop_from_wallet(request.session, target)
|
drop_from_wallet(request.session, target)
|
||||||
raise HTTPException(status_code=404, detail="That account no longer exists.")
|
raise not_found("account")
|
||||||
if not is_allowed(db, u.email):
|
if not is_allowed(db, u.email):
|
||||||
raise HTTPException(status_code=403, detail="That account no longer has access.")
|
raise refused(403, "account_no_access")
|
||||||
if not u.is_active:
|
if not u.is_active:
|
||||||
drop_from_wallet(request.session, target)
|
drop_from_wallet(request.session, target)
|
||||||
raise HTTPException(status_code=403, detail="That account no longer has access.")
|
raise refused(403, "account_no_access")
|
||||||
if u.is_suspended:
|
if u.is_suspended:
|
||||||
drop_from_wallet(request.session, target)
|
drop_from_wallet(request.session, target)
|
||||||
raise HTTPException(status_code=403, detail="That account is suspended.")
|
raise refused(403, "account_suspended")
|
||||||
# Revoked: a password reset/change or "log out everywhere" bumped the account's epoch.
|
# Revoked: a password reset/change or "log out everywhere" bumped the account's epoch.
|
||||||
epochs = request.session.get("epochs") or {}
|
epochs = request.session.get("epochs") or {}
|
||||||
if int(epochs.get(str(target), 0)) != (u.session_epoch or 0):
|
if int(epochs.get(str(target), 0)) != (u.session_epoch or 0):
|
||||||
drop_from_wallet(request.session, target)
|
drop_from_wallet(request.session, target)
|
||||||
raise HTTPException(
|
raise refused(403, "account_signed_out")
|
||||||
status_code=403, detail="That account was signed out elsewhere — sign in again."
|
|
||||||
)
|
|
||||||
request.session["user_id"] = target
|
request.session["user_id"] = target
|
||||||
return {"ok": True, "user_id": target}
|
return {"ok": True, "user_id": target}
|
||||||
|
|
||||||
@@ -138,6 +140,9 @@ def get_me(
|
|||||||
user.google_email if user.google_email and user.google_email != user.email else None
|
user.google_email if user.google_email and user.google_email != user.email else None
|
||||||
),
|
),
|
||||||
"has_password": user.password_hash is not None,
|
"has_password": user.password_hash is not None,
|
||||||
|
# The account's latest sign-in on any device — shown in Settings → Account, so a sign-in the
|
||||||
|
# user does not recognise is something they can see (null: none since 0.88, or the demo).
|
||||||
|
"last_login_at": iso(user.last_login_at),
|
||||||
# Instance-wide: whether Google sign-in / YouTube connect is available at all (the UI hides
|
# Instance-wide: whether Google sign-in / YouTube connect is available at all (the UI hides
|
||||||
# YouTube-access affordances and the onboarding nudge when it isn't configured).
|
# YouTube-access affordances and the onboarding nudge when it isn't configured).
|
||||||
"google_enabled": google_enabled(db),
|
"google_enabled": google_enabled(db),
|
||||||
@@ -154,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,
|
||||||
@@ -177,10 +191,7 @@ def delete_my_account(
|
|||||||
if user.is_demo:
|
if user.is_demo:
|
||||||
raise HTTPException(status_code=403, detail={"code": "demo", "reason": "delete_account"})
|
raise HTTPException(status_code=403, detail={"code": "demo", "reason": "delete_account"})
|
||||||
if user.role == "admin" and count_admins(db) <= 1:
|
if user.role == "admin" and count_admins(db) <= 1:
|
||||||
raise HTTPException(
|
raise refused(400, "only_admin_delete")
|
||||||
status_code=400,
|
|
||||||
detail="You're the only admin — promote another admin before deleting your account.",
|
|
||||||
)
|
|
||||||
user_id = user.id
|
user_id = user.id
|
||||||
# Full erasure (cascades) + access-request cleanup + Google-grant revocation; shared with the
|
# Full erasure (cascades) + access-request cleanup + Google-grant revocation; shared with the
|
||||||
# admin delete path. Capture the id first — `user` is detached once purge_user deletes the row.
|
# admin delete path. Capture the id first — `user` is detached once purge_user deletes the row.
|
||||||
@@ -213,7 +224,7 @@ def update_preferences(
|
|||||||
user.preferences = merged
|
user.preferences = merged
|
||||||
db.add(user)
|
db.add(user)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"preferences": merged}
|
return {"ok": True, "preferences": merged}
|
||||||
|
|
||||||
|
|
||||||
class DiagnosticsIn(BaseModel):
|
class DiagnosticsIn(BaseModel):
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ routing and unread counts.
|
|||||||
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect
|
from fastapi import APIRouter, Depends, WebSocket, WebSocketDisconnect
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy import and_, case, delete, func, or_, select
|
from sqlalchemy import and_, case, delete, func, or_, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
@@ -29,6 +29,8 @@ from app.realtime import manager
|
|||||||
from app.security import verify_password
|
from app.security import verify_password
|
||||||
from app.userscope import is_messageable_user, messageable_clauses
|
from app.userscope import is_messageable_user, messageable_clauses
|
||||||
from app.utils import iso
|
from app.utils import iso
|
||||||
|
from app.refusal import refused
|
||||||
|
from app.routes._common import not_found
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/messages", tags=["messages"])
|
router = APIRouter(prefix="/api/messages", tags=["messages"])
|
||||||
|
|
||||||
@@ -152,7 +154,7 @@ def setup_keys(
|
|||||||
"""Store this user's public key + passphrase-wrapped private key. Set-once: changing the
|
"""Store this user's public key + passphrase-wrapped private key. Set-once: changing the
|
||||||
key would orphan every existing conversation key, so re-setup is rejected here."""
|
key would orphan every existing conversation key, so re-setup is rejected here."""
|
||||||
if db.get(MessageKey, user.id) is not None:
|
if db.get(MessageKey, user.id) is not None:
|
||||||
raise HTTPException(status_code=409, detail="Secure messaging is already set up.")
|
raise refused(409, "messaging_set_up")
|
||||||
db.add(
|
db.add(
|
||||||
MessageKey(
|
MessageKey(
|
||||||
user_id=user.id,
|
user_id=user.id,
|
||||||
@@ -164,7 +166,7 @@ def setup_keys(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"configured": True}
|
return {"ok": True, "configured": True}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/keys/reset")
|
@router.post("/keys/reset")
|
||||||
@@ -185,11 +187,11 @@ def reset_keys(
|
|||||||
less (Google-only) account has nothing to re-verify, so it relies on its live session plus the
|
less (Google-only) account has nothing to re-verify, so it relies on its live session plus the
|
||||||
client-side danger confirmation."""
|
client-side danger confirmation."""
|
||||||
if not _reset_key_limiter.allow(str(user.id)):
|
if not _reset_key_limiter.allow(str(user.id)):
|
||||||
raise HTTPException(status_code=429, detail="Too many attempts. Try again shortly.")
|
raise refused(429, "too_many_attempts")
|
||||||
if user.password_hash and not verify_password(
|
if user.password_hash and not verify_password(
|
||||||
payload.current_password or "", user.password_hash
|
payload.current_password or "", user.password_hash
|
||||||
):
|
):
|
||||||
raise HTTPException(status_code=403, detail="Current password is incorrect.")
|
raise refused(403, "wrong_password")
|
||||||
# Delete the now-dead ciphertext first (both directions), then the wrapped keypair itself so a
|
# Delete the now-dead ciphertext first (both directions), then the wrapped keypair itself so a
|
||||||
# fresh setup is allowed. Order doesn't matter (no FK between them), but do it in one commit.
|
# fresh setup is allowed. Order doesn't matter (no FK between them), but do it in one commit.
|
||||||
db.execute(
|
db.execute(
|
||||||
@@ -202,7 +204,7 @@ def reset_keys(
|
|||||||
if key is not None:
|
if key is not None:
|
||||||
db.delete(key)
|
db.delete(key)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"configured": False}
|
return {"ok": True, "configured": False}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/keys/{user_id}")
|
@router.get("/keys/{user_id}")
|
||||||
@@ -223,10 +225,10 @@ def public_key(
|
|||||||
p = db.get(User, user_id)
|
p = db.get(User, user_id)
|
||||||
has_history = db.scalar(select(Message.id).where(cond).limit(1))
|
has_history = db.scalar(select(Message.id).where(cond).limit(1))
|
||||||
if p is None or (not is_messageable_user(p) and not has_history):
|
if p is None or (not is_messageable_user(p) and not has_history):
|
||||||
raise HTTPException(status_code=404, detail="User not found")
|
raise not_found("user")
|
||||||
k = db.get(MessageKey, user_id)
|
k = db.get(MessageKey, user_id)
|
||||||
if k is None:
|
if k is None:
|
||||||
raise HTTPException(status_code=404, detail="That member hasn't set up messaging yet.")
|
raise refused(404, "no_messaging")
|
||||||
return {"user_id": user_id, "public_key": k.public_key}
|
return {"user_id": user_id, "public_key": k.public_key}
|
||||||
|
|
||||||
|
|
||||||
@@ -344,7 +346,7 @@ def get_thread(
|
|||||||
# still opens). Otherwise the same 404 as a non-existent id → no enumeration oracle.
|
# still opens). Otherwise the same 404 as a non-existent id → no enumeration oracle.
|
||||||
has_history = db.scalar(select(Message.id).where(cond).limit(1))
|
has_history = db.scalar(select(Message.id).where(cond).limit(1))
|
||||||
if p is None or (not is_messageable_user(p) and not has_history):
|
if p is None or (not is_messageable_user(p) and not has_history):
|
||||||
raise HTTPException(status_code=404, detail="User not found")
|
raise not_found("user")
|
||||||
partner = _serialize_user(p)
|
partner = _serialize_user(p)
|
||||||
msgs = (
|
msgs = (
|
||||||
db.execute(select(Message).where(cond).order_by(Message.created_at.asc())).scalars().all()
|
db.execute(select(Message).where(cond).order_by(Message.created_at.asc())).scalars().all()
|
||||||
@@ -374,20 +376,20 @@ def send_message(
|
|||||||
"""Send an end-to-end-encrypted message. The server stores only the ciphertext the client
|
"""Send an end-to-end-encrypted message. The server stores only the ciphertext the client
|
||||||
produced, then pushes it live to the recipient (and the sender's other devices)."""
|
produced, then pushes it live to the recipient (and the sender's other devices)."""
|
||||||
if not _send_limiter.allow(f"msg:{user.id}"):
|
if not _send_limiter.allow(f"msg:{user.id}"):
|
||||||
raise HTTPException(status_code=429, detail="Too many messages — slow down a moment.")
|
raise refused(429, "message_rate")
|
||||||
ct = (payload.ciphertext or "").strip()
|
ct = (payload.ciphertext or "").strip()
|
||||||
iv = (payload.iv or "").strip()
|
iv = (payload.iv or "").strip()
|
||||||
if not ct or not iv:
|
if not ct or not iv:
|
||||||
raise HTTPException(status_code=400, detail="Message is empty.")
|
raise refused(400, "message_empty")
|
||||||
if len(ct) > MAX_CIPHERTEXT:
|
if len(ct) > MAX_CIPHERTEXT:
|
||||||
raise HTTPException(status_code=400, detail="Message is too long.")
|
raise refused(400, "message_too_long")
|
||||||
if payload.recipient_id == user.id:
|
if payload.recipient_id == user.id:
|
||||||
raise HTTPException(status_code=400, detail="You can't message yourself.")
|
raise refused(400, "message_self")
|
||||||
recipient = db.get(User, payload.recipient_id)
|
recipient = db.get(User, payload.recipient_id)
|
||||||
if not is_messageable_user(recipient):
|
if not is_messageable_user(recipient):
|
||||||
raise HTTPException(status_code=404, detail="Recipient not available.")
|
raise not_found("recipient")
|
||||||
if db.get(MessageKey, recipient.id) is None:
|
if db.get(MessageKey, recipient.id) is None:
|
||||||
raise HTTPException(status_code=404, detail="That member hasn't set up messaging yet.")
|
raise refused(404, "no_messaging")
|
||||||
m = Message(kind="user", sender_id=user.id, recipient_id=recipient.id, ciphertext=ct, iv=iv)
|
m = Message(kind="user", sender_id=user.id, recipient_id=recipient.id, ciphertext=ct, iv=iv)
|
||||||
db.add(m)
|
db.add(m)
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -449,6 +451,8 @@ async def messages_ws(ws: WebSocket) -> None:
|
|||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
await ws.receive_text()
|
await ws.receive_text()
|
||||||
|
# silent-ok: the ordinary way a chat socket ends — a closed tab. The `finally` below is the
|
||||||
|
# whole handling, and nothing failed.
|
||||||
except WebSocketDisconnect:
|
except WebSocketDisconnect:
|
||||||
pass
|
pass
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ All endpoints use `current_user` (not `require_human`): notifications and their
|
|||||||
state are personal UI state with no quota or YouTube involvement, so the shared demo account
|
state are personal UI state with no quota or YouTube involvement, so the shared demo account
|
||||||
manages its own inbox normally."""
|
manages its own inbox normally."""
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, Query
|
||||||
from sqlalchemy import func, select, update
|
from sqlalchemy import func, select, update
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
@@ -14,6 +14,7 @@ from app.db import get_db
|
|||||||
from app.models import Notification, User
|
from app.models import Notification, User
|
||||||
from app.notifications import trim_read
|
from app.notifications import trim_read
|
||||||
from app.utils import iso
|
from app.utils import iso
|
||||||
|
from app.routes._common import owned_or_404
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/me/notifications", tags=["notifications"])
|
router = APIRouter(prefix="/api/me/notifications", tags=["notifications"])
|
||||||
|
|
||||||
@@ -72,20 +73,13 @@ def unread_count(user: User = Depends(current_user), db: Session = Depends(get_d
|
|||||||
return {"count": count or 0}
|
return {"count": count or 0}
|
||||||
|
|
||||||
|
|
||||||
def _owned(db: Session, notification_id: int, user: User) -> Notification:
|
|
||||||
n = db.get(Notification, notification_id)
|
|
||||||
if n is None or n.user_id != user.id:
|
|
||||||
raise HTTPException(status_code=404, detail="Notification not found")
|
|
||||||
return n
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{notification_id}/read")
|
@router.post("/{notification_id}/read")
|
||||||
def mark_read(
|
def mark_read(
|
||||||
notification_id: int,
|
notification_id: int,
|
||||||
user: User = Depends(current_user),
|
user: User = Depends(current_user),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
n = _owned(db, notification_id, user)
|
n = owned_or_404(db, Notification, notification_id, user, what="notification")
|
||||||
n.read = True
|
n.read = True
|
||||||
db.commit()
|
db.commit()
|
||||||
trim_read(db, user.id)
|
trim_read(db, user.id)
|
||||||
@@ -110,7 +104,7 @@ def dismiss(
|
|||||||
user: User = Depends(current_user),
|
user: User = Depends(current_user),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
n = _owned(db, notification_id, user)
|
n = owned_or_404(db, Notification, notification_id, user, what="notification")
|
||||||
# Dismiss implies read (it no longer counts toward the unread badge).
|
# Dismiss implies read (it no longer counts toward the unread badge).
|
||||||
n.dismissed = True
|
n.dismissed = True
|
||||||
n.read = True
|
n.read = True
|
||||||
|
|||||||
+162
-85
@@ -3,10 +3,8 @@ CRUD (create / rename / delete, add / remove / reorder items) plus YouTube two-w
|
|||||||
mirror the user's own YouTube playlists in (`/sync-youtube`) and push/revert local edits back
|
mirror the user's own YouTube playlists in (`/sync-youtube`) and push/revert local edits back
|
||||||
out (`/{id}/push-plan`, `/{id}/revert-youtube`), behind the optional write scope."""
|
out (`/{id}/push-plan`, `/{id}/revert-youtube`), behind the optional write scope."""
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel, Field
|
||||||
from sqlalchemy import func, select, and_
|
from sqlalchemy import func, select, and_
|
||||||
from sqlalchemy.orm import Session, aliased
|
from sqlalchemy.orm import Session, aliased
|
||||||
|
|
||||||
@@ -16,14 +14,18 @@ from app.db import get_db
|
|||||||
from app.feedquery import feed_columns, serialize
|
from app.feedquery import feed_columns, serialize
|
||||||
from app.models import Channel, Playlist, PlaylistItem, User, Video, VideoState
|
from app.models import Channel, Playlist, PlaylistItem, User, Video, VideoState
|
||||||
from app.sync.playlists import (
|
from app.sync.playlists import (
|
||||||
|
PlaylistGone,
|
||||||
|
lock_playlists,
|
||||||
merge_playlist,
|
merge_playlist,
|
||||||
plan_merge,
|
plan_merge,
|
||||||
recompute_dirty,
|
recompute_dirty,
|
||||||
|
pull_user_playlists,
|
||||||
repull_playlist,
|
repull_playlist,
|
||||||
sync_user_playlists,
|
sync_claim,
|
||||||
|
user_sync_claim,
|
||||||
)
|
)
|
||||||
from app.ratelimit import RateLimiter
|
from app.ratelimit import RateLimiter
|
||||||
from app.routes._common import get_or_404, owned_or_404
|
from app.routes._common import get_or_404, not_found, owned_or_404, youtube_refusal
|
||||||
from app.youtube.client import YouTubeClient, YouTubeError
|
from app.youtube.client import YouTubeClient, YouTubeError
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/playlists", tags=["playlists"])
|
router = APIRouter(prefix="/api/playlists", tags=["playlists"])
|
||||||
@@ -48,10 +50,6 @@ def _check_pull_cooldown(user: User) -> None:
|
|||||||
raise HTTPException(status_code=429, detail={"code": "sync_cooldown"})
|
raise HTTPException(status_code=429, detail={"code": "sync_cooldown"})
|
||||||
|
|
||||||
|
|
||||||
def _own_playlist(db: Session, user: User, playlist_id: int) -> Playlist:
|
|
||||||
return owned_or_404(db, Playlist, playlist_id, user, what="playlist")
|
|
||||||
|
|
||||||
|
|
||||||
def _refused(status: int, reason: str) -> HTTPException:
|
def _refused(status: int, reason: str) -> HTTPException:
|
||||||
"""A playlist action the server refuses, as a reason the UI translates
|
"""A playlist action the server refuses, as a reason the UI translates
|
||||||
(`playlists.errors.<reason>`) — a bare English detail reached the Hungarian UI verbatim."""
|
(`playlists.errors.<reason>`) — a bare English detail reached the Hungarian UI verbatim."""
|
||||||
@@ -64,8 +62,11 @@ def _touch(pl: Playlist) -> None:
|
|||||||
`UpdatedAtMixin`'s `onupdate` fires on an UPDATE of the PLAYLIST row, and add / remove /
|
`UpdatedAtMixin`'s `onupdate` fires on an UPDATE of the PLAYLIST row, and add / remove /
|
||||||
reorder / move write the item rows instead — so without this the rail's "recently changed"
|
reorder / move write the item rows instead — so without this the rail's "recently changed"
|
||||||
sort would only ever see renames. The assignment is what makes the column mean "last time
|
sort would only ever see renames. The assignment is what makes the column mean "last time
|
||||||
this list changed", which is what the rail promises the reader."""
|
this list changed", which is what the rail promises the reader.
|
||||||
pl.updated_at = datetime.now(timezone.utc)
|
|
||||||
|
The DATABASE's clock, like the mixin's own `onupdate`: one sort key written from two clocks
|
||||||
|
(this process's and Postgres's) could order a rename and an item edit the wrong way round."""
|
||||||
|
pl.updated_at = func.now()
|
||||||
|
|
||||||
|
|
||||||
def _next_playlist_position(db: Session, user_id: int) -> int:
|
def _next_playlist_position(db: Session, user_id: int) -> int:
|
||||||
@@ -81,7 +82,10 @@ def _next_playlist_position(db: Session, user_id: int) -> int:
|
|||||||
|
|
||||||
|
|
||||||
def _next_item_position(db: Session, playlist_id: int) -> int:
|
def _next_item_position(db: Session, playlist_id: int) -> int:
|
||||||
"""Append position for a new item at the end of a playlist."""
|
"""Append position for a new item at the end of a playlist. Locks the playlist row first: two
|
||||||
|
adds (two tabs, the rail and the page) otherwise both read the same max and land on one
|
||||||
|
position, and the list's order between them is then arbitrary."""
|
||||||
|
lock_playlists(db, playlist_id)
|
||||||
return (
|
return (
|
||||||
db.scalar(
|
db.scalar(
|
||||||
select(func.coalesce(func.max(PlaylistItem.position), -1)).where(
|
select(func.coalesce(func.max(PlaylistItem.position), -1)).where(
|
||||||
@@ -94,7 +98,10 @@ def _next_item_position(db: Session, playlist_id: int) -> int:
|
|||||||
|
|
||||||
def _add_item(db: Session, pl: Playlist, video_id: str, *, mark_dirty: bool) -> bool:
|
def _add_item(db: Session, pl: Playlist, video_id: str, *, mark_dirty: bool) -> bool:
|
||||||
"""Append a video to a playlist if not already present (idempotent). Returns whether it
|
"""Append a video to a playlist if not already present (idempotent). Returns whether it
|
||||||
was added. `mark_dirty` recomputes the YouTube-sync dirty flag (skip for Watch later)."""
|
was added. `mark_dirty` recomputes the YouTube-sync dirty flag (skip for Watch later).
|
||||||
|
Locked BEFORE the presence check: two adds of one video both saw "absent" and the second one's
|
||||||
|
insert hit uq_playlist_video as a 500."""
|
||||||
|
lock_playlists(db, pl.id)
|
||||||
exists = db.scalar(
|
exists = db.scalar(
|
||||||
select(PlaylistItem).where(
|
select(PlaylistItem).where(
|
||||||
PlaylistItem.playlist_id == pl.id, PlaylistItem.video_id == video_id
|
PlaylistItem.playlist_id == pl.id, PlaylistItem.video_id == video_id
|
||||||
@@ -108,13 +115,14 @@ def _add_item(db: Session, pl: Playlist, video_id: str, *, mark_dirty: bool) ->
|
|||||||
if mark_dirty:
|
if mark_dirty:
|
||||||
recompute_dirty(db, pl)
|
recompute_dirty(db, pl)
|
||||||
_touch(pl)
|
_touch(pl)
|
||||||
db.commit()
|
db.flush() # the handler commits (CLAUDE.md: helpers flush)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
def _remove_item(db: Session, pl: Playlist, video_id: str, *, mark_dirty: bool) -> bool:
|
def _remove_item(db: Session, pl: Playlist, video_id: str, *, mark_dirty: bool) -> bool:
|
||||||
"""Remove a video from a playlist if present (idempotent). Returns whether it was removed.
|
"""Remove a video from a playlist if present (idempotent). Returns whether it was removed.
|
||||||
`mark_dirty` recomputes the YouTube-sync dirty flag (skip for Watch later)."""
|
`mark_dirty` recomputes the YouTube-sync dirty flag (skip for Watch later)."""
|
||||||
|
lock_playlists(db, pl.id)
|
||||||
item = db.scalar(
|
item = db.scalar(
|
||||||
select(PlaylistItem).where(
|
select(PlaylistItem).where(
|
||||||
PlaylistItem.playlist_id == pl.id, PlaylistItem.video_id == video_id
|
PlaylistItem.playlist_id == pl.id, PlaylistItem.video_id == video_id
|
||||||
@@ -126,7 +134,7 @@ def _remove_item(db: Session, pl: Playlist, video_id: str, *, mark_dirty: bool)
|
|||||||
if mark_dirty:
|
if mark_dirty:
|
||||||
recompute_dirty(db, pl)
|
recompute_dirty(db, pl)
|
||||||
_touch(pl)
|
_touch(pl)
|
||||||
db.commit()
|
db.flush() # the handler commits (CLAUDE.md: helpers flush)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
@@ -280,7 +288,7 @@ def _watch_later(db: Session, user: User) -> Playlist:
|
|||||||
position=_next_playlist_position(db, user.id),
|
position=_next_playlist_position(db, user.id),
|
||||||
)
|
)
|
||||||
db.add(pl)
|
db.add(pl)
|
||||||
db.commit()
|
db.flush() # the id, for the item the caller adds next; the handler commits
|
||||||
return pl
|
return pl
|
||||||
|
|
||||||
|
|
||||||
@@ -295,7 +303,8 @@ def add_watch_later(
|
|||||||
get_or_404(db, Video, video_id, what="video")
|
get_or_404(db, Video, video_id, what="video")
|
||||||
pl = _watch_later(db, user)
|
pl = _watch_later(db, user)
|
||||||
_add_item(db, pl, video_id, mark_dirty=False)
|
_add_item(db, pl, video_id, mark_dirty=False)
|
||||||
return {"saved": True, "playlist_id": pl.id}
|
db.commit()
|
||||||
|
return {"ok": True, "saved": True, "playlist_id": pl.id}
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/watch-later/{video_id}")
|
@router.delete("/watch-later/{video_id}")
|
||||||
@@ -309,7 +318,8 @@ def remove_watch_later(
|
|||||||
)
|
)
|
||||||
if pl is not None:
|
if pl is not None:
|
||||||
_remove_item(db, pl, video_id, mark_dirty=False)
|
_remove_item(db, pl, video_id, mark_dirty=False)
|
||||||
return {"saved": False}
|
db.commit()
|
||||||
|
return {"ok": True, "saved": False}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/sync-youtube")
|
@router.post("/sync-youtube")
|
||||||
@@ -321,9 +331,18 @@ def sync_youtube(
|
|||||||
Also runs automatically on the scheduler; this is the manual 'sync now'."""
|
Also runs automatically on the scheduler; this is the manual 'sync now'."""
|
||||||
if not has_read_scope(user):
|
if not has_read_scope(user):
|
||||||
raise _refused(403, "youtube_read_scope")
|
raise _refused(403, "youtube_read_scope")
|
||||||
_check_pull_cooldown(user)
|
with user_sync_claim(db, user.id) as free:
|
||||||
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PULL):
|
if not free:
|
||||||
return sync_user_playlists(db, user)
|
# Another run (the scheduler, another tab) is pulling right now: "0 synced" would lie.
|
||||||
|
raise _refused(409, "sync_all_busy")
|
||||||
|
# After the claim, like the re-pull's: a refused attempt must not spend one of the pulls.
|
||||||
|
_check_pull_cooldown(user)
|
||||||
|
try:
|
||||||
|
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PULL):
|
||||||
|
return {"ok": True, **pull_user_playlists(db, user)}
|
||||||
|
except YouTubeError as exc:
|
||||||
|
db.rollback()
|
||||||
|
raise youtube_refusal(exc)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{playlist_id}/revert-youtube")
|
@router.post("/{playlist_id}/revert-youtube")
|
||||||
@@ -335,24 +354,31 @@ def revert_youtube(
|
|||||||
"""Reset one YouTube-linked playlist to its current YouTube state, discarding local
|
"""Reset one YouTube-linked playlist to its current YouTube state, discarding local
|
||||||
edits (and clearing the dirty flag). The user-facing 'undo all my changes' for a list
|
edits (and clearing the dirty flag). The user-facing 'undo all my changes' for a list
|
||||||
once the in-session undo history is gone."""
|
once the in-session undo history is gone."""
|
||||||
pl = _own_playlist(db, user, playlist_id)
|
pl = owned_or_404(db, Playlist, playlist_id, user, what="playlist")
|
||||||
if not pl.yt_playlist_id:
|
if not pl.yt_playlist_id:
|
||||||
raise _refused(400, "not_on_youtube")
|
raise _refused(400, "not_on_youtube")
|
||||||
if not has_read_scope(user):
|
if not has_read_scope(user):
|
||||||
raise _refused(403, "youtube_read_scope")
|
raise _refused(403, "youtube_read_scope")
|
||||||
_check_pull_cooldown(user)
|
with sync_claim(db, pl.id) as free:
|
||||||
try:
|
if not free:
|
||||||
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PULL):
|
raise _refused(409, "sync_busy")
|
||||||
return repull_playlist(db, user, pl)
|
# The cooldown is spent only by a pull that runs: checked after the claim, so clicking
|
||||||
except YouTubeError:
|
# Revert while a push is in flight does not use up the three pulls per ten minutes.
|
||||||
db.rollback()
|
_check_pull_cooldown(user)
|
||||||
raise _refused(422, "youtube_failed")
|
try:
|
||||||
|
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PULL):
|
||||||
|
return {"ok": True, **repull_playlist(db, user, pl)}
|
||||||
|
except PlaylistGone:
|
||||||
|
raise not_found("playlist")
|
||||||
|
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:
|
||||||
"""A local playlist the user may push to YouTube (create or update). The built-in
|
"""A local playlist the user may push to YouTube (create or update). The built-in
|
||||||
Watch later and read-only YouTube mirrors are never pushable."""
|
Watch later and read-only YouTube mirrors are never pushable."""
|
||||||
pl = _own_playlist(db, user, playlist_id)
|
pl = owned_or_404(db, Playlist, playlist_id, user, what="playlist")
|
||||||
if pl.kind == "watch_later":
|
if pl.kind == "watch_later":
|
||||||
raise _refused(400, "watch_later_not_pushable")
|
raise _refused(400, "watch_later_not_pushable")
|
||||||
return pl
|
return pl
|
||||||
@@ -371,8 +397,8 @@ def push_plan(
|
|||||||
try:
|
try:
|
||||||
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PUSH):
|
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PUSH):
|
||||||
plan = plan_merge(db, user, pl)
|
plan = plan_merge(db, user, pl)
|
||||||
except YouTubeError:
|
except YouTubeError as exc:
|
||||||
raise _refused(422, "youtube_failed")
|
raise youtube_refusal(exc)
|
||||||
plan["remaining_today"] = quota.remaining_today(db)
|
plan["remaining_today"] = quota.remaining_today(db)
|
||||||
plan["affordable"] = plan["units_estimate"] <= plan["remaining_today"]
|
plan["affordable"] = plan["units_estimate"] <= plan["remaining_today"]
|
||||||
return plan
|
return plan
|
||||||
@@ -388,25 +414,32 @@ def push(
|
|||||||
neither side loses an item). Refuses if the estimated quota exceeds what's left today, so a
|
neither side loses an item). Refuses if the estimated quota exceeds what's left today, so a
|
||||||
big sync can't strand half-applied. Write scope required (require_write_scope)."""
|
big sync can't strand half-applied. Write scope required (require_write_scope)."""
|
||||||
pl = _pushable(db, user, playlist_id)
|
pl = _pushable(db, user, playlist_id)
|
||||||
try:
|
# One sync of a list at a time, across tabs, devices and the scheduler (`sync_claim`): the
|
||||||
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PUSH):
|
# client's own in-flight flag only covers the menus of one page.
|
||||||
# Read the live playlist ONCE (linked playlists only) and share it with both the
|
with sync_claim(db, pl.id) as free:
|
||||||
# plan and the merge, halving read-quota and closing the second read→write TOCTOU
|
if not free:
|
||||||
# window. A fresh export (no yt_playlist_id) reads nothing.
|
raise _refused(409, "sync_busy")
|
||||||
live = None
|
try:
|
||||||
if pl.yt_playlist_id:
|
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PUSH):
|
||||||
with YouTubeClient(db, user) as yt:
|
# Read the live playlist ONCE (linked playlists only) and share it with both the
|
||||||
live = (
|
# plan and the merge, halving read-quota and closing the second read→write TOCTOU
|
||||||
list(yt.iter_playlist_items_with_ids(pl.yt_playlist_id)),
|
# window. A fresh export (no yt_playlist_id) reads nothing.
|
||||||
yt.get_playlist_snippet(pl.yt_playlist_id),
|
live = None
|
||||||
)
|
if pl.yt_playlist_id:
|
||||||
plan = plan_merge(db, user, pl, live=live)
|
with YouTubeClient(db, user) as yt:
|
||||||
if plan["units_estimate"] > quota.remaining_today(db):
|
live = (
|
||||||
raise _refused(429, "quota_exceeded")
|
list(yt.iter_playlist_items_with_ids(pl.yt_playlist_id)),
|
||||||
return merge_playlist(db, user, pl, live=live)
|
yt.get_playlist_snippet(pl.yt_playlist_id),
|
||||||
except YouTubeError:
|
)
|
||||||
db.rollback()
|
plan = plan_merge(db, user, pl, live=live)
|
||||||
raise _refused(422, "youtube_failed")
|
if plan["units_estimate"] > quota.remaining_today(db):
|
||||||
|
raise _refused(429, "quota_exceeded")
|
||||||
|
return {"ok": True, **merge_playlist(db, user, pl, live=live)}
|
||||||
|
except PlaylistGone:
|
||||||
|
raise not_found("playlist")
|
||||||
|
except YouTubeError as exc:
|
||||||
|
db.rollback()
|
||||||
|
raise youtube_refusal(exc)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{playlist_id}")
|
@router.get("/{playlist_id}")
|
||||||
@@ -415,7 +448,7 @@ def get_playlist(
|
|||||||
user: User = Depends(current_user),
|
user: User = Depends(current_user),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
pl = _own_playlist(db, user, playlist_id)
|
pl = owned_or_404(db, Playlist, playlist_id, user, what="playlist")
|
||||||
state = aliased(VideoState)
|
state = aliased(VideoState)
|
||||||
rows = db.execute(
|
rows = db.execute(
|
||||||
select(*feed_columns(user, state))
|
select(*feed_columns(user, state))
|
||||||
@@ -423,7 +456,8 @@ def get_playlist(
|
|||||||
.join(Channel, Channel.id == Video.channel_id)
|
.join(Channel, Channel.id == Video.channel_id)
|
||||||
.outerjoin(state, and_(state.video_id == Video.id, state.user_id == user.id))
|
.outerjoin(state, and_(state.video_id == Video.id, state.user_id == user.id))
|
||||||
.where(PlaylistItem.playlist_id == pl.id)
|
.where(PlaylistItem.playlist_id == pl.id)
|
||||||
.order_by(PlaylistItem.position)
|
# `id` breaks a position tie the same way on every read (the renumbering add does too).
|
||||||
|
.order_by(PlaylistItem.position, PlaylistItem.id)
|
||||||
).all()
|
).all()
|
||||||
return {
|
return {
|
||||||
"id": pl.id,
|
"id": pl.id,
|
||||||
@@ -447,7 +481,8 @@ def rename_playlist(
|
|||||||
user: User = Depends(current_user),
|
user: User = Depends(current_user),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
pl = _own_playlist(db, user, playlist_id)
|
# Locked: the new name moves `dirty`, which the scheduled mirror refresh reads under this lock.
|
||||||
|
pl = owned_or_404(db, Playlist, playlist_id, user, what="playlist", lock=True)
|
||||||
if "name" in body.model_fields_set:
|
if "name" in body.model_fields_set:
|
||||||
name = (body.name or "").strip()
|
name = (body.name or "").strip()
|
||||||
if not name:
|
if not name:
|
||||||
@@ -484,37 +519,70 @@ def delete_playlist(
|
|||||||
user: User = Depends(current_user),
|
user: User = Depends(current_user),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
pl = _own_playlist(db, user, playlist_id)
|
pl = owned_or_404(db, Playlist, playlist_id, user, what="playlist")
|
||||||
if pl.kind == "watch_later":
|
if pl.kind == "watch_later":
|
||||||
raise _refused(400, "watch_later_undeletable")
|
raise _refused(400, "watch_later_undeletable")
|
||||||
if on_youtube:
|
# Not in the middle of a sync of it (`sync_claim`): a push that read the list before the delete
|
||||||
if not pl.yt_playlist_id:
|
# and wrote YouTube after it planned from no items at all, so it emptied the YouTube playlist a
|
||||||
raise _refused(400, "not_on_youtube")
|
# local-only delete means to keep. The push checks for a vanished list too (`PlaylistGone`);
|
||||||
if not has_write_scope(user):
|
# this refuses the delete while a sync runs rather than race it.
|
||||||
raise _refused(403, "youtube_editing_off")
|
with sync_claim(db, pl.id) as free:
|
||||||
try:
|
if not free:
|
||||||
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_DELETE):
|
raise _refused(409, "sync_busy")
|
||||||
with YouTubeClient(db, user) as yt:
|
if on_youtube:
|
||||||
yt.delete_playlist(pl.yt_playlist_id)
|
if not pl.yt_playlist_id:
|
||||||
except YouTubeError:
|
raise _refused(400, "not_on_youtube")
|
||||||
raise _refused(422, "youtube_failed")
|
if not has_write_scope(user):
|
||||||
db.delete(pl) # items cascade
|
raise _refused(403, "youtube_editing_off")
|
||||||
db.commit()
|
try:
|
||||||
|
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_DELETE):
|
||||||
|
with YouTubeClient(db, user) as yt:
|
||||||
|
yt.delete_playlist(pl.yt_playlist_id)
|
||||||
|
except YouTubeError as exc:
|
||||||
|
raise youtube_refusal(exc)
|
||||||
|
# The playlist first, as every item write: the cascade deletes the items before the list row.
|
||||||
|
lock_playlists(db, pl.id)
|
||||||
|
db.delete(pl) # items cascade
|
||||||
|
db.commit()
|
||||||
return {"deleted": playlist_id}
|
return {"deleted": playlist_id}
|
||||||
|
|
||||||
|
|
||||||
|
class AddItemIn(VideoRefIn):
|
||||||
|
# Where the video lands among the list's items, in their stored order (0 = first). Omitted, it
|
||||||
|
# is appended. The list page's "Undo" of a removal puts the row back where it was with this —
|
||||||
|
# ONE request, because append-then-reorder leaves it at the end whenever the second call fails.
|
||||||
|
position: int | None = Field(default=None, ge=0)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{playlist_id}/items")
|
@router.post("/{playlist_id}/items")
|
||||||
def add_item(
|
def add_item(
|
||||||
playlist_id: int,
|
playlist_id: int,
|
||||||
body: VideoRefIn,
|
body: AddItemIn,
|
||||||
user: User = Depends(current_user),
|
user: User = Depends(current_user),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
pl = _own_playlist(db, user, playlist_id)
|
pl = owned_or_404(db, Playlist, playlist_id, user, what="playlist")
|
||||||
video_id = body.video_id
|
video_id = body.video_id
|
||||||
get_or_404(db, Video, video_id, what="video")
|
get_or_404(db, Video, video_id, what="video")
|
||||||
_add_item(db, pl, video_id, mark_dirty=True)
|
added = _add_item(db, pl, video_id, mark_dirty=False)
|
||||||
return {"playlist_id": pl.id, "video_id": video_id}
|
if added and body.position is not None:
|
||||||
|
# Renumber the whole list with the new row at `position` (clamped to the end): positions
|
||||||
|
# can have gaps after removals, so "index N" is only meaningful over the ordered rows.
|
||||||
|
rows = list(
|
||||||
|
db.execute(
|
||||||
|
select(PlaylistItem)
|
||||||
|
.where(PlaylistItem.playlist_id == pl.id)
|
||||||
|
.order_by(PlaylistItem.position, PlaylistItem.id)
|
||||||
|
).scalars()
|
||||||
|
)
|
||||||
|
new = next(r for r in rows if r.video_id == video_id)
|
||||||
|
rows.remove(new)
|
||||||
|
rows.insert(min(body.position, len(rows)), new)
|
||||||
|
for pos, row in enumerate(rows):
|
||||||
|
row.position = pos
|
||||||
|
recompute_dirty(db, pl)
|
||||||
|
db.commit()
|
||||||
|
return {"ok": True, "playlist_id": pl.id, "video_id": video_id}
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{playlist_id}/items/{video_id}")
|
@router.delete("/{playlist_id}/items/{video_id}")
|
||||||
@@ -524,9 +592,10 @@ def remove_item(
|
|||||||
user: User = Depends(current_user),
|
user: User = Depends(current_user),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
pl = _own_playlist(db, user, playlist_id)
|
pl = owned_or_404(db, Playlist, playlist_id, user, what="playlist")
|
||||||
_remove_item(db, pl, video_id, mark_dirty=True)
|
_remove_item(db, pl, video_id, mark_dirty=True)
|
||||||
return {"playlist_id": pl.id, "video_id": video_id}
|
db.commit()
|
||||||
|
return {"ok": True, "playlist_id": pl.id, "video_id": video_id}
|
||||||
|
|
||||||
|
|
||||||
class MoveItemIn(BaseModel):
|
class MoveItemIn(BaseModel):
|
||||||
@@ -548,7 +617,7 @@ def move_item(
|
|||||||
exactly the kind of half-applied state this codebase treats as a bug rather than an edge
|
exactly the kind of half-applied state this codebase treats as a bug rather than an edge
|
||||||
case. Idempotent: if the video is already in the target, the source row is simply dropped
|
case. Idempotent: if the video is already in the target, the source row is simply dropped
|
||||||
(moving into a list that already has it is a removal, not a duplicate)."""
|
(moving into a list that already has it is a removal, not a duplicate)."""
|
||||||
src = _own_playlist(db, user, playlist_id)
|
src = owned_or_404(db, Playlist, playlist_id, user, what="playlist")
|
||||||
# Its own reason: "this playlist doesn't exist any more" about a TARGET that is gone was read as
|
# Its own reason: "this playlist doesn't exist any more" about a TARGET that is gone was read as
|
||||||
# the list on screen — which is still there.
|
# the list on screen — which is still there.
|
||||||
dst = owned_or_404(db, Playlist, body.target_playlist_id, user, what="move_target")
|
dst = owned_or_404(db, Playlist, body.target_playlist_id, user, what="move_target")
|
||||||
@@ -556,6 +625,8 @@ def move_item(
|
|||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400, detail={"code": "playlist_move", "reason": "same_playlist"}
|
status_code=400, detail={"code": "playlist_move", "reason": "same_playlist"}
|
||||||
)
|
)
|
||||||
|
# Both lists, in id order: a move A→B beside a move B→A would otherwise lock crosswise.
|
||||||
|
lock_playlists(db, src.id, dst.id)
|
||||||
item = db.scalar(
|
item = db.scalar(
|
||||||
select(PlaylistItem).where(
|
select(PlaylistItem).where(
|
||||||
PlaylistItem.playlist_id == src.id, PlaylistItem.video_id == video_id
|
PlaylistItem.playlist_id == src.id, PlaylistItem.video_id == video_id
|
||||||
@@ -571,19 +642,24 @@ def move_item(
|
|||||||
PlaylistItem.playlist_id == dst.id, PlaylistItem.video_id == video_id
|
PlaylistItem.playlist_id == dst.id, PlaylistItem.video_id == video_id
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if already is not None:
|
dst_changed = False
|
||||||
db.delete(item) # the uq_playlist_video constraint would reject a second row anyway
|
if already is None:
|
||||||
else:
|
|
||||||
# Re-parent the existing row rather than delete+insert: `added_at` ("when I saved this")
|
# Re-parent the existing row rather than delete+insert: `added_at` ("when I saved this")
|
||||||
# survives the move, and there is no window where the video belongs to neither list.
|
# survives the move, and there is no window where the video belongs to neither list. The
|
||||||
|
# check above holds: both lists are locked, and an insert into the target (another tab's
|
||||||
|
# add, a YouTube merge) waits for the lock — its foreign key needs the target row.
|
||||||
item.playlist_id = dst.id
|
item.playlist_id = dst.id
|
||||||
item.position = _next_item_position(db, dst.id)
|
item.position = _next_item_position(db, dst.id)
|
||||||
# Both sides changed, so both get the dirty recompute and the timestamp — the asymmetric
|
dst_changed = True
|
||||||
# version of this (touching only the source) is the recurring smell CLAUDE.md warns about.
|
if not dst_changed:
|
||||||
|
db.delete(item)
|
||||||
|
# The source always changed; the target only when the row landed in it. `_touch` on a target
|
||||||
|
# that already had the video promoted an untouched list in the rail's "recently changed" sort.
|
||||||
recompute_dirty(db, src)
|
recompute_dirty(db, src)
|
||||||
recompute_dirty(db, dst)
|
|
||||||
_touch(src)
|
_touch(src)
|
||||||
_touch(dst)
|
if dst_changed:
|
||||||
|
recompute_dirty(db, dst)
|
||||||
|
_touch(dst)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"ok": True, "from_playlist_id": src.id, "to_playlist_id": dst.id, "video_id": video_id}
|
return {"ok": True, "from_playlist_id": src.id, "to_playlist_id": dst.id, "video_id": video_id}
|
||||||
|
|
||||||
@@ -599,7 +675,8 @@ def reorder_items(
|
|||||||
user: User = Depends(current_user),
|
user: User = Depends(current_user),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
pl = _own_playlist(db, user, playlist_id)
|
# Locked: a concurrent add between reading the items and writing the order would collide.
|
||||||
|
pl = owned_or_404(db, Playlist, playlist_id, user, what="playlist", lock=True)
|
||||||
order = body.video_ids
|
order = body.video_ids
|
||||||
items = {
|
items = {
|
||||||
it.video_id: it
|
it.video_id: it
|
||||||
@@ -625,4 +702,4 @@ def reorder_items(
|
|||||||
recompute_dirty(db, pl)
|
recompute_dirty(db, pl)
|
||||||
_touch(pl)
|
_touch(pl)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"playlist_id": pl.id, "count": pos}
|
return {"ok": True, "playlist_id": pl.id, "count": pos}
|
||||||
|
|||||||
@@ -1,20 +1,19 @@
|
|||||||
"""Shared helpers, serializers and constants for the plex route package."""
|
"""Shared helpers, serializers and constants for the plex route package."""
|
||||||
|
|
||||||
import logging
|
|
||||||
|
|
||||||
from fastapi import HTTPException
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app import logs
|
||||||
from app.models import PlexItem
|
from app.models import PlexItem
|
||||||
from app.plex.lookups import by_rating_key
|
from app.plex.lookups import by_rating_key
|
||||||
|
from app.routes._common import not_found
|
||||||
|
|
||||||
log = logging.getLogger("siftlode.plex")
|
log = logs.get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def item_or_404(db: Session, rating_key: str) -> PlexItem:
|
def item_or_404(db: Session, rating_key: str) -> PlexItem:
|
||||||
it = by_rating_key(db, PlexItem, rating_key)
|
it = by_rating_key(db, PlexItem, rating_key)
|
||||||
if it is None:
|
if it is None:
|
||||||
raise HTTPException(status_code=404, detail="Unknown Plex item")
|
raise not_found("plex_item")
|
||||||
return it
|
return it
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,12 +2,12 @@
|
|||||||
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app import sysconfig
|
from app import logs, sysconfig
|
||||||
from app.auth import admin_user, current_user
|
from app.auth import admin_user, current_user
|
||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.models import (
|
from app.models import (
|
||||||
@@ -20,6 +20,9 @@ from app.plex import sync as plex_sync
|
|||||||
from app.plex import watch_sync as plex_watch
|
from app.plex import watch_sync as plex_watch
|
||||||
from app.plex.client import PlexClient, PlexError, PlexNotConfigured
|
from app.plex.client import PlexClient, PlexError, PlexNotConfigured
|
||||||
from app.utils import iso
|
from app.utils import iso
|
||||||
|
from app.refusal import refused
|
||||||
|
|
||||||
|
log = logs.get_logger(__name__)
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/plex", tags=["plex"])
|
router = APIRouter(prefix="/api/plex", tags=["plex"])
|
||||||
|
|
||||||
@@ -35,10 +38,15 @@ def test_connection(_: User = Depends(admin_user), db: Session = Depends(get_db)
|
|||||||
info = plex.server_info()
|
info = plex.server_info()
|
||||||
sections = plex.sections()
|
sections = plex.sections()
|
||||||
media_check = _media_check(db, plex, sections)
|
media_check = _media_check(db, plex, sections)
|
||||||
except PlexNotConfigured as e:
|
except PlexNotConfigured:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise refused(400, "plex_not_configured") from None
|
||||||
except PlexError as e:
|
except PlexError as e:
|
||||||
raise HTTPException(status_code=422, detail=f"Plex connection failed: {e}")
|
# What the upstream said is for the log, not the toast: it can quote the server URL back.
|
||||||
|
log.warning(
|
||||||
|
"the Plex connection test failed",
|
||||||
|
extra=logs.event("plex.admin.connection_failed", reason=type(e).__name__, error=str(e)),
|
||||||
|
)
|
||||||
|
raise refused(422, "plex_unreachable") from None
|
||||||
return {
|
return {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"server_name": info.get("friendlyName") or info.get("title1") or "Plex",
|
"server_name": info.get("friendlyName") or info.get("title1") or "Plex",
|
||||||
@@ -95,7 +103,7 @@ def _media_check(db: Session, plex: PlexClient, sections: list[dict]) -> dict:
|
|||||||
def trigger_sync(_: User = Depends(admin_user), db: Session = Depends(get_db)) -> dict:
|
def trigger_sync(_: User = Depends(admin_user), db: Session = Depends(get_db)) -> dict:
|
||||||
"""Admin: mirror the enabled Plex sections into the local catalog. Synchronous (can take a
|
"""Admin: mirror the enabled Plex sections into the local catalog. Synchronous (can take a
|
||||||
while on a large library); a background/scheduled sync also runs periodically."""
|
while on a large library); a background/scheduled sync also runs periodically."""
|
||||||
return plex_sync.sync(db)
|
return {"ok": True, **plex_sync.sync(db)}
|
||||||
|
|
||||||
|
|
||||||
def _link_dict(link: PlexLink | None) -> dict:
|
def _link_dict(link: PlexLink | None) -> dict:
|
||||||
@@ -129,7 +137,7 @@ def watch_link_set(
|
|||||||
admin token, so no separate Plex login. Enabling it for the first time runs the one-time
|
admin token, so no separate Plex login. Enabling it for the first time runs the one-time
|
||||||
"Plex is master" import immediately so the owner's existing Plex history shows up in Siftlode."""
|
"Plex is master" import immediately so the owner's existing Plex history shows up in Siftlode."""
|
||||||
if not sysconfig.get_bool(db, "plex_enabled"):
|
if not sysconfig.get_bool(db, "plex_enabled"):
|
||||||
raise HTTPException(status_code=409, detail="The Plex module is disabled.")
|
raise refused(409, "plex_disabled")
|
||||||
enabled = body.enabled
|
enabled = body.enabled
|
||||||
link = db.execute(select(PlexLink).where(PlexLink.user_id == user.id)).scalars().first()
|
link = db.execute(select(PlexLink).where(PlexLink.user_id == user.id)).scalars().first()
|
||||||
if link is None:
|
if link is None:
|
||||||
@@ -141,7 +149,7 @@ def watch_link_set(
|
|||||||
result: dict = {}
|
result: dict = {}
|
||||||
if enabled and not link.initial_import_done:
|
if enabled and not link.initial_import_done:
|
||||||
result = plex_watch.import_owner_watch_state(db, link)
|
result = plex_watch.import_owner_watch_state(db, link)
|
||||||
return {**_link_dict(link), "import": result or None}
|
return {"ok": True, **_link_dict(link), "import": result or None}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/watch/import")
|
@router.post("/watch/import")
|
||||||
@@ -150,6 +158,6 @@ def watch_import_now(user: User = Depends(admin_user), db: Session = Depends(get
|
|||||||
intersection; Siftlode-only states are preserved. Idempotent."""
|
intersection; Siftlode-only states are preserved. Idempotent."""
|
||||||
link = db.execute(select(PlexLink).where(PlexLink.user_id == user.id)).scalars().first()
|
link = db.execute(select(PlexLink).where(PlexLink.user_id == user.id)).scalars().first()
|
||||||
if link is None or not link.sync_enabled:
|
if link is None or not link.sync_enabled:
|
||||||
raise HTTPException(status_code=409, detail="Enable Plex watch-sync first.")
|
raise refused(409, "watch_sync_off")
|
||||||
result = plex_watch.import_owner_watch_state(db, link)
|
result = plex_watch.import_owner_watch_state(db, link)
|
||||||
return {**_link_dict(link), "import": result}
|
return {"ok": True, **_link_dict(link), "import": result}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from pydantic import BaseModel
|
|||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app import logs
|
||||||
from app.auth import admin_user, current_user
|
from app.auth import admin_user, current_user
|
||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.models import (
|
from app.models import (
|
||||||
@@ -24,7 +25,12 @@ from app.plex import sync as plex_sync
|
|||||||
from app.plex.cards import movie_card, show_card
|
from app.plex.cards import movie_card, show_card
|
||||||
from app.plex.client import PlexClient, PlexError, PlexNotConfigured
|
from app.plex.client import PlexClient, PlexError, PlexNotConfigured
|
||||||
from app.plex.lookups import by_rating_key
|
from app.plex.lookups import by_rating_key
|
||||||
from app.plex.query import content_libs, like_escape, runtime_by_key, top_art_keys
|
from app.plex.query import content_libs, runtime_by_key, top_art_keys
|
||||||
|
from app.utils import like_escape
|
||||||
|
from app.refusal import refused
|
||||||
|
from app.routes._common import not_found
|
||||||
|
|
||||||
|
log = logs.get_logger(__name__)
|
||||||
|
|
||||||
# The most-restrictive → least ordering used to pick a collection's effective content rating from its
|
# The most-restrictive → least ordering used to pick a collection's effective content rating from its
|
||||||
# members (Plex shows one cert on the collection; we don't store it, so derive it).
|
# members (Plex shows one cert on the collection; we don't store it, so derive it).
|
||||||
@@ -233,7 +239,7 @@ def collection_detail(
|
|||||||
endpoints below."""
|
endpoints below."""
|
||||||
col = by_rating_key(db, PlexCollection, rating_key)
|
col = by_rating_key(db, PlexCollection, rating_key)
|
||||||
if col is None:
|
if col is None:
|
||||||
raise HTTPException(status_code=404, detail="Unknown collection")
|
raise not_found("collection")
|
||||||
movies = (
|
movies = (
|
||||||
db.execute(
|
db.execute(
|
||||||
select(PlexItem)
|
select(PlexItem)
|
||||||
@@ -324,9 +330,9 @@ def collection_detail(
|
|||||||
def _editable_collection_or_403(db: Session, rating_key: str) -> PlexCollection:
|
def _editable_collection_or_403(db: Session, rating_key: str) -> PlexCollection:
|
||||||
col = by_rating_key(db, PlexCollection, rating_key)
|
col = by_rating_key(db, PlexCollection, rating_key)
|
||||||
if col is None:
|
if col is None:
|
||||||
raise HTTPException(status_code=404, detail="Unknown collection")
|
raise not_found("collection")
|
||||||
if not _collection_editable(col):
|
if not _collection_editable(col):
|
||||||
raise HTTPException(status_code=403, detail="This collection is read-only")
|
raise refused(403, "collection_read_only")
|
||||||
return col
|
return col
|
||||||
|
|
||||||
|
|
||||||
@@ -361,14 +367,13 @@ def _write_or_pending_error(wrote: bool, e: Exception) -> HTTPException:
|
|||||||
Both use 422, NOT a 5xx: the frontend treats 502/503/504 as "gateway unreachable" and raises a
|
Both use 422, NOT a 5xx: the frontend treats 502/503/504 as "gateway unreachable" and raises a
|
||||||
sticky offline banner (see api/core.ts RETRIABLE_GATEWAY). A 422 surfaces the detail as an ordinary
|
sticky offline banner (see api/core.ts RETRIABLE_GATEWAY). A 422 surfaces the detail as an ordinary
|
||||||
action-error toast, which is what both of these are — the app answered, it didn't go unreachable."""
|
action-error toast, which is what both of these are — the app answered, it didn't go unreachable."""
|
||||||
if wrote:
|
log.warning(
|
||||||
detail = (
|
"a Plex collection write did not settle",
|
||||||
"Collection updated in Plex, but refreshing the local copy failed; "
|
extra=logs.event(
|
||||||
f"it will reconcile on the next sync ({e})"
|
"plex.collections.write_failed", wrote=wrote, reason=type(e).__name__, error=str(e)
|
||||||
)
|
),
|
||||||
else:
|
)
|
||||||
detail = f"Plex write failed: {e}"
|
return refused(422, "collection_pending_sync") if wrote else refused(422, "plex_write_failed")
|
||||||
return HTTPException(status_code=422, detail=detail)
|
|
||||||
|
|
||||||
|
|
||||||
class CreateCollectionIn(BaseModel):
|
class CreateCollectionIn(BaseModel):
|
||||||
@@ -388,7 +393,7 @@ def create_collection(
|
|||||||
title = body.title.strip()
|
title = body.title.strip()
|
||||||
seed = body.item_rating_key
|
seed = body.item_rating_key
|
||||||
if not title or not seed:
|
if not title or not seed:
|
||||||
raise HTTPException(status_code=400, detail="title and item_rating_key are required")
|
raise refused(400, "collection_fields_required")
|
||||||
if library:
|
if library:
|
||||||
lib = (
|
lib = (
|
||||||
db.execute(select(PlexLibrary).where(PlexLibrary.plex_key == library)).scalars().first()
|
db.execute(select(PlexLibrary).where(PlexLibrary.plex_key == library)).scalars().first()
|
||||||
@@ -398,7 +403,7 @@ def create_collection(
|
|||||||
seed_item = by_rating_key(db, PlexItem, seed) or by_rating_key(db, PlexShow, seed)
|
seed_item = by_rating_key(db, PlexItem, seed) or by_rating_key(db, PlexShow, seed)
|
||||||
lib = db.get(PlexLibrary, seed_item.library_id) if seed_item else None
|
lib = db.get(PlexLibrary, seed_item.library_id) if seed_item else None
|
||||||
if lib is None:
|
if lib is None:
|
||||||
raise HTTPException(status_code=404, detail="Unknown library")
|
raise not_found("library")
|
||||||
_reject_duplicate_name(db, lib.id, title)
|
_reject_duplicate_name(db, lib.id, title)
|
||||||
wrote = False
|
wrote = False
|
||||||
try:
|
try:
|
||||||
@@ -472,7 +477,7 @@ def collection_rename(
|
|||||||
col = _editable_collection_or_403(db, rating_key)
|
col = _editable_collection_or_403(db, rating_key)
|
||||||
title = body.title.strip()
|
title = body.title.strip()
|
||||||
if not title:
|
if not title:
|
||||||
raise HTTPException(status_code=400, detail="title is required")
|
raise refused(400, "title_required")
|
||||||
_reject_duplicate_name(db, col.library_id, title, exclude_rk=rating_key)
|
_reject_duplicate_name(db, col.library_id, title, exclude_rk=rating_key)
|
||||||
lib = db.get(PlexLibrary, col.library_id)
|
lib = db.get(PlexLibrary, col.library_id)
|
||||||
wrote = False
|
wrote = False
|
||||||
@@ -501,7 +506,7 @@ def collection_delete(
|
|||||||
db.commit()
|
db.commit()
|
||||||
except (PlexError, PlexNotConfigured) as e:
|
except (PlexError, PlexNotConfigured) as e:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
raise HTTPException(status_code=422, detail=f"Plex write failed: {e}")
|
raise _write_or_pending_error(False, e) from None
|
||||||
return {"deleted": rating_key}
|
return {"deleted": rating_key}
|
||||||
|
|
||||||
|
|
||||||
@@ -520,13 +525,10 @@ def collection_set_editable(
|
|||||||
(IMDb/TMDb/…) can never be made editable — they're regenerated outside Plex."""
|
(IMDb/TMDb/…) can never be made editable — they're regenerated outside Plex."""
|
||||||
col = by_rating_key(db, PlexCollection, rating_key)
|
col = by_rating_key(db, PlexCollection, rating_key)
|
||||||
if col is None:
|
if col is None:
|
||||||
raise HTTPException(status_code=404, detail="Unknown collection")
|
raise not_found("collection")
|
||||||
want = body.editable
|
want = body.editable
|
||||||
if want and (col.smart or _collection_source(col) != "collection"):
|
if want and (col.smart or _collection_source(col) != "collection"):
|
||||||
raise HTTPException(
|
raise refused(400, "collection_smart")
|
||||||
status_code=400,
|
|
||||||
detail="Smart / auto-list collections can't be made editable",
|
|
||||||
)
|
|
||||||
col.editable = want
|
col.editable = want
|
||||||
db.commit()
|
db.commit()
|
||||||
return _collection_card(col)
|
return _collection_card(col)
|
||||||
|
|||||||
@@ -5,10 +5,11 @@ import time
|
|||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app import logs
|
||||||
from app.auth import current_user
|
from app.auth import current_user
|
||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.models import (
|
from app.models import (
|
||||||
@@ -29,6 +30,9 @@ from app.plex.client import PlexClient, PlexError, PlexNotConfigured
|
|||||||
from app.plex.lookups import by_rating_key
|
from app.plex.lookups import by_rating_key
|
||||||
from app.routes.plex._shared import _norm_lang, item_or_404
|
from app.routes.plex._shared import _norm_lang, item_or_404
|
||||||
from app.routes.plex.collections import _collection_source
|
from app.routes.plex.collections import _collection_source
|
||||||
|
from app.routes._common import not_found
|
||||||
|
|
||||||
|
log = logs.get_logger(__name__)
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/plex", tags=["plex"])
|
router = APIRouter(prefix="/api/plex", tags=["plex"])
|
||||||
|
|
||||||
@@ -65,7 +69,21 @@ def _show_enrichment(db: Session, rating_key: str) -> tuple[dict, list]:
|
|||||||
related_f = ex.submit(plex.related, rating_key) # swallows its own errors → []
|
related_f = ex.submit(plex.related, rating_key) # swallows its own errors → []
|
||||||
meta = meta_f.result() or {}
|
meta = meta_f.result() or {}
|
||||||
related_raw = related_f.result()
|
related_raw = related_f.result()
|
||||||
except (PlexError, PlexNotConfigured):
|
except PlexNotConfigured:
|
||||||
|
# silent-ok: not a failure but a state — this instance has no Plex server, and the page
|
||||||
|
# is meant to render from the local rows alone.
|
||||||
|
return {}, []
|
||||||
|
except PlexError as exc:
|
||||||
|
# The detail page still renders, from the rows the sync left behind: no cast, no related
|
||||||
|
# strip, no rich detail. Indistinguishable on screen from a show that simply has none.
|
||||||
|
log.warning(
|
||||||
|
"live Plex enrichment failed for a show",
|
||||||
|
extra=logs.event(
|
||||||
|
"plex.detail.show_enrich_failed",
|
||||||
|
rating_key=rating_key,
|
||||||
|
reason=type(exc).__name__,
|
||||||
|
),
|
||||||
|
)
|
||||||
return {}, []
|
return {}, []
|
||||||
# Don't cache an EMPTY related list: plex.related() swallows a transient failure as [] (same as a
|
# Don't cache an EMPTY related list: plex.related() swallows a transient failure as [] (same as a
|
||||||
# genuinely no-related show), so caching it would serve an empty "related" section for the whole TTL
|
# genuinely no-related show), so caching it would serve an empty "related" section for the whole TTL
|
||||||
@@ -92,7 +110,7 @@ def show_detail(
|
|||||||
hu = _norm_lang(lang) == "hu"
|
hu = _norm_lang(lang) == "hu"
|
||||||
sh = by_rating_key(db, PlexShow, rating_key)
|
sh = by_rating_key(db, PlexShow, rating_key)
|
||||||
if sh is None:
|
if sh is None:
|
||||||
raise HTTPException(status_code=404, detail="Unknown Plex show")
|
raise not_found("show")
|
||||||
seasons = (
|
seasons = (
|
||||||
db.execute(
|
db.execute(
|
||||||
select(PlexSeason)
|
select(PlexSeason)
|
||||||
@@ -145,12 +163,26 @@ def show_detail(
|
|||||||
sh.people_enriched_at = datetime.now(timezone.utc)
|
sh.people_enriched_at = datetime.now(timezone.utc)
|
||||||
try:
|
try:
|
||||||
db.commit()
|
db.commit()
|
||||||
except Exception:
|
except Exception: # noqa: BLE001 — a failed enrich must not fail the page
|
||||||
|
# The rollback un-does the enrichment AND the `people_enriched_at` stamp, so the
|
||||||
|
# next view tries again — but if the commit keeps failing nobody ever hears, and
|
||||||
|
# the cast section stays empty for good.
|
||||||
|
log.warning(
|
||||||
|
"the show enrichment could not be committed",
|
||||||
|
extra=logs.event(
|
||||||
|
"plex.detail.enrich_commit_failed", rating_key=sh.rating_key, kind="show"
|
||||||
|
),
|
||||||
|
)
|
||||||
db.rollback()
|
db.rollback()
|
||||||
except Exception:
|
except Exception: # noqa: BLE001 — the local episode list must render whatever Plex does
|
||||||
# Best-effort only — the local episode list is already assembled, so any live-Plex failure
|
# Best-effort only — the local episode list is already assembled, so any live-Plex failure
|
||||||
# (not configured, HTTP/JSON shape) must degrade, not 500.
|
# (not configured, HTTP/JSON shape) must degrade, not 500. With the traceback, because what
|
||||||
pass
|
# is left in here after the Plex errors above are handled is our own shape bugs.
|
||||||
|
log.warning(
|
||||||
|
"the live part of a show page could not be built",
|
||||||
|
extra=logs.event("plex.detail.show_extras_failed", rating_key=sh.rating_key),
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
|
||||||
imdb_id = sh.imdb_id
|
imdb_id = sh.imdb_id
|
||||||
return {
|
return {
|
||||||
@@ -295,6 +327,14 @@ def _episode_nav(db: Session, it: PlexItem) -> tuple[str | None, str | None]:
|
|||||||
try:
|
try:
|
||||||
i = keys.index(it.rating_key)
|
i = keys.index(it.rating_key)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
|
# An episode missing from its own show's ordered list — the two came out of the same sync,
|
||||||
|
# so this is a gap in it. The page loses its previous/next arrows and says nothing.
|
||||||
|
log.info(
|
||||||
|
"an episode is not in its show's episode list; no previous/next",
|
||||||
|
extra=logs.event(
|
||||||
|
"plex.detail.episode_nav_missing", rating_key=it.rating_key, show_id=it.show_id
|
||||||
|
),
|
||||||
|
)
|
||||||
return None, None
|
return None, None
|
||||||
return (keys[i - 1] if i > 0 else None), (keys[i + 1] if i < len(keys) - 1 else None)
|
return (keys[i - 1] if i > 0 else None), (keys[i + 1] if i < len(keys) - 1 else None)
|
||||||
|
|
||||||
@@ -366,8 +406,32 @@ def item_detail(
|
|||||||
"text": codec not in subtitles.BITMAP_SUBS,
|
"text": codec not in subtitles.BITMAP_SUBS,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
except Exception:
|
# Best-effort, but not free: what either branch drops is the subtitle stream list and the intro
|
||||||
pass # live extras (markers/streams) are best-effort; any live-Plex failure must degrade, not 500 — playback works without them
|
# markers, so the player offers no subtitles and no skip button — and looks like a file that has
|
||||||
|
# neither.
|
||||||
|
except PlexNotConfigured:
|
||||||
|
# silent-ok: not a failure but a state — no Plex server, and nothing to play from anyway.
|
||||||
|
pass
|
||||||
|
except PlexError as exc:
|
||||||
|
log.warning(
|
||||||
|
"the live extras for an item could not be fetched",
|
||||||
|
extra=logs.event(
|
||||||
|
"plex.detail.extras_failed",
|
||||||
|
rating_key=it.rating_key,
|
||||||
|
kind=it.kind,
|
||||||
|
reason=type(exc).__name__,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
except Exception: # noqa: BLE001 — playback works without the extras; never 500 here
|
||||||
|
# Plex answered and WE could not read it — a shape our parsing does not expect. The same
|
||||||
|
# split `show_detail` makes: the stack is the only thing that says where.
|
||||||
|
log.warning(
|
||||||
|
"the live extras for an item could not be read",
|
||||||
|
extra=logs.event(
|
||||||
|
"plex.detail.extras_unreadable", rating_key=it.rating_key, kind=it.kind
|
||||||
|
),
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
|
||||||
# Lazy enrichment (movies only): the live fetch above carries the FULL cast + rich detail (the sync
|
# Lazy enrichment (movies only): the live fetch above carries the FULL cast + rich detail (the sync
|
||||||
# only stored the listing's top ~3), so fill a not-yet-enriched movie — its cast becomes filterable
|
# only stored the listing's top ~3), so fill a not-yet-enriched movie — its cast becomes filterable
|
||||||
@@ -380,7 +444,15 @@ def item_detail(
|
|||||||
it.people_enriched_at = datetime.now(timezone.utc)
|
it.people_enriched_at = datetime.now(timezone.utc)
|
||||||
try:
|
try:
|
||||||
db.commit()
|
db.commit()
|
||||||
except Exception:
|
except Exception: # noqa: BLE001 — a failed enrich must not fail the page
|
||||||
|
# Same as the show path above: the info page then serves the three names the listing
|
||||||
|
# sync stored, and the full cast it just fetched is thrown away unreported.
|
||||||
|
log.warning(
|
||||||
|
"the movie enrichment could not be committed",
|
||||||
|
extra=logs.event(
|
||||||
|
"plex.detail.enrich_commit_failed", rating_key=it.rating_key, kind="movie"
|
||||||
|
),
|
||||||
|
)
|
||||||
db.rollback()
|
db.rollback()
|
||||||
|
|
||||||
hu = lang == "hu"
|
hu = lang == "hu"
|
||||||
|
|||||||
@@ -32,12 +32,12 @@ from app.plex.query import (
|
|||||||
meta_filter_conds,
|
meta_filter_conds,
|
||||||
top_art_keys,
|
top_art_keys,
|
||||||
)
|
)
|
||||||
|
from app.discover.queries import RAIL_LIMIT
|
||||||
from app.plex.tmdb import resolve_recs
|
from app.plex.tmdb import resolve_recs
|
||||||
from app.routes.plex._shared import _norm_lang
|
from app.routes.plex._shared import _norm_lang
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/plex", tags=["plex"])
|
router = APIRouter(prefix="/api/plex", tags=["plex"])
|
||||||
|
|
||||||
RAIL_LIMIT = 20
|
|
||||||
GENRE_TILES = 12
|
GENRE_TILES = 12
|
||||||
SURPRISE_PER_KIND = 3 # Surprise-me: at most this many movies + this many shows (6 total)
|
SURPRISE_PER_KIND = 3 # Surprise-me: at most this many movies + this many shows (6 total)
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
"""Plex people: cast/crew filter-chip cards + a single person's filmography page."""
|
"""Plex people: cast/crew filter-chip cards + a single person's filmography page."""
|
||||||
|
|
||||||
import logging
|
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
|
||||||
@@ -8,6 +7,7 @@ from fastapi import APIRouter, BackgroundTasks, Depends
|
|||||||
from sqlalchemy import func, or_, select
|
from sqlalchemy import func, or_, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app import logs
|
||||||
from app.auth import current_user
|
from app.auth import current_user
|
||||||
from app.db import SessionLocal, get_db
|
from app.db import SessionLocal, get_db
|
||||||
from app.models import (
|
from app.models import (
|
||||||
@@ -22,7 +22,7 @@ from app.plex.cards import movie_card, show_card
|
|||||||
from app.plex.query import content_libs, split_csv
|
from app.plex.query import content_libs, split_csv
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/plex", tags=["plex"])
|
router = APIRouter(prefix="/api/plex", tags=["plex"])
|
||||||
log = logging.getLogger("siftlode.plex")
|
log = logs.get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def _resolve_known_for(
|
def _resolve_known_for(
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ from fastapi import (
|
|||||||
)
|
)
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select, update
|
||||||
|
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.auth import current_user, resolved_user_id
|
from app.auth import current_user, resolved_user_id
|
||||||
@@ -38,6 +39,8 @@ from app.plex import stream as plex_stream
|
|||||||
from app.plex import watch_sync as plex_watch
|
from app.plex import watch_sync as plex_watch
|
||||||
from app.plex.client import PlexClient, PlexError, PlexNotConfigured
|
from app.plex.client import PlexClient, PlexError, PlexNotConfigured
|
||||||
from app.routes.plex._shared import item_or_404
|
from app.routes.plex._shared import item_or_404
|
||||||
|
from app.refusal import refused
|
||||||
|
from app.routes._common import not_found
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/plex", tags=["plex"])
|
router = APIRouter(prefix="/api/plex", tags=["plex"])
|
||||||
|
|
||||||
@@ -48,7 +51,7 @@ def _require_session(request: Request) -> int:
|
|||||||
low-sensitivity; a valid session is enough."""
|
low-sensitivity; a valid session is enough."""
|
||||||
uid, _ = resolved_user_id(request)
|
uid, _ = resolved_user_id(request)
|
||||||
if not uid:
|
if not uid:
|
||||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
raise refused(401, "not_authenticated")
|
||||||
return uid
|
return uid
|
||||||
|
|
||||||
|
|
||||||
@@ -77,15 +80,17 @@ def image(rating_key: str, request: Request, variant: str = "thumb") -> Response
|
|||||||
with SessionLocal() as db:
|
with SessionLocal() as db:
|
||||||
key = imgcache.key_for(db, str(rating_key), v)
|
key = imgcache.key_for(db, str(rating_key), v)
|
||||||
if not key:
|
if not key:
|
||||||
raise HTTPException(status_code=404, detail="No image")
|
raise not_found("image")
|
||||||
plex = PlexClient(db) # reads base URL + token now; the fetch below needs no DB
|
plex = PlexClient(db) # reads base URL + token now; the fetch below needs no DB
|
||||||
except PlexNotConfigured as e:
|
except PlexNotConfigured:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise refused(400, "plex_not_configured") from None
|
||||||
try:
|
try:
|
||||||
with plex:
|
with plex:
|
||||||
data, ct = plex.image_bytes(key, w, h)
|
data, ct = plex.image_bytes(key, w, h)
|
||||||
except PlexError as e:
|
# silent-ok: a byte proxy for an <img>; the browser shows the placeholder and no reader sees the
|
||||||
raise HTTPException(status_code=502, detail=f"Plex image fetch failed: {e}")
|
# detail — the 502 is the whole answer, as for the person-image proxy below.
|
||||||
|
except PlexError:
|
||||||
|
raise refused(502, "image_fetch_failed") from None
|
||||||
imgcache.write(cache_key, data, ct)
|
imgcache.write(cache_key, data, ct)
|
||||||
return imgcache.response(data, ct)
|
return imgcache.response(data, ct)
|
||||||
|
|
||||||
@@ -120,7 +125,7 @@ def subtitle_status(
|
|||||||
|
|
||||||
`at` is the viewer's absolute position: the extractor windows around it, so what gets read first
|
`at` is the viewer's absolute position: the extractor windows around it, so what gets read first
|
||||||
is the part actually about to be watched."""
|
is the part actually about to be watched."""
|
||||||
return subtitles.ensure(rating_key, sub_ord, at)
|
return subtitles.ensure(rating_key, sub_ord, at, viewer=user.id)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/subtitle/{rating_key}/{sub_ord}")
|
@router.get("/subtitle/{rating_key}/{sub_ord}")
|
||||||
@@ -213,7 +218,7 @@ def person_image(u: str, request: Request) -> Response:
|
|||||||
Signed-session auth (no DB) so it, like /image, never holds a pool connection during the fetch."""
|
Signed-session auth (no DB) so it, like /image, never holds a pool connection during the fetch."""
|
||||||
_require_session(request)
|
_require_session(request)
|
||||||
if not u.startswith(plex_people.PERSON_IMG_HOST):
|
if not u.startswith(plex_people.PERSON_IMG_HOST):
|
||||||
raise HTTPException(status_code=400, detail="Unsupported image host")
|
raise refused(400, "image_host")
|
||||||
cache_key = "person_" + hashlib.sha1(u.encode()).hexdigest()
|
cache_key = "person_" + hashlib.sha1(u.encode()).hexdigest()
|
||||||
hit = imgcache.read(cache_key)
|
hit = imgcache.read(cache_key)
|
||||||
if hit:
|
if hit:
|
||||||
@@ -223,7 +228,7 @@ def person_image(u: str, request: Request) -> Response:
|
|||||||
r = c.get(u)
|
r = c.get(u)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
except httpx.HTTPError:
|
except httpx.HTTPError:
|
||||||
raise HTTPException(status_code=502, detail="Image fetch failed")
|
raise refused(502, "image_fetch_failed")
|
||||||
ct = r.headers.get("content-type", "image/jpeg")
|
ct = r.headers.get("content-type", "image/jpeg")
|
||||||
imgcache.write(cache_key, r.content, ct)
|
imgcache.write(cache_key, r.content, ct)
|
||||||
return imgcache.response(r.content, ct)
|
return imgcache.response(r.content, ct)
|
||||||
@@ -295,6 +300,37 @@ def _push_watch(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _mark_finished(db: Session, user_id: int, item_id: int, now: datetime) -> bool:
|
||||||
|
"""Mark the item watched (position cleared) and say whether THIS call made it so. Two near-end
|
||||||
|
checkpoints arrive together routinely (the 10 s tick and the debounced `seeked` flush), and a
|
||||||
|
read-then-write let both see "not watched" and both scrobble. The transition is decided by the
|
||||||
|
write itself: an insert that did not conflict, or an update conditional on the old status —
|
||||||
|
under READ COMMITTED the second writer re-reads the committed row and matches nothing."""
|
||||||
|
values = {
|
||||||
|
"status": "watched",
|
||||||
|
"watched_at": now,
|
||||||
|
"position_seconds": 0,
|
||||||
|
"progress_updated_at": now,
|
||||||
|
"synced_to_plex": False,
|
||||||
|
}
|
||||||
|
inserted = db.execute(
|
||||||
|
pg_insert(PlexState)
|
||||||
|
.values(user_id=user_id, item_id=item_id, **values)
|
||||||
|
.on_conflict_do_nothing(constraint="uq_plex_user_item")
|
||||||
|
.returning(PlexState.id)
|
||||||
|
).first()
|
||||||
|
if inserted is not None:
|
||||||
|
return True
|
||||||
|
mine = (PlexState.user_id == user_id) & (PlexState.item_id == item_id)
|
||||||
|
if db.execute(
|
||||||
|
update(PlexState).where(mine, PlexState.status != "watched").values(values)
|
||||||
|
).rowcount:
|
||||||
|
return True
|
||||||
|
# Already watched: a re-finish still restamps the row, as it always did — just no scrobble.
|
||||||
|
db.execute(update(PlexState).where(mine).values(values))
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
class ItemProgressIn(BaseModel):
|
class ItemProgressIn(BaseModel):
|
||||||
# Finite only: JSON parsing here accepts `Infinity`/`NaN`, and the handler's `int()` turned
|
# Finite only: JSON parsing here accepts `Infinity`/`NaN`, and the handler's `int()` turned
|
||||||
# one into a 500.
|
# one into a 500.
|
||||||
@@ -330,11 +366,27 @@ def item_progress(
|
|||||||
# finish latch is up, or before the first position is known. Those are the states in which
|
# finish latch is up, or before the first position is known. Those are the states in which
|
||||||
# reaping is the right answer anyway, and a viewer who scrubs back into a reaped session gets
|
# reaping is the right answer anyway, and a viewer who scrubs back into a reaped session gets
|
||||||
# the same 404 → reclaim the player already handles.
|
# the same 404 → reclaim the player already handles.
|
||||||
plex_stream.touch_session(str(rating_key))
|
plex_stream.touch_session(user.id, str(rating_key))
|
||||||
position = max(0, int(body.position_seconds))
|
position = max(0, int(body.position_seconds))
|
||||||
duration = int(body.duration_seconds)
|
duration = int(body.duration_seconds)
|
||||||
final = body.final
|
final = body.final
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
|
near_end = duration > 0 and position > duration - _FINISH_MARGIN_S
|
||||||
|
if near_end:
|
||||||
|
flipped = _mark_finished(db, user.id, it.id, now)
|
||||||
|
# The finish supersedes every resume push still in flight for this item: stamping it as the
|
||||||
|
# latest push makes `_resume_push_superseded` drop them. Popping the entry instead (to keep
|
||||||
|
# the map small) removed the very guard, and an older resume push could then land at Plex
|
||||||
|
# AFTER the scrobble and revive a resume point on a watched item. Eviction bounds the map.
|
||||||
|
with _push_throttle_lock:
|
||||||
|
_last_push_at[(user.id, it.id)] = now
|
||||||
|
db.commit()
|
||||||
|
# Only the checkpoint that flipped the row scrobbles: a later checkpoint of the same finished
|
||||||
|
# item — or a second one racing this one — would otherwise bump Plex's viewCount again.
|
||||||
|
if flipped:
|
||||||
|
_push_watch(background, db, user.id, it.id, rating_key, "watched")
|
||||||
|
return {"ok": True, "status": "watched", "position_seconds": 0}
|
||||||
|
|
||||||
st = (
|
st = (
|
||||||
db.execute(
|
db.execute(
|
||||||
select(PlexState).where(PlexState.user_id == user.id, PlexState.item_id == it.id)
|
select(PlexState).where(PlexState.user_id == user.id, PlexState.item_id == it.id)
|
||||||
@@ -342,28 +394,6 @@ def item_progress(
|
|||||||
.scalars()
|
.scalars()
|
||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
was_watched = st is not None and st.status == "watched"
|
|
||||||
|
|
||||||
near_end = duration > 0 and position > duration - _FINISH_MARGIN_S
|
|
||||||
if near_end:
|
|
||||||
if st is None:
|
|
||||||
st = PlexState(user_id=user.id, item_id=it.id)
|
|
||||||
db.add(st)
|
|
||||||
st.status = "watched"
|
|
||||||
st.watched_at = now
|
|
||||||
st.position_seconds = 0
|
|
||||||
st.progress_updated_at = now
|
|
||||||
st.synced_to_plex = False
|
|
||||||
# Terminal state for resume write-back: drop the throttle/ordering entry so the map doesn't
|
|
||||||
# retain a dead key for every finished item.
|
|
||||||
with _push_throttle_lock:
|
|
||||||
_last_push_at.pop((user.id, it.id), None)
|
|
||||||
db.commit()
|
|
||||||
# Immediate on the watched transition; skip a repeat scrobble if it was already watched
|
|
||||||
# (a later checkpoint of the same finished item would otherwise bump Plex's viewCount again).
|
|
||||||
if not was_watched:
|
|
||||||
_push_watch(background, db, user.id, it.id, rating_key, "watched")
|
|
||||||
return {"status": "watched", "position_seconds": 0}
|
|
||||||
|
|
||||||
if position < _PROGRESS_MIN_S:
|
if position < _PROGRESS_MIN_S:
|
||||||
if st is not None and st.status == "new":
|
if st is not None and st.status == "new":
|
||||||
@@ -372,7 +402,7 @@ def item_progress(
|
|||||||
st.position_seconds = 0
|
st.position_seconds = 0
|
||||||
st.progress_updated_at = now
|
st.progress_updated_at = now
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"position_seconds": 0}
|
return {"ok": True, "position_seconds": 0}
|
||||||
|
|
||||||
if st is None:
|
if st is None:
|
||||||
st = PlexState(user_id=user.id, item_id=it.id)
|
st = PlexState(user_id=user.id, item_id=it.id)
|
||||||
@@ -397,7 +427,7 @@ def item_progress(
|
|||||||
duration * 1000,
|
duration * 1000,
|
||||||
intent=now,
|
intent=now,
|
||||||
)
|
)
|
||||||
return {"position_seconds": position}
|
return {"ok": True, "position_seconds": position}
|
||||||
|
|
||||||
|
|
||||||
class ItemStateIn(BaseModel):
|
class ItemStateIn(BaseModel):
|
||||||
@@ -418,7 +448,7 @@ def item_state(
|
|||||||
it = item_or_404(db, rating_key)
|
it = item_or_404(db, rating_key)
|
||||||
status = body.status
|
status = body.status
|
||||||
if status not in ("new", "watched", "hidden"):
|
if status not in ("new", "watched", "hidden"):
|
||||||
raise HTTPException(status_code=400, detail="Invalid status")
|
raise refused(400, "invalid_status")
|
||||||
st = (
|
st = (
|
||||||
db.execute(
|
db.execute(
|
||||||
select(PlexState).where(PlexState.user_id == user.id, PlexState.item_id == it.id)
|
select(PlexState).where(PlexState.user_id == user.id, PlexState.item_id == it.id)
|
||||||
@@ -443,7 +473,7 @@ def item_state(
|
|||||||
db.commit()
|
db.commit()
|
||||||
if mirrored_on_plex:
|
if mirrored_on_plex:
|
||||||
_push_watch(background, db, user.id, it.id, rating_key, "unwatched")
|
_push_watch(background, db, user.id, it.id, rating_key, "unwatched")
|
||||||
return {"status": "new"}
|
return {"ok": True, "status": "new"}
|
||||||
if st is None:
|
if st is None:
|
||||||
st = PlexState(user_id=user.id, item_id=it.id)
|
st = PlexState(user_id=user.id, item_id=it.id)
|
||||||
db.add(st)
|
db.add(st)
|
||||||
@@ -455,7 +485,7 @@ def item_state(
|
|||||||
db.commit()
|
db.commit()
|
||||||
if status == "watched" and prev_status != "watched":
|
if status == "watched" and prev_status != "watched":
|
||||||
_push_watch(background, db, user.id, it.id, rating_key, "watched")
|
_push_watch(background, db, user.id, it.id, rating_key, "watched")
|
||||||
return {"status": status}
|
return {"ok": True, "status": status}
|
||||||
|
|
||||||
|
|
||||||
def _bulk_state(
|
def _bulk_state(
|
||||||
@@ -497,7 +527,9 @@ def _bulk_state(
|
|||||||
db.delete(st)
|
db.delete(st)
|
||||||
if prev != "new":
|
if prev != "new":
|
||||||
to_push.append((e.id, e.rating_key, "unwatched"))
|
to_push.append((e.id, e.rating_key, "unwatched"))
|
||||||
db.commit()
|
# Flush, not commit: the handler commits, and it does so before the response — which is when
|
||||||
|
# the background push below runs, so the push always reads the committed marks.
|
||||||
|
db.flush()
|
||||||
if pushable and to_push:
|
if pushable and to_push:
|
||||||
# One coalesced background task for the whole batch (reuses a single session + Plex client),
|
# One coalesced background task for the whole batch (reuses a single session + Plex client),
|
||||||
# not one task per episode — a 200-ep show mark is 1 task, not 200.
|
# not one task per episode — a 200-ep show mark is 1 task, not 200.
|
||||||
@@ -520,7 +552,7 @@ def show_state(
|
|||||||
"""Mark a whole show watched/unwatched (all its episodes) for the current user + push to Plex."""
|
"""Mark a whole show watched/unwatched (all its episodes) for the current user + push to Plex."""
|
||||||
sh = by_rating_key(db, PlexShow, rating_key)
|
sh = by_rating_key(db, PlexShow, rating_key)
|
||||||
if sh is None:
|
if sh is None:
|
||||||
raise HTTPException(status_code=404, detail="Unknown Plex show")
|
raise not_found("show")
|
||||||
watched = body.watched
|
watched = body.watched
|
||||||
eps = (
|
eps = (
|
||||||
db.execute(select(PlexItem).where(PlexItem.show_id == sh.id, PlexItem.kind == "episode"))
|
db.execute(select(PlexItem).where(PlexItem.show_id == sh.id, PlexItem.kind == "episode"))
|
||||||
@@ -528,7 +560,8 @@ def show_state(
|
|||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
changed = _bulk_state(background, db, user.id, eps, watched)
|
changed = _bulk_state(background, db, user.id, eps, watched)
|
||||||
return {"changed": changed, "watched": watched}
|
db.commit()
|
||||||
|
return {"ok": True, "changed": changed, "watched": watched}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/season/{rating_key}/state")
|
@router.post("/season/{rating_key}/state")
|
||||||
@@ -542,7 +575,7 @@ def season_state(
|
|||||||
"""Mark a whole season watched/unwatched (all its episodes) for the current user + push to Plex."""
|
"""Mark a whole season watched/unwatched (all its episodes) for the current user + push to Plex."""
|
||||||
se = by_rating_key(db, PlexSeason, rating_key)
|
se = by_rating_key(db, PlexSeason, rating_key)
|
||||||
if se is None:
|
if se is None:
|
||||||
raise HTTPException(status_code=404, detail="Unknown Plex season")
|
raise not_found("season")
|
||||||
watched = body.watched
|
watched = body.watched
|
||||||
eps = (
|
eps = (
|
||||||
db.execute(select(PlexItem).where(PlexItem.season_id == se.id, PlexItem.kind == "episode"))
|
db.execute(select(PlexItem).where(PlexItem.season_id == se.id, PlexItem.kind == "episode"))
|
||||||
@@ -550,7 +583,8 @@ def season_state(
|
|||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
changed = _bulk_state(background, db, user.id, eps, watched)
|
changed = _bulk_state(background, db, user.id, eps, watched)
|
||||||
return {"changed": changed, "watched": watched}
|
db.commit()
|
||||||
|
return {"ok": True, "changed": changed, "watched": watched}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/stream/{rating_key}/session")
|
@router.post("/stream/{rating_key}/session")
|
||||||
@@ -575,21 +609,23 @@ def stream_session(
|
|||||||
# the client can switch audio without a restart — even for otherwise direct-playable files.
|
# the client can switch audio without a restart — even for otherwise direct-playable files.
|
||||||
if it.playable == "direct" and audio is None and not aoff and not multi:
|
if it.playable == "direct" and audio is None and not aoff and not multi:
|
||||||
return {
|
return {
|
||||||
|
"ok": True,
|
||||||
"mode": "direct",
|
"mode": "direct",
|
||||||
"url": f"/api/plex/stream/{it.rating_key}/file",
|
"url": f"/api/plex/stream/{it.rating_key}/file",
|
||||||
"start_s": 0.0,
|
"start_s": 0.0,
|
||||||
}
|
}
|
||||||
if plex_paths.local_media_path(db, it.file_path) is None:
|
if plex_paths.local_media_path(db, it.file_path) is None:
|
||||||
raise HTTPException(status_code=404, detail="Media file not found on disk")
|
raise refused(404, "media_missing")
|
||||||
s = plex_stream.start_session(db, it, start, audio, aoff, multi, transcode=transcode)
|
s = plex_stream.start_session(db, it, user.id, start, audio, aoff, multi, transcode=transcode)
|
||||||
if s is None:
|
if s is None:
|
||||||
raise HTTPException(status_code=404, detail="Media file not found on disk")
|
raise refused(404, "media_missing")
|
||||||
# Report the REAL media start (the keyframe ffmpeg landed on, which hls.js zero-bases to), not the
|
# Report the REAL media start (the keyframe ffmpeg landed on, which hls.js zero-bases to), not the
|
||||||
# requested offset — the client's absolute clock and the subtitle cue-shift both key off this.
|
# requested offset — the client's absolute clock and the subtitle cue-shift both key off this.
|
||||||
# `transcode`/`hw`/`tonemap` let the player show an honest cold-start ("Preparing stream…") and
|
# `transcode`/`hw`/`tonemap` let the player show an honest cold-start ("Preparing stream…") and
|
||||||
# surface the active mode + encoder in the stats overlay (`tonemap` = the HW HDR→SDR path); both
|
# surface the active mode + encoder in the stats overlay (`tonemap` = the HW HDR→SDR path); both
|
||||||
# segment streams are otherwise identical.
|
# segment streams are otherwise identical.
|
||||||
return {
|
return {
|
||||||
|
"ok": True,
|
||||||
"mode": "hls",
|
"mode": "hls",
|
||||||
"start_s": s.media_start_s,
|
"start_s": s.media_start_s,
|
||||||
# The session id is part of the path (not a query param) so the playlist's RELATIVE segment
|
# The session id is part of the path (not a query param) so the playlist's RELATIVE segment
|
||||||
@@ -614,7 +650,7 @@ def stream_file(
|
|||||||
it = item_or_404(db, rating_key)
|
it = item_or_404(db, rating_key)
|
||||||
src = plex_paths.local_media_path(db, it.file_path)
|
src = plex_paths.local_media_path(db, it.file_path)
|
||||||
if src is None:
|
if src is None:
|
||||||
raise HTTPException(status_code=404, detail="Media file not found on disk")
|
raise refused(404, "media_missing")
|
||||||
if download:
|
if download:
|
||||||
# A clean, safe download name from the title + the real file extension.
|
# A clean, safe download name from the title + the real file extension.
|
||||||
ext = src.suffix or (f".{it.container}" if it.container else "")
|
ext = src.suffix or (f".{it.container}" if it.container else "")
|
||||||
@@ -651,9 +687,9 @@ def stream_hls_file(
|
|||||||
references its segments/subtitles by relative name, all resolving back here.
|
references its segments/subtitles by relative name, all resolving back here.
|
||||||
|
|
||||||
The session id in the path is what makes "back here" mean the same session. Sessions are keyed by
|
The session id in the path is what makes "back here" mean the same session. Sessions are keyed by
|
||||||
rating_key alone and a seek-restart or a second viewer of the same item replaces the registered
|
(viewer, rating_key) and a seek-restart replaces the viewer's registered one, while the DIRECTORY
|
||||||
one, while the DIRECTORY is per (key, offset, tag) — so resolving by rating_key handed the old
|
is per (key, viewer, offset, tag) — so resolving by the key alone handed the old player the new
|
||||||
viewer the new session's `seg_12.ts`, produced from a different offset under `-copyts`: wrong
|
session's `seg_12.ts`, produced from a different offset under `-copyts`: wrong
|
||||||
bytes with a 200, which the player has no way to notice (deep-review round 5). A request for a
|
bytes with a 200, which the player has no way to notice (deep-review round 5). A request for a
|
||||||
session that is no longer the registered one is a 404, and the player reclaims at the playhead.
|
session that is no longer the registered one is a 404, and the player reclaims at the playhead.
|
||||||
|
|
||||||
@@ -668,10 +704,10 @@ def stream_hls_file(
|
|||||||
or "\\" in filename
|
or "\\" in filename
|
||||||
or ".." in filename
|
or ".." in filename
|
||||||
):
|
):
|
||||||
raise HTTPException(status_code=404, detail="Not found")
|
raise not_found("file")
|
||||||
s = plex_stream.current_session(str(rating_key))
|
s = plex_stream.current_session(user.id, str(rating_key))
|
||||||
if s is None or s.id != session_id:
|
if s is None or s.id != session_id:
|
||||||
raise HTTPException(status_code=404, detail="No active playback session")
|
raise refused(404, "session_gone")
|
||||||
path = s.dir / filename
|
path = s.dir / filename
|
||||||
# Only a name THIS session can actually produce is worth waiting for: its own playlists, and a
|
# Only a name THIS session can actually produce is worth waiting for: its own playlists, and a
|
||||||
# segment of one of its own renditions whose predecessor is already on disk (ffmpeg writes them
|
# segment of one of its own renditions whose predecessor is already on disk (ffmpeg writes them
|
||||||
@@ -681,14 +717,14 @@ def stream_hls_file(
|
|||||||
# past the produced region — or a deliberate flood of hopeless names — from parking threadpool
|
# past the produced region — or a deliberate flood of hopeless names — from parking threadpool
|
||||||
# workers and their DB connections (see `artifact_may_arrive`).
|
# workers and their DB connections (see `artifact_may_arrive`).
|
||||||
if not plex_stream.artifact_may_arrive(s, filename):
|
if not plex_stream.artifact_may_arrive(s, filename):
|
||||||
raise HTTPException(status_code=404, detail="Not available yet")
|
raise refused(404, "not_ready")
|
||||||
timeout = 30 if ext == ".m3u8" else 20
|
timeout = 30 if ext == ".m3u8" else 20
|
||||||
# `proc` so a session whose ffmpeg has already exited answers at once instead of polling out the
|
# `proc` so a session whose ffmpeg has already exited answers at once instead of polling out the
|
||||||
# whole timeout: this is a SYNC route on the ~40-slot threadpool, holding a pooled DB connection
|
# whole timeout: this is a SYNC route on the ~40-slot threadpool, holding a pooled DB connection
|
||||||
# with it, so every such wait is a worker (and a connection) parked for 20 s — an ordinary seek
|
# with it, so every such wait is a worker (and a connection) parked for 20 s — an ordinary seek
|
||||||
# past the produced region was enough to do it (round 5).
|
# past the produced region was enough to do it (round 5).
|
||||||
if not plex_stream.wait_for(path, timeout, proc=s.proc):
|
if not plex_stream.wait_for(path, timeout, proc=s.proc):
|
||||||
raise HTTPException(status_code=404, detail="Not available yet")
|
raise refused(404, "not_ready")
|
||||||
try:
|
try:
|
||||||
content = path.read_bytes()
|
content = path.read_bytes()
|
||||||
except OSError:
|
except OSError:
|
||||||
@@ -699,7 +735,7 @@ def stream_hls_file(
|
|||||||
# restarting at the playhead. Ordinary trigger: `loadSession` awaits the new session — which
|
# restarting at the playhead. Ordinary trigger: `loadSession` awaits the new session — which
|
||||||
# kills and rmtrees the old one — before destroying the old hls instance, so the outgoing
|
# kills and rmtrees the old one — before destroying the old hls instance, so the outgoing
|
||||||
# instance's in-flight segment request races the rmtree on every seek-restart.
|
# instance's in-flight segment request races the rmtree on every seek-restart.
|
||||||
raise HTTPException(status_code=404, detail="No active playback session") from None
|
raise refused(404, "session_gone") from None
|
||||||
return Response(
|
return Response(
|
||||||
content=content,
|
content=content,
|
||||||
media_type=_HLS_CT[ext],
|
media_type=_HLS_CT[ext],
|
||||||
|
|||||||
@@ -2,9 +2,11 @@
|
|||||||
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
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
|
||||||
@@ -19,15 +21,12 @@ from app.models import (
|
|||||||
)
|
)
|
||||||
from app.plex.cards import leaf_card
|
from app.plex.cards import leaf_card
|
||||||
from app.plex.lookups import by_rating_key
|
from app.plex.lookups import by_rating_key
|
||||||
from app.routes._common import owned_or_404
|
from app.routes._common import owned_or_404, not_found
|
||||||
|
from app.refusal import refused
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/plex", tags=["plex"])
|
router = APIRouter(prefix="/api/plex", tags=["plex"])
|
||||||
|
|
||||||
|
|
||||||
def _own_playlist_or_404(db: Session, pid: int, user: User) -> PlexPlaylist:
|
|
||||||
return owned_or_404(db, PlexPlaylist, pid, user, what="playlist")
|
|
||||||
|
|
||||||
|
|
||||||
def _playlist_card(pl: PlexPlaylist, count: int, thumb_rk: str | None) -> dict:
|
def _playlist_card(pl: PlexPlaylist, count: int, thumb_rk: str | None) -> dict:
|
||||||
return {
|
return {
|
||||||
"id": pl.id,
|
"id": pl.id,
|
||||||
@@ -120,7 +119,7 @@ def create_playlist(
|
|||||||
) -> dict:
|
) -> dict:
|
||||||
title = body.title.strip()
|
title = body.title.strip()
|
||||||
if not title:
|
if not title:
|
||||||
raise HTTPException(status_code=400, detail="title is required")
|
raise refused(400, "title_required")
|
||||||
pl = PlexPlaylist(user_id=user.id, title=title)
|
pl = PlexPlaylist(user_id=user.id, title=title)
|
||||||
db.add(pl)
|
db.add(pl)
|
||||||
db.flush()
|
db.flush()
|
||||||
@@ -140,7 +139,7 @@ def create_playlist(
|
|||||||
def playlist_detail(
|
def playlist_detail(
|
||||||
pid: int, user: User = Depends(current_user), db: Session = Depends(get_db)
|
pid: int, user: User = Depends(current_user), db: Session = Depends(get_db)
|
||||||
) -> dict:
|
) -> dict:
|
||||||
pl = _own_playlist_or_404(db, pid, user)
|
pl = owned_or_404(db, PlexPlaylist, pid, user, what="playlist")
|
||||||
rows = db.execute(_playlist_item_query(pid)).all()
|
rows = db.execute(_playlist_item_query(pid)).all()
|
||||||
items = [it for (_pi, it) in rows]
|
items = [it for (_pi, it) in rows]
|
||||||
states = {
|
states = {
|
||||||
@@ -183,10 +182,10 @@ def rename_playlist(
|
|||||||
user: User = Depends(current_user),
|
user: User = Depends(current_user),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
pl = _own_playlist_or_404(db, pid, user)
|
pl = owned_or_404(db, PlexPlaylist, pid, user, what="playlist")
|
||||||
title = body.title.strip()
|
title = body.title.strip()
|
||||||
if not title:
|
if not title:
|
||||||
raise HTTPException(status_code=400, detail="title is required")
|
raise refused(400, "title_required")
|
||||||
pl.title = title
|
pl.title = title
|
||||||
db.commit()
|
db.commit()
|
||||||
count = (
|
count = (
|
||||||
@@ -203,7 +202,7 @@ def rename_playlist(
|
|||||||
def delete_playlist(
|
def delete_playlist(
|
||||||
pid: int, user: User = Depends(current_user), db: Session = Depends(get_db)
|
pid: int, user: User = Depends(current_user), db: Session = Depends(get_db)
|
||||||
) -> dict:
|
) -> dict:
|
||||||
pl = _own_playlist_or_404(db, pid, user)
|
pl = owned_or_404(db, PlexPlaylist, pid, user, what="playlist")
|
||||||
db.delete(pl)
|
db.delete(pl)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"deleted": pid}
|
return {"deleted": pid}
|
||||||
@@ -220,10 +219,11 @@ 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 = _own_playlist_or_404(db, pid, user)
|
# 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 HTTPException(status_code=404, detail="Unknown item")
|
raise not_found("item")
|
||||||
exists = (
|
exists = (
|
||||||
db.execute(
|
db.execute(
|
||||||
select(PlexPlaylistItem).where(
|
select(PlexPlaylistItem).where(
|
||||||
@@ -252,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 = _own_playlist_or_404(db, pid, user)
|
# 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(
|
||||||
@@ -288,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 = _own_playlist_or_404(db, pid, user)
|
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(
|
||||||
@@ -328,7 +389,7 @@ def playlist_add_bulk(
|
|||||||
)
|
)
|
||||||
or 0
|
or 0
|
||||||
)
|
)
|
||||||
return {"added": added, "item_count": total}
|
return {"ok": True, "added": added, "item_count": total}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/playlists/{pid}/items/remove-bulk")
|
@router.post("/playlists/{pid}/items/remove-bulk")
|
||||||
@@ -340,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 = _own_playlist_or_404(db, pid, user)
|
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:
|
||||||
@@ -359,7 +420,7 @@ def playlist_remove_bulk(
|
|||||||
)
|
)
|
||||||
or 0
|
or 0
|
||||||
)
|
)
|
||||||
return {"removed": removed, "item_count": total}
|
return {"ok": True, "removed": removed, "item_count": total}
|
||||||
|
|
||||||
|
|
||||||
@router.put("/playlists/{pid}/order")
|
@router.put("/playlists/{pid}/order")
|
||||||
@@ -370,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 = _own_playlist_or_404(db, pid, user)
|
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
|
||||||
@@ -378,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}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ the app or any account data. Password-protected links exchange the password once
|
|||||||
a short-lived signed grant that the media URL carries (a `<video src>` can't send a header).
|
a short-lived signed grant that the media URL carries (a `<video src>` can't send a header).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
from fastapi import APIRouter, Depends, Request
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
@@ -19,6 +19,8 @@ from app.downloads import storage
|
|||||||
from app.models import DownloadJob, DownloadLink, MediaAsset
|
from app.models import DownloadJob, DownloadLink, MediaAsset
|
||||||
from app.ratelimit import RateLimiter, client_key
|
from app.ratelimit import RateLimiter, client_key
|
||||||
from app.security import verify_password
|
from app.security import verify_password
|
||||||
|
from app.refusal import refused
|
||||||
|
from app.routes._common import not_found
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/public", tags=["public"])
|
router = APIRouter(prefix="/api/public", tags=["public"])
|
||||||
|
|
||||||
@@ -37,7 +39,7 @@ _unlock_failures = RateLimiter(max_events=50, window_seconds=300)
|
|||||||
def _link_or_404(db: Session, token: str) -> DownloadLink:
|
def _link_or_404(db: Session, token: str) -> DownloadLink:
|
||||||
link = db.execute(select(DownloadLink).where(DownloadLink.token == token)).scalar_one_or_none()
|
link = db.execute(select(DownloadLink).where(DownloadLink.token == token)).scalar_one_or_none()
|
||||||
if link is None or linksmod.is_expired(link):
|
if link is None or linksmod.is_expired(link):
|
||||||
raise HTTPException(status_code=404, detail="This link is no longer available.")
|
raise refused(404, "link_gone")
|
||||||
return link
|
return link
|
||||||
|
|
||||||
|
|
||||||
@@ -45,12 +47,12 @@ def _ready_target(db: Session, link: DownloadLink) -> tuple[DownloadJob, MediaAs
|
|||||||
job = db.get(DownloadJob, link.job_id)
|
job = db.get(DownloadJob, link.job_id)
|
||||||
asset = db.get(MediaAsset, job.asset_id) if job and job.asset_id else None
|
asset = db.get(MediaAsset, job.asset_id) if job and job.asset_id else None
|
||||||
if job is None or asset is None or asset.status != "ready" or not asset.rel_path:
|
if job is None or asset is None or asset.status != "ready" or not asset.rel_path:
|
||||||
raise HTTPException(status_code=404, detail="This video is no longer available.")
|
raise refused(404, "video_gone")
|
||||||
# Verify the file is actually on disk here (not just flagged ready), so the watch page never
|
# Verify the file is actually on disk here (not just flagged ready), so the watch page never
|
||||||
# loads a player for a missing file — meta and file agree.
|
# loads a player for a missing file — meta and file agree.
|
||||||
path = storage.safe_abs_path(settings.download_root, asset.rel_path)
|
path = storage.safe_abs_path(settings.download_root, asset.rel_path)
|
||||||
if path is None:
|
if path is None:
|
||||||
raise HTTPException(status_code=404, detail="This video is no longer available.")
|
raise refused(404, "video_gone")
|
||||||
return job, asset
|
return job, asset
|
||||||
|
|
||||||
|
|
||||||
@@ -100,22 +102,24 @@ def watch_unlock(
|
|||||||
link = _link_or_404(db, token)
|
link = _link_or_404(db, token)
|
||||||
viewer = f"{token}|{client_key(request)}"
|
viewer = f"{token}|{client_key(request)}"
|
||||||
if _unlock_failures.exhausted(token) or not _unlock_limit.allow(viewer):
|
if _unlock_failures.exhausted(token) or not _unlock_limit.allow(viewer):
|
||||||
raise HTTPException(status_code=429, detail="Too many attempts. Try again later.")
|
raise refused(429, "too_many_attempts")
|
||||||
if not link.password_hash:
|
if not link.password_hash:
|
||||||
# Not a password link — nothing to unlock; just hand back the plain meta.
|
# Not a password link — nothing to unlock; just hand back the plain meta.
|
||||||
job, asset = _ready_target(db, link)
|
job, asset = _ready_target(db, link)
|
||||||
return {
|
return {
|
||||||
|
"ok": True,
|
||||||
"needs_password": False,
|
"needs_password": False,
|
||||||
**linksmod.public_meta(link, job, asset, _file_url(token), _poster_url(token, asset)),
|
**linksmod.public_meta(link, job, asset, _file_url(token), _poster_url(token, asset)),
|
||||||
}
|
}
|
||||||
if not verify_password(body.password, link.password_hash):
|
if not verify_password(body.password, link.password_hash):
|
||||||
_unlock_failures.allow(token)
|
_unlock_failures.allow(token)
|
||||||
raise HTTPException(status_code=403, detail="Wrong password.")
|
raise refused(403, "wrong_share_password")
|
||||||
job, asset = _ready_target(db, link)
|
job, asset = _ready_target(db, link)
|
||||||
link.view_count += 1
|
link.view_count += 1
|
||||||
db.commit()
|
db.commit()
|
||||||
grant = linksmod.make_grant(token, link.password_version)
|
grant = linksmod.make_grant(token, link.password_version)
|
||||||
return {
|
return {
|
||||||
|
"ok": True,
|
||||||
"needs_password": False,
|
"needs_password": False,
|
||||||
**linksmod.public_meta(
|
**linksmod.public_meta(
|
||||||
link, job, asset, _file_url(token, grant), _poster_url(token, asset, grant)
|
link, job, asset, _file_url(token, grant), _poster_url(token, asset, grant)
|
||||||
@@ -129,11 +133,11 @@ def watch_file(token: str, g: str | None = None, db: Session = Depends(get_db)):
|
|||||||
`allow_download` link serves it as an attachment. Password links require a valid grant."""
|
`allow_download` link serves it as an attachment. Password links require a valid grant."""
|
||||||
link = _link_or_404(db, token)
|
link = _link_or_404(db, token)
|
||||||
if link.password_hash and not linksmod.check_grant(token, g, link.password_version):
|
if link.password_hash and not linksmod.check_grant(token, g, link.password_version):
|
||||||
raise HTTPException(status_code=403, detail="This link needs a password.")
|
raise refused(403, "share_password_required")
|
||||||
asset = _ready_asset(db, link)
|
asset = _ready_asset(db, link)
|
||||||
path = storage.safe_abs_path(settings.download_root, asset.rel_path)
|
path = storage.safe_abs_path(settings.download_root, asset.rel_path)
|
||||||
if path is None:
|
if path is None:
|
||||||
raise HTTPException(status_code=404, detail="This video is no longer available.")
|
raise refused(404, "video_gone")
|
||||||
|
|
||||||
filename = storage.download_filename(asset.title or "video", asset.container, path)
|
filename = storage.download_filename(asset.title or "video", asset.container, path)
|
||||||
# The served Content-Type comes from the asset's ffprobe-verified media_kind + a container
|
# The served Content-Type comes from the asset's ffprobe-verified media_kind + a container
|
||||||
@@ -163,11 +167,11 @@ def watch_poster(token: str, g: str | None = None, db: Session = Depends(get_db)
|
|||||||
and the link-preview image). Password links require a valid grant, like the file."""
|
and the link-preview image). Password links require a valid grant, like the file."""
|
||||||
link = _link_or_404(db, token)
|
link = _link_or_404(db, token)
|
||||||
if link.password_hash and not linksmod.check_grant(token, g, link.password_version):
|
if link.password_hash and not linksmod.check_grant(token, g, link.password_version):
|
||||||
raise HTTPException(status_code=403, detail="This link needs a password.")
|
raise refused(403, "share_password_required")
|
||||||
job, asset = _ready_target(db, link)
|
job, asset = _ready_target(db, link)
|
||||||
if not asset.poster_path:
|
if not asset.poster_path:
|
||||||
raise HTTPException(status_code=404, detail="No poster.")
|
raise not_found("poster")
|
||||||
path = storage.safe_abs_path(settings.download_root, asset.poster_path)
|
path = storage.safe_abs_path(settings.download_root, asset.poster_path)
|
||||||
if path is None:
|
if path is None:
|
||||||
raise HTTPException(status_code=404, detail="No poster.")
|
raise not_found("poster")
|
||||||
return FileResponse(path, media_type="image/jpeg")
|
return FileResponse(path, media_type="image/jpeg")
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy import func, select, update
|
from sqlalchemy import func, select, update
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
@@ -8,6 +8,7 @@ from app.auth import require_human
|
|||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.models import SavedView, User
|
from app.models import SavedView, User
|
||||||
from app.routes._common import owned_or_404
|
from app.routes._common import owned_or_404
|
||||||
|
from app.refusal import refused
|
||||||
|
|
||||||
# Saved "smart views": per-user named snapshots of a module's filters (feed or plex, keyed by
|
# Saved "smart views": per-user named snapshots of a module's filters (feed or plex, keyed by
|
||||||
# `source`). Gated behind require_human so the shared demo account can't pollute a communal list
|
# `source`). Gated behind require_human so the shared demo account can't pollute a communal list
|
||||||
@@ -66,7 +67,7 @@ def create_view(
|
|||||||
) -> dict:
|
) -> dict:
|
||||||
name = body.name.strip()
|
name = body.name.strip()
|
||||||
if not name:
|
if not name:
|
||||||
raise HTTPException(status_code=400, detail="name is required")
|
raise refused(400, "name_required")
|
||||||
source = _norm_source(body.source)
|
source = _norm_source(body.source)
|
||||||
# Position is per (user, source), so each module's views number 0..N independently.
|
# Position is per (user, source), so each module's views number 0..N independently.
|
||||||
maxpos = db.execute(
|
maxpos = db.execute(
|
||||||
@@ -86,14 +87,10 @@ def create_view(
|
|||||||
db.commit()
|
db.commit()
|
||||||
except IntegrityError:
|
except IntegrityError:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
raise HTTPException(status_code=409, detail=f"You already have a view named “{name}”.")
|
raise refused(409, "view_name_taken", name=name)
|
||||||
return _serialize(view)
|
return _serialize(view)
|
||||||
|
|
||||||
|
|
||||||
def _own_view(db: Session, user: User, view_id: int) -> SavedView:
|
|
||||||
return owned_or_404(db, SavedView, view_id, user, what="view")
|
|
||||||
|
|
||||||
|
|
||||||
class UpdateViewIn(BaseModel):
|
class UpdateViewIn(BaseModel):
|
||||||
# PATCH: presence decides what's touched (read via model_fields_set). filters, when sent,
|
# PATCH: presence decides what's touched (read via model_fields_set). filters, when sent,
|
||||||
# must be a real object — an explicit null is rejected (a view can't have null filters).
|
# must be a real object — an explicit null is rejected (a view can't have null filters).
|
||||||
@@ -109,16 +106,16 @@ def update_view(
|
|||||||
user: User = Depends(require_human),
|
user: User = Depends(require_human),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
view = _own_view(db, user, view_id)
|
view = owned_or_404(db, SavedView, view_id, user, what="view")
|
||||||
sent = body.model_fields_set
|
sent = body.model_fields_set
|
||||||
if "name" in sent:
|
if "name" in sent:
|
||||||
name = (body.name or "").strip()
|
name = (body.name or "").strip()
|
||||||
if not name:
|
if not name:
|
||||||
raise HTTPException(status_code=400, detail="name cannot be empty")
|
raise refused(400, "name_required")
|
||||||
view.name = name[:80]
|
view.name = name[:80]
|
||||||
if "filters" in sent:
|
if "filters" in sent:
|
||||||
if body.filters is None:
|
if body.filters is None:
|
||||||
raise HTTPException(status_code=400, detail="filters must be an object")
|
raise refused(400, "filters_not_object")
|
||||||
view.filters = body.filters
|
view.filters = body.filters
|
||||||
if "is_default" in sent:
|
if "is_default" in sent:
|
||||||
make_default = bool(body.is_default)
|
make_default = bool(body.is_default)
|
||||||
@@ -138,7 +135,7 @@ def update_view(
|
|||||||
db.commit()
|
db.commit()
|
||||||
except IntegrityError:
|
except IntegrityError:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
raise HTTPException(status_code=409, detail=f"You already have a view named “{view.name}”.")
|
raise refused(409, "view_name_taken", name=view.name)
|
||||||
return _serialize(view)
|
return _serialize(view)
|
||||||
|
|
||||||
|
|
||||||
@@ -148,7 +145,7 @@ def delete_view(
|
|||||||
user: User = Depends(require_human),
|
user: User = Depends(require_human),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
view = _own_view(db, user, view_id)
|
view = owned_or_404(db, SavedView, view_id, user, what="view")
|
||||||
db.delete(view)
|
db.delete(view)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"deleted": view_id}
|
return {"deleted": view_id}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
per-job run activity (from the in-process scheduler), the work still queued (DB-derived),
|
per-job run activity (from the in-process scheduler), the work still queued (DB-derived),
|
||||||
and the shared quota picture."""
|
and the shared quota picture."""
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy import and_, func, or_, select
|
from sqlalchemy import and_, func, or_, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
@@ -25,6 +25,8 @@ from app.scheduler import (
|
|||||||
from app.sync.explore import followed_channel
|
from app.sync.explore import followed_channel
|
||||||
from app.sync.runner import estimate_deep_backfill
|
from app.sync.runner import estimate_deep_backfill
|
||||||
from app.routes._common import not_found
|
from app.routes._common import not_found
|
||||||
|
from app.refusal import refused
|
||||||
|
from app.refusal import setting_refused
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/admin/scheduler", tags=["admin"])
|
router = APIRouter(prefix="/api/admin/scheduler", tags=["admin"])
|
||||||
|
|
||||||
@@ -46,10 +48,7 @@ def set_job_interval(
|
|||||||
raise not_found("job")
|
raise not_found("job")
|
||||||
minutes = body.interval_minutes
|
minutes = body.interval_minutes
|
||||||
if not (MIN_INTERVAL <= minutes <= MAX_INTERVAL):
|
if not (MIN_INTERVAL <= minutes <= MAX_INTERVAL):
|
||||||
raise HTTPException(
|
raise refused(400, "invalid_interval", min=MIN_INTERVAL, max=MAX_INTERVAL)
|
||||||
status_code=400,
|
|
||||||
detail=f"interval must be between {MIN_INTERVAL} and {MAX_INTERVAL} minutes",
|
|
||||||
)
|
|
||||||
applied = apply_interval(db, job_id, minutes)
|
applied = apply_interval(db, job_id, minutes)
|
||||||
audit.record(
|
audit.record(
|
||||||
db,
|
db,
|
||||||
@@ -61,7 +60,7 @@ def set_job_interval(
|
|||||||
after={"interval_minutes": applied},
|
after={"interval_minutes": applied},
|
||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"id": job_id, "interval_minutes": applied}
|
return {"ok": True, "id": job_id, "interval_minutes": applied}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/jobs/{job_id}/run")
|
@router.post("/jobs/{job_id}/run")
|
||||||
@@ -77,14 +76,14 @@ def run_job_now(
|
|||||||
except KeyError:
|
except KeyError:
|
||||||
raise not_found("job")
|
raise not_found("job")
|
||||||
if result == "already_running":
|
if result == "already_running":
|
||||||
raise HTTPException(status_code=409, detail="Job is already running")
|
raise refused(409, "job_running")
|
||||||
return {"id": job_id, "started": True}
|
return {"ok": True, "id": job_id, "started": True}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/run-all")
|
@router.post("/run-all")
|
||||||
def run_all_now(user: User = Depends(admin_user)) -> dict:
|
def run_all_now(user: User = Depends(admin_user)) -> dict:
|
||||||
"""Trigger every job that isn't already running. Returns the ids actually started."""
|
"""Trigger every job that isn't already running. Returns the ids actually started."""
|
||||||
return {"started": trigger_all(actor_id=user.id)}
|
return {"ok": True, "started": trigger_all(actor_id=user.id)}
|
||||||
|
|
||||||
|
|
||||||
class MaintenanceSettingsIn(BaseModel):
|
class MaintenanceSettingsIn(BaseModel):
|
||||||
@@ -102,8 +101,8 @@ def set_maintenance_settings(
|
|||||||
# set_value validates against the spec's min/max (one source of bounds), mirroring the config PATCH.
|
# set_value validates against the spec's min/max (one source of bounds), mirroring the config PATCH.
|
||||||
try:
|
try:
|
||||||
sysconfig.set_value(db, "maintenance_revalidate_batch", body.revalidate_batch)
|
sysconfig.set_value(db, "maintenance_revalidate_batch", body.revalidate_batch)
|
||||||
except (TypeError, ValueError) as exc:
|
except sysconfig.InvalidSetting as exc:
|
||||||
raise HTTPException(status_code=400, detail=str(exc) or "Invalid value")
|
raise setting_refused(exc) from None
|
||||||
new = sysconfig.get_int(db, "maintenance_revalidate_batch")
|
new = sysconfig.get_int(db, "maintenance_revalidate_batch")
|
||||||
audit.record(
|
audit.record(
|
||||||
db,
|
db,
|
||||||
@@ -113,7 +112,7 @@ def set_maintenance_settings(
|
|||||||
after={"revalidate_batch": new},
|
after={"revalidate_batch": new},
|
||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"revalidate_batch": new}
|
return {"ok": True, "revalidate_batch": new}
|
||||||
|
|
||||||
|
|
||||||
@router.get("")
|
@router.get("")
|
||||||
|
|||||||
@@ -13,17 +13,16 @@ this search-discovered "noise" by default. Shorts and currently-live/upcoming vi
|
|||||||
ingested this way (Shorts are confirmed by the youtube.com/shorts probe; live/upcoming are
|
ingested this way (Shorts are confirmed by the youtube.com/shorts probe; live/upcoming are
|
||||||
dropped from the search response up front)."""
|
dropped from the search response up front)."""
|
||||||
|
|
||||||
import logging
|
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy import and_, delete, func, or_, select, update
|
from sqlalchemy import and_, delete, func, or_, select, update
|
||||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||||
from sqlalchemy.orm import Session, aliased
|
from sqlalchemy.orm import Session, aliased
|
||||||
|
|
||||||
from app import quota, sysconfig
|
from app import logs, quota, sysconfig
|
||||||
from app.auth import require_human
|
from app.auth import require_human
|
||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.models import (
|
from app.models import (
|
||||||
@@ -39,14 +38,16 @@ from app.models import (
|
|||||||
VideoState,
|
VideoState,
|
||||||
)
|
)
|
||||||
from app.feedquery import feed_columns, serialize
|
from app.feedquery import feed_columns, serialize
|
||||||
|
from app.utils import like_escape
|
||||||
from app.sync.subscriptions import apply_channel_details
|
from app.sync.subscriptions import apply_channel_details
|
||||||
from app.sync.videos import insert_stubs, apply_video_details, parse_dt
|
from app.sync.videos import insert_stubs, apply_video_details, parse_dt
|
||||||
from app.youtube import search_scrape
|
from app.youtube import search_scrape
|
||||||
from app.youtube.client import YouTubeClient, YouTubeError
|
from app.youtube.client import YouTubeClient, YouTubeError
|
||||||
from app.youtube.shorts import make_client, probe_is_short
|
from app.youtube.shorts import make_client, probe_is_short
|
||||||
|
from app.refusal import refused
|
||||||
|
from app.routes._common import youtube_refusal
|
||||||
|
|
||||||
log = logging.getLogger("siftlode.search")
|
log = logs.get_logger(__name__)
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/search", tags=["search"])
|
router = APIRouter(prefix="/api/search", tags=["search"])
|
||||||
|
|
||||||
# search.list costs this many quota units per page.
|
# search.list costs this many quota units per page.
|
||||||
@@ -129,9 +130,16 @@ 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
|
||||||
|
# channels sit in the results with no thumbnail and no subscriber count, looking like
|
||||||
|
# channels that have neither.
|
||||||
|
log.warning(
|
||||||
|
"the channels found by a search could not be enriched",
|
||||||
|
extra=logs.event("search.channel_enrich_failed", channels=len(new_channel_ids)),
|
||||||
|
)
|
||||||
db.rollback() # details can be backfilled later; the stubs remain
|
db.rollback() # details can be backfilled later; the stubs remain
|
||||||
|
|
||||||
# 2) Video stubs, flagged via_search. Existing rows keep their flags (ON CONFLICT DO
|
# 2) Video stubs, flagged via_search. Existing rows keep their flags (ON CONFLICT DO
|
||||||
@@ -161,6 +169,12 @@ def _ingest_candidates(
|
|||||||
with quota.attribute(user.id, quota.QuotaAction.VIDEOS_ENRICH):
|
with quota.attribute(user.id, quota.QuotaAction.VIDEOS_ENRICH):
|
||||||
detail_by_id = {it["id"]: it for it in yt.get_videos(ids)}
|
detail_by_id = {it["id"]: it for it in yt.get_videos(ids)}
|
||||||
except YouTubeError:
|
except YouTubeError:
|
||||||
|
# Every candidate then stays unenriched, and an unenriched video cannot be judged live or
|
||||||
|
# Short — so it is dropped. A working search returns nothing and reads as "no results".
|
||||||
|
log.warning(
|
||||||
|
"the videos found by a search could not be enriched",
|
||||||
|
extra=logs.event("search.video_enrich_failed", candidates=len(ids)),
|
||||||
|
)
|
||||||
detail_by_id = {}
|
detail_by_id = {}
|
||||||
enriched_at = datetime.now(
|
enriched_at = datetime.now(
|
||||||
timezone.utc
|
timezone.utc
|
||||||
@@ -213,6 +227,25 @@ def _new_first(db: Session, user: User, ids: list[str]) -> list[str]:
|
|||||||
return [i for i in ids if i not in have] + [i for i in ids if i in have]
|
return [i for i in ids if i not in have] + [i for i in ids if i in have]
|
||||||
|
|
||||||
|
|
||||||
|
def _fold_term(db: Session, video_ids: list[str], term: str) -> None:
|
||||||
|
"""Append `term` to each video's space-joined `search_terms`, unless it is already one of them.
|
||||||
|
|
||||||
|
"Already one of them" is a whole-term match, as a literal: a substring test skipped `cat` on a
|
||||||
|
video already found for `category`, and an unescaped one read the `_` in `my_channel` as a
|
||||||
|
wildcard — either way the term was never folded in, and local search lost that relevance."""
|
||||||
|
padded = func.concat(" ", func.coalesce(Video.search_terms, ""), " ")
|
||||||
|
db.execute(
|
||||||
|
update(Video)
|
||||||
|
.where(Video.id.in_(video_ids))
|
||||||
|
.where(padded.notilike(f"% {like_escape(term)} %"))
|
||||||
|
.values(
|
||||||
|
search_terms=func.trim(
|
||||||
|
func.coalesce(Video.search_terms, "").op("||")(" ").op("||")(term)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/youtube")
|
@router.get("/youtube")
|
||||||
def search_youtube(
|
def search_youtube(
|
||||||
q: str | None = None,
|
q: str | None = None,
|
||||||
@@ -227,7 +260,7 @@ def search_youtube(
|
|||||||
page, so it returns a single page (≤50) regardless. `cursor` lets the caller fetch further."""
|
page, so it returns a single page (≤50) regardless. `cursor` lets the caller fetch further."""
|
||||||
term = (q or "").strip()
|
term = (q or "").strip()
|
||||||
if not term:
|
if not term:
|
||||||
raise HTTPException(status_code=400, detail="A search term is required.")
|
raise refused(400, "search_term_required")
|
||||||
limit = max(1, min(limit, 100))
|
limit = max(1, min(limit, 100))
|
||||||
|
|
||||||
# Source: "scrape" (InnerTube, no API quota) or "api" (search.list, 100 units/page).
|
# Source: "scrape" (InnerTube, no API quota) or "api" (search.list, 100 units/page).
|
||||||
@@ -238,16 +271,20 @@ def search_youtube(
|
|||||||
daily_limit = sysconfig.get_int(db, "search_daily_limit_per_user")
|
daily_limit = sysconfig.get_int(db, "search_daily_limit_per_user")
|
||||||
used = quota.actions_today(db, user.id, quota.QuotaAction.VIDEOS_SEARCH)
|
used = quota.actions_today(db, user.id, quota.QuotaAction.VIDEOS_SEARCH)
|
||||||
if used >= daily_limit:
|
if used >= daily_limit:
|
||||||
raise HTTPException(
|
# The 429 is in the request line either way; which of the two caps produced it is not, and
|
||||||
status_code=429,
|
# they are answered by different things — this one by tomorrow, the other by the admin.
|
||||||
detail=f"You've reached today's YouTube search limit ({daily_limit}). Try again tomorrow.",
|
log.info(
|
||||||
|
"a live search was refused: the per-user daily cap is spent",
|
||||||
|
extra=logs.event("search.refused", reason="daily_cap", limit=daily_limit),
|
||||||
)
|
)
|
||||||
|
raise refused(429, "search_daily_limit", limit=daily_limit)
|
||||||
# The 100-unit budget pre-check only applies to the API source.
|
# The 100-unit budget pre-check only applies to the API source.
|
||||||
if source == "api" and not quota.can_spend(db, SEARCH_COST):
|
if source == "api" and not quota.can_spend(db, SEARCH_COST):
|
||||||
raise HTTPException(
|
log.info(
|
||||||
status_code=429,
|
"a live search was refused: the shared YouTube budget is spent",
|
||||||
detail="The shared YouTube quota for today is used up. Try again tomorrow.",
|
extra=logs.event("search.refused", reason="shared_quota", cost=SEARCH_COST),
|
||||||
)
|
)
|
||||||
|
raise refused(429, "quota_spent")
|
||||||
|
|
||||||
# Channels this user has blocked are dropped before ingestion — never shown, never stored.
|
# Channels this user has blocked are dropped before ingestion — never shown, never stored.
|
||||||
blocked = set(
|
blocked = set(
|
||||||
@@ -272,9 +309,14 @@ def search_youtube(
|
|||||||
# dropout (auto-retry + "connection lost" banner — lib/api/core RETRIABLE_GATEWAY),
|
# dropout (auto-retry + "connection lost" banner — lib/api/core RETRIABLE_GATEWAY),
|
||||||
# which hides the real message and burns a second quota-costed search. Upstream failure
|
# which hides the real message and burns a second quota-costed search. Upstream failure
|
||||||
# on a JSON endpoint = 422 + message; reserve 5xx for the raw byte-proxies. (B-core-1)
|
# on a JSON endpoint = 422 + message; reserve 5xx for the raw byte-proxies. (B-core-1)
|
||||||
raise HTTPException(
|
# A Data API failure is logged where it happened; the scraper's is not, and its text
|
||||||
status_code=422, detail=f"YouTube search failed: {exc}"
|
# no longer rides the answer — so it is said here.
|
||||||
) from exc
|
if isinstance(exc, search_scrape.ScrapeError):
|
||||||
|
log.warning(
|
||||||
|
"the YouTube search scrape failed",
|
||||||
|
extra=logs.event("search.scrape_failed", error=str(exc)),
|
||||||
|
)
|
||||||
|
raise youtube_refusal(exc) from exc
|
||||||
|
|
||||||
# Drop currently-live/upcoming, channel-less, and blocked-channel results.
|
# Drop currently-live/upcoming, channel-less, and blocked-channel results.
|
||||||
candidates = [
|
candidates = [
|
||||||
@@ -291,12 +333,32 @@ def search_youtube(
|
|||||||
collected.extend(_ingest_candidates(db, yt, user, candidates))
|
collected.extend(_ingest_candidates(db, yt, user, candidates))
|
||||||
except Exception as exc: # noqa: BLE001 — any ingest failure, not just YouTubeError
|
except Exception as exc: # noqa: BLE001 — any ingest failure, not just YouTubeError
|
||||||
db.rollback()
|
db.rollback()
|
||||||
log.exception("live search ingest failed for %r", term)
|
# The term is NOT in this line. A search string is exactly what the privacy
|
||||||
|
# policy keeps out of the log unless the user turned Teljes/TRACE on for
|
||||||
|
# themselves, and `redact_text` does not cover it — it scrubs addresses and
|
||||||
|
# credential params, not what someone typed into the search box. What the
|
||||||
|
# ERROR line carries instead is the shape of the failure, which is what tells
|
||||||
|
# an ingest bug from an upstream one; the term follows at TRACE, and only
|
||||||
|
# under this user's own consent — not whenever the process runs at TRACE.
|
||||||
|
log.exception(
|
||||||
|
"live search ingest failed",
|
||||||
|
extra=logs.event(
|
||||||
|
"search.live.ingest_failed",
|
||||||
|
source=source,
|
||||||
|
candidates=len(candidates),
|
||||||
|
collected=len(collected),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if logs.consented_trace():
|
||||||
|
log.log(
|
||||||
|
logs.TRACE,
|
||||||
|
"the term the ingest failed on",
|
||||||
|
# log-pii-ok: only under this user's own Teljes consent
|
||||||
|
extra=logs.event("search.live.term", term=term),
|
||||||
|
)
|
||||||
if collected:
|
if collected:
|
||||||
break # keep what earlier pages already gathered
|
break # keep what earlier pages already gathered
|
||||||
raise HTTPException(
|
raise youtube_refusal(exc) from exc
|
||||||
status_code=422, detail=f"YouTube search failed: {exc}"
|
|
||||||
) from exc
|
|
||||||
next_cursor = page["next_page_token"]
|
next_cursor = page["next_page_token"]
|
||||||
# The API source pages cost 100 units each — never auto-page; one page per request.
|
# The API source pages cost 100 units each — never auto-page; one page per request.
|
||||||
if source == "api" or len(collected) >= limit or not next_cursor:
|
if source == "api" or len(collected) >= limit or not next_cursor:
|
||||||
@@ -321,16 +383,7 @@ def search_youtube(
|
|||||||
.values([{"user_id": user.id, "video_id": vid} for vid in ordered])
|
.values([{"user_id": user.id, "video_id": vid} for vid in ordered])
|
||||||
.on_conflict_do_nothing(index_elements=["user_id", "video_id"])
|
.on_conflict_do_nothing(index_elements=["user_id", "video_id"])
|
||||||
)
|
)
|
||||||
db.execute(
|
_fold_term(db, ordered, term)
|
||||||
update(Video)
|
|
||||||
.where(Video.id.in_(ordered))
|
|
||||||
.where(func.coalesce(Video.search_terms, "").notilike(f"%{term}%"))
|
|
||||||
.values(
|
|
||||||
search_terms=func.trim(
|
|
||||||
func.coalesce(Video.search_terms, "").op("||")(" ").op("||")(term)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
ordered = _new_first(db, user, ordered)
|
ordered = _new_first(db, user, ordered)
|
||||||
@@ -358,7 +411,7 @@ def clear_search(
|
|||||||
find. Kept videos and organically-owned ones are left untouched."""
|
find. Kept videos and organically-owned ones are left untouched."""
|
||||||
ids = body.video_ids
|
ids = body.video_ids
|
||||||
if not ids:
|
if not ids:
|
||||||
return {"removed": 0}
|
return {"ok": True, "removed": 0}
|
||||||
db.execute(
|
db.execute(
|
||||||
delete(SearchFind).where(SearchFind.user_id == user.id, SearchFind.video_id.in_(ids))
|
delete(SearchFind).where(SearchFind.user_id == user.id, SearchFind.video_id.in_(ids))
|
||||||
)
|
)
|
||||||
@@ -378,4 +431,4 @@ def clear_search(
|
|||||||
)
|
)
|
||||||
).rowcount
|
).rowcount
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"removed": removed}
|
return {"ok": True, "removed": removed}
|
||||||
|
|||||||
+14
-14
@@ -8,7 +8,7 @@ X-Setup-Token header).
|
|||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Header, HTTPException
|
from fastapi import APIRouter, Depends, Header
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
@@ -21,6 +21,8 @@ from app.auth import PASSWORD_MIN_LEN
|
|||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.models import User
|
from app.models import User
|
||||||
from app.security import hash_password
|
from app.security import hash_password
|
||||||
|
from app.refusal import refused
|
||||||
|
from app.refusal import setting_refused
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/setup", tags=["setup"])
|
router = APIRouter(prefix="/api/setup", tags=["setup"])
|
||||||
|
|
||||||
@@ -31,9 +33,9 @@ def require_setup(
|
|||||||
) -> Session:
|
) -> Session:
|
||||||
"""Gate for setup steps. Returns the db session for the handler to reuse."""
|
"""Gate for setup steps. Returns the db session for the handler to reuse."""
|
||||||
if state.is_configured(db):
|
if state.is_configured(db):
|
||||||
raise HTTPException(status_code=409, detail="This instance is already set up.")
|
raise refused(409, "already_set_up")
|
||||||
if not state.check_setup_token(db, x_setup_token):
|
if not state.check_setup_token(db, x_setup_token):
|
||||||
raise HTTPException(status_code=403, detail="Invalid or missing setup token.")
|
raise refused(403, "setup_token")
|
||||||
return db
|
return db
|
||||||
|
|
||||||
|
|
||||||
@@ -66,11 +68,9 @@ def setup_admin(body: SetupAdminIn, db: Session = Depends(require_setup)) -> dic
|
|||||||
email = body.email.strip().lower()
|
email = body.email.strip().lower()
|
||||||
password = body.password
|
password = body.password
|
||||||
if "@" not in email:
|
if "@" not in email:
|
||||||
raise HTTPException(status_code=400, detail="Enter a valid email address.")
|
raise refused(400, "invalid_email")
|
||||||
if len(password) < PASSWORD_MIN_LEN:
|
if len(password) < PASSWORD_MIN_LEN:
|
||||||
raise HTTPException(
|
raise refused(400, "weak_password", min=PASSWORD_MIN_LEN)
|
||||||
status_code=400, detail=f"Password must be at least {PASSWORD_MIN_LEN} characters."
|
|
||||||
)
|
|
||||||
# Resolve canonically, not by the literal string (DX-17). The wizard can be re-run, and E9.5's
|
# Resolve canonically, not by the literal string (DX-17). The wizard can be re-run, and E9.5's
|
||||||
# whole point is that two spellings of one Gmail mailbox are one person — matching literally here
|
# whole point is that two spellings of one Gmail mailbox are one person — matching literally here
|
||||||
# would let a second run under the other spelling create the DUPLICATE admin row the epic exists
|
# would let a second run under the other spelling create the DUPLICATE admin row the epic exists
|
||||||
@@ -97,16 +97,16 @@ def setup_config(settings_map: dict[str, Any], db: Session = Depends(require_set
|
|||||||
saved: list[str] = []
|
saved: list[str] = []
|
||||||
for key, value in settings_map.items():
|
for key, value in settings_map.items():
|
||||||
if sysconfig.spec(key) is None:
|
if sysconfig.spec(key) is None:
|
||||||
raise HTTPException(status_code=400, detail=f"Unknown setting: {key}")
|
raise refused(400, "unknown_setting", key=key)
|
||||||
if value is None or value == "":
|
if value is None or value == "":
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
sysconfig.set_value(db, key, value)
|
sysconfig.set_value(db, key, value)
|
||||||
except (ValueError, RuntimeError) as exc:
|
except sysconfig.InvalidSetting as exc:
|
||||||
raise HTTPException(status_code=400, detail=str(exc))
|
raise setting_refused(exc) from None
|
||||||
saved.append(key)
|
saved.append(key)
|
||||||
db.commit() # sysconfig.set_value only flushes now — persist the wizard's config in one commit
|
db.commit() # sysconfig.set_value only flushes now — persist the wizard's config in one commit
|
||||||
return {"saved": saved}
|
return {"ok": True, "saved": saved}
|
||||||
|
|
||||||
|
|
||||||
class SetupTestEmailIn(BaseModel):
|
class SetupTestEmailIn(BaseModel):
|
||||||
@@ -121,9 +121,9 @@ def setup_test_email(body: SetupTestEmailIn, db: Session = Depends(require_setup
|
|||||||
"""Send a test email to confirm the SMTP settings the wizard just saved."""
|
"""Send a test email to confirm the SMTP settings the wizard just saved."""
|
||||||
to = body.to.strip()
|
to = body.to.strip()
|
||||||
if "@" not in to:
|
if "@" not in to:
|
||||||
raise HTTPException(status_code=400, detail="Enter a valid email address.")
|
raise refused(400, "invalid_email")
|
||||||
if not email_mod.send_test(to, mailtext.normalize(body.lang)):
|
if not email_mod.send_test(to, mailtext.normalize(body.lang)):
|
||||||
raise HTTPException(status_code=400, detail="Couldn't send — check the SMTP settings.")
|
raise refused(400, "smtp_failed")
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
@@ -133,6 +133,6 @@ def setup_finish(db: Session = Depends(require_setup)) -> dict:
|
|||||||
invalidates the setup token and disables the wizard)."""
|
invalidates the setup token and disables the wizard)."""
|
||||||
has_admin = db.execute(select(User).where(User.role == "admin")).first() is not None
|
has_admin = db.execute(select(User).where(User.role == "admin")).first() is not None
|
||||||
if not has_admin:
|
if not has_admin:
|
||||||
raise HTTPException(status_code=400, detail="Create the admin account first.")
|
raise refused(400, "admin_first")
|
||||||
state.mark_configured(db)
|
state.mark_configured(db)
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|||||||
+16
-10
@@ -7,10 +7,13 @@ from app.audit import AuditAction
|
|||||||
from app.auth import admin_user, current_user, has_read_scope, require_human
|
from app.auth import admin_user, current_user, has_read_scope, require_human
|
||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.models import Channel, Subscription, User, Video
|
from app.models import Channel, Subscription, User, Video
|
||||||
|
from app.routes._common import youtube_refusal
|
||||||
from app.ratelimit import RateLimiter
|
from app.ratelimit import RateLimiter
|
||||||
from app.sync.runner import estimate_deep_backfill
|
from app.sync.runner import estimate_deep_backfill
|
||||||
from app.sync.subscriptions import import_subscriptions
|
from app.sync.subscriptions import import_subscriptions
|
||||||
from app.scheduler import running_job_ids
|
from app.scheduler import running_job_ids
|
||||||
|
from app.youtube.client import YouTubeError
|
||||||
|
from app.refusal import refused
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/sync", tags=["sync"])
|
router = APIRouter(prefix="/api/sync", tags=["sync"])
|
||||||
|
|
||||||
@@ -26,17 +29,20 @@ _sync_limiter = RateLimiter(max_events=3, window_seconds=600)
|
|||||||
def sync_subscriptions(user: User = Depends(require_human), db: Session = Depends(get_db)) -> dict:
|
def sync_subscriptions(user: User = Depends(require_human), db: Session = Depends(get_db)) -> dict:
|
||||||
if not has_read_scope(user):
|
if not has_read_scope(user):
|
||||||
# No YouTube read grant yet — the onboarding wizard hasn't been completed.
|
# No YouTube read grant yet — the onboarding wizard hasn't been completed.
|
||||||
raise HTTPException(
|
raise refused(403, "youtube_read_scope")
|
||||||
status_code=403,
|
|
||||||
detail="Connect your YouTube account (read access) to import subscriptions.",
|
|
||||||
)
|
|
||||||
if not _sync_limiter.allow(str(user.id)):
|
if not _sync_limiter.allow(str(user.id)):
|
||||||
raise HTTPException(status_code=429, detail={"code": "sync_cooldown"})
|
raise HTTPException(status_code=429, detail={"code": "sync_cooldown"})
|
||||||
with quota.measured(db, user.id, quota.QuotaAction.SUBSCRIPTIONS_PULL) as m:
|
try:
|
||||||
result = import_subscriptions(db, user)
|
with quota.measured(db, user.id, quota.QuotaAction.SUBSCRIPTIONS_PULL) as m:
|
||||||
|
result = import_subscriptions(db, user)
|
||||||
|
except YouTubeError as exc:
|
||||||
|
# The onboarding wizard's first call: an unguarded failure here was a raw 500 and the
|
||||||
|
# blocking "Server error" dialog, with nothing the user could act on (E9 F1).
|
||||||
|
db.rollback()
|
||||||
|
raise youtube_refusal(exc)
|
||||||
result["quota_used_estimate"] = m.used
|
result["quota_used_estimate"] = m.used
|
||||||
result["quota_remaining_today"] = m.remaining
|
result["quota_remaining_today"] = m.remaining
|
||||||
return result
|
return {"ok": True, **result}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/status")
|
@router.get("/status")
|
||||||
@@ -137,7 +143,7 @@ def deep_all(
|
|||||||
update(Subscription).where(Subscription.user_id == user.id).values(deep_requested=on)
|
update(Subscription).where(Subscription.user_id == user.id).values(deep_requested=on)
|
||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"updated": result.rowcount, "deep_requested": on}
|
return {"ok": True, "updated": result.rowcount, "deep_requested": on}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/pause")
|
@router.post("/pause")
|
||||||
@@ -145,7 +151,7 @@ def pause_sync(admin: User = Depends(admin_user), db: Session = Depends(get_db))
|
|||||||
state.set_sync_paused(db, True)
|
state.set_sync_paused(db, True)
|
||||||
audit.record(db, admin.id, AuditAction.SYNC_PAUSE, summary="Background sync paused")
|
audit.record(db, admin.id, AuditAction.SYNC_PAUSE, summary="Background sync paused")
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"paused": True}
|
return {"ok": True, "paused": True}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/resume")
|
@router.post("/resume")
|
||||||
@@ -153,4 +159,4 @@ def resume_sync(admin: User = Depends(admin_user), db: Session = Depends(get_db)
|
|||||||
state.set_sync_paused(db, False)
|
state.set_sync_paused(db, False)
|
||||||
audit.record(db, admin.id, AuditAction.SYNC_RESUME, summary="Background sync resumed")
|
audit.record(db, admin.id, AuditAction.SYNC_RESUME, summary="Background sync resumed")
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"paused": False}
|
return {"ok": True, "paused": False}
|
||||||
|
|||||||
+24
-14
@@ -1,4 +1,4 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy import func, or_, select
|
from sqlalchemy import func, or_, select
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
@@ -9,6 +9,7 @@ from app.db import get_db
|
|||||||
from app.models import ChannelTag, Tag, User
|
from app.models import ChannelTag, Tag, User
|
||||||
from app.routes._common import owned_or_404
|
from app.routes._common import owned_or_404
|
||||||
from app.sync.tagsuggest import compute_tag_suggestions
|
from app.sync.tagsuggest import compute_tag_suggestions
|
||||||
|
from app.refusal import refused
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/tags", tags=["tags"])
|
router = APIRouter(prefix="/api/tags", tags=["tags"])
|
||||||
|
|
||||||
@@ -60,7 +61,7 @@ def create_tag(
|
|||||||
) -> dict:
|
) -> dict:
|
||||||
name = body.name.strip()
|
name = body.name.strip()
|
||||||
if not name:
|
if not name:
|
||||||
raise HTTPException(status_code=400, detail="name is required")
|
raise refused(400, "name_required")
|
||||||
# An unknown category silently falls back to "other" (unchanged behaviour) rather than 422.
|
# An unknown category silently falls back to "other" (unchanged behaviour) rather than 422.
|
||||||
category = body.category if body.category in ("language", "topic", "other") else "other"
|
category = body.category if body.category in ("language", "topic", "other") else "other"
|
||||||
tag = Tag(
|
tag = Tag(
|
||||||
@@ -74,13 +75,14 @@ def create_tag(
|
|||||||
db.commit()
|
db.commit()
|
||||||
except IntegrityError:
|
except IntegrityError:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
raise HTTPException(status_code=409, detail=f"You already have a tag named “{name}”.")
|
raise refused(409, "tag_name_taken", name=name)
|
||||||
return {"id": tag.id, "name": tag.name, "color": tag.color, "category": tag.category}
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"id": tag.id,
|
||||||
def _own_tag(db: Session, user: User, tag_id: int) -> Tag:
|
"name": tag.name,
|
||||||
# System tags (user_id NULL) are not user-editable → owned_or_404 404s them too.
|
"color": tag.color,
|
||||||
return owned_or_404(db, Tag, tag_id, user, what="tag")
|
"category": tag.category,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class TagUpdateIn(BaseModel):
|
class TagUpdateIn(BaseModel):
|
||||||
@@ -97,12 +99,13 @@ def update_tag(
|
|||||||
user: User = Depends(current_user),
|
user: User = Depends(current_user),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
tag = _own_tag(db, user, tag_id)
|
# System tags (user_id NULL) are not user-editable: owned_or_404 404s them too.
|
||||||
|
tag = owned_or_404(db, Tag, tag_id, user, what="tag")
|
||||||
sent = body.model_fields_set
|
sent = body.model_fields_set
|
||||||
if "name" in sent:
|
if "name" in sent:
|
||||||
name = (body.name or "").strip()
|
name = (body.name or "").strip()
|
||||||
if not name:
|
if not name:
|
||||||
raise HTTPException(status_code=400, detail="name cannot be empty")
|
raise refused(400, "name_required")
|
||||||
tag.name = name
|
tag.name = name
|
||||||
if "color" in sent:
|
if "color" in sent:
|
||||||
tag.color = body.color
|
tag.color = body.color
|
||||||
@@ -110,8 +113,14 @@ def update_tag(
|
|||||||
db.commit()
|
db.commit()
|
||||||
except IntegrityError:
|
except IntegrityError:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
raise HTTPException(status_code=409, detail=f"You already have a tag named “{tag.name}”.")
|
raise refused(409, "tag_name_taken", name=tag.name)
|
||||||
return {"id": tag.id, "name": tag.name, "color": tag.color, "category": tag.category}
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"id": tag.id,
|
||||||
|
"name": tag.name,
|
||||||
|
"color": tag.color,
|
||||||
|
"category": tag.category,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{tag_id}")
|
@router.delete("/{tag_id}")
|
||||||
@@ -120,7 +129,8 @@ def delete_tag(
|
|||||||
user: User = Depends(current_user),
|
user: User = Depends(current_user),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
tag = _own_tag(db, user, tag_id)
|
# System tags (user_id NULL) are not user-editable: owned_or_404 404s them too.
|
||||||
|
tag = owned_or_404(db, Tag, tag_id, user, what="tag")
|
||||||
db.delete(tag) # ChannelTag rows cascade on tag delete.
|
db.delete(tag) # ChannelTag rows cascade on tag delete.
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"deleted": tag_id}
|
return {"deleted": tag_id}
|
||||||
|
|||||||
+16
-14
@@ -2,7 +2,6 @@
|
|||||||
recent-first / deep backfill. Runs inside the API process (single worker)."""
|
recent-first / deep backfill. Runs inside the API process (single worker)."""
|
||||||
|
|
||||||
import contextvars
|
import contextvars
|
||||||
import logging
|
|
||||||
import threading
|
import threading
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Callable
|
from typing import Callable
|
||||||
@@ -10,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, 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
|
||||||
@@ -35,8 +34,7 @@ from app.sync.runner import (
|
|||||||
run_subscription_resync,
|
run_subscription_resync,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = logging.getLogger("siftlode.scheduler")
|
log = logs.get_logger(__name__)
|
||||||
|
|
||||||
_scheduler: BackgroundScheduler | None = None
|
_scheduler: BackgroundScheduler | None = None
|
||||||
|
|
||||||
# Per-job run activity, kept in-memory for the admin Scheduler dashboard to read (the
|
# Per-job run activity, kept in-memory for the admin Scheduler dashboard to read (the
|
||||||
@@ -104,7 +102,7 @@ def apply_interval(db, job_id: str, minutes: int) -> int:
|
|||||||
db.commit()
|
db.commit()
|
||||||
if _scheduler is not None and _scheduler.get_job(job_id) is not None:
|
if _scheduler is not None and _scheduler.get_job(job_id) is not None:
|
||||||
_scheduler.reschedule_job(job_id, trigger="interval", minutes=minutes)
|
_scheduler.reschedule_job(job_id, trigger="interval", minutes=minutes)
|
||||||
logger.info("rescheduled job %s to every %s min", job_id, minutes)
|
log.info("rescheduled job %s to every %s min", job_id, minutes)
|
||||||
return minutes
|
return minutes
|
||||||
|
|
||||||
|
|
||||||
@@ -131,7 +129,7 @@ def _notify_done(db, actor_id: int, job_id: str, status: str, summary: str | Non
|
|||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
logger.exception("failed to write completion notification for job %s", job_id)
|
log.exception("failed to write completion notification for job %s", job_id)
|
||||||
|
|
||||||
|
|
||||||
def _run_changed(result) -> bool:
|
def _run_changed(result) -> bool:
|
||||||
@@ -167,7 +165,7 @@ def _audit_run(db, actor_id, job_id, status, summary, result) -> None:
|
|||||||
db.commit()
|
db.commit()
|
||||||
except Exception:
|
except Exception:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
logger.exception("failed to write audit row for job %s", job_id)
|
log.exception("failed to write audit row for job %s", job_id)
|
||||||
|
|
||||||
|
|
||||||
def _job(name: str, fn) -> None:
|
def _job(name: str, fn) -> None:
|
||||||
@@ -175,7 +173,7 @@ def _job(name: str, fn) -> None:
|
|||||||
# HERE, atomically: whichever reaches this first wins, the other skips. This is the real
|
# HERE, atomically: whichever reaches this first wins, the other skips. This is the real
|
||||||
# single-run guard — trigger_job's pre-check is only a fast best-effort answer for the UI.
|
# single-run guard — trigger_job's pre-check is only a fast best-effort answer for the UI.
|
||||||
if not _claim_running(name):
|
if not _claim_running(name):
|
||||||
logger.info("job %s already running — skipping this trigger", name)
|
log.info("job %s already running — skipping this trigger", name)
|
||||||
return
|
return
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
actor_id = _manual_actor.get() # set only for a manual "run now" trigger
|
actor_id = _manual_actor.get() # set only for a manual "run now" trigger
|
||||||
@@ -188,7 +186,7 @@ def _job(name: str, fn) -> None:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
if is_sync_paused(db):
|
if is_sync_paused(db):
|
||||||
logger.info("job %s skipped (sync paused)", name)
|
log.info("job %s skipped (sync paused)", name)
|
||||||
_record(
|
_record(
|
||||||
name,
|
name,
|
||||||
status="skipped",
|
status="skipped",
|
||||||
@@ -203,14 +201,14 @@ def _job(name: str, fn) -> None:
|
|||||||
with progress.bind(sink):
|
with progress.bind(sink):
|
||||||
result = fn(db)
|
result = fn(db)
|
||||||
summary = _summarize(result)
|
summary = _summarize(result)
|
||||||
logger.info("job %s -> %s", name, result)
|
log.info("job %s -> %s", name, result)
|
||||||
_record(name, status="ok", last_finished=_now_iso(), last_result=summary, progress=None)
|
_record(name, status="ok", last_finished=_now_iso(), last_result=summary, progress=None)
|
||||||
if actor_id is not None:
|
if actor_id is not None:
|
||||||
_notify_done(db, actor_id, name, "ok", summary)
|
_notify_done(db, actor_id, name, "ok", summary)
|
||||||
_audit_run(db, actor_id, name, "ok", summary, result)
|
_audit_run(db, actor_id, name, "ok", summary, result)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
logger.exception("job %s failed", name)
|
log.exception("job %s failed", name, extra=logs.event("scheduler.job.failed", job=name))
|
||||||
err = str(exc) or exc.__class__.__name__
|
err = str(exc) or exc.__class__.__name__
|
||||||
_record(name, status="error", last_finished=_now_iso(), last_error=err, progress=None)
|
_record(name, status="error", last_finished=_now_iso(), last_error=err, progress=None)
|
||||||
if actor_id is not None:
|
if actor_id is not None:
|
||||||
@@ -388,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)
|
||||||
|
|
||||||
@@ -493,7 +495,7 @@ def start_scheduler() -> None:
|
|||||||
scheduler.add_job(fn, "interval", minutes=iv[job_id], id=job_id)
|
scheduler.add_job(fn, "interval", minutes=iv[job_id], id=job_id)
|
||||||
scheduler.start()
|
scheduler.start()
|
||||||
_scheduler = scheduler
|
_scheduler = scheduler
|
||||||
logger.info("scheduler started")
|
log.info("scheduler started")
|
||||||
|
|
||||||
|
|
||||||
def shutdown_scheduler() -> None:
|
def shutdown_scheduler() -> None:
|
||||||
|
|||||||
+23
-3
@@ -1,10 +1,14 @@
|
|||||||
import hashlib
|
import hashlib
|
||||||
|
|
||||||
from argon2 import PasswordHasher
|
from argon2 import PasswordHasher
|
||||||
|
from argon2.exceptions import VerifyMismatchError
|
||||||
from cryptography.fernet import Fernet
|
from cryptography.fernet import Fernet
|
||||||
|
|
||||||
|
from app import logs
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
|
||||||
|
log = logs.get_logger(__name__)
|
||||||
|
|
||||||
# argon2id with the library defaults (sensible memory/time cost). One shared hasher instance.
|
# argon2id with the library defaults (sensible memory/time cost). One shared hasher instance.
|
||||||
_ph = PasswordHasher()
|
_ph = PasswordHasher()
|
||||||
|
|
||||||
@@ -24,8 +28,16 @@ def verify_password(password: str, hashed: str | None) -> bool:
|
|||||||
return False
|
return False
|
||||||
try:
|
try:
|
||||||
return _ph.verify(hashed, password)
|
return _ph.verify(hashed, password)
|
||||||
except Exception:
|
except VerifyMismatchError:
|
||||||
# Any failure (mismatch, malformed hash) is a non-match — never raise to the caller.
|
return False # silent-ok: a wrong password. The commonest path through this function.
|
||||||
|
except Exception as exc: # noqa: BLE001 — never raise to the caller, whatever went wrong
|
||||||
|
# Everything else is a hash this process cannot USE — truncated in the column, written by
|
||||||
|
# a different argon2 build. It is answered as "wrong password", so the owner of that
|
||||||
|
# account is locked out by a bug and told they mistyped.
|
||||||
|
log.warning(
|
||||||
|
"a stored password hash could not be verified",
|
||||||
|
extra=logs.event("auth.password.hash_unusable", reason=type(exc).__name__),
|
||||||
|
)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
@@ -60,5 +72,13 @@ def decrypt_optional(value: str | None) -> str | None:
|
|||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
return _require_fernet().decrypt(value.encode()).decode()
|
return _require_fernet().decrypt(value.encode()).decode()
|
||||||
except Exception:
|
except Exception as exc: # noqa: BLE001 — callers fall back to a refresh, never to an error
|
||||||
|
# Info rather than warning because the docstring's legacy-plaintext case comes through
|
||||||
|
# here too and is harmless. What is NOT harmless looks identical: a rotated
|
||||||
|
# TOKEN_ENCRYPTION_KEY turns every stored token unreadable at once, and every account
|
||||||
|
# then reads as "not connected" with nothing saying the key is the reason.
|
||||||
|
log.info(
|
||||||
|
"a stored ciphertext could not be decrypted",
|
||||||
|
extra=logs.event("security.decrypt.failed", reason=type(exc).__name__),
|
||||||
|
)
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -1,15 +1,14 @@
|
|||||||
"""Global admin-controlled app state (e.g. pausing background sync, first-run setup)."""
|
"""Global admin-controlled app state (e.g. pausing background sync, first-run setup)."""
|
||||||
|
|
||||||
import logging
|
|
||||||
import secrets
|
import secrets
|
||||||
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app import logs
|
||||||
from app.models import AppState
|
from app.models import AppState
|
||||||
from app.security import hash_token
|
from app.security import hash_token
|
||||||
|
|
||||||
log = logging.getLogger("siftlode.state")
|
log = logs.get_logger(__name__)
|
||||||
|
|
||||||
# Once configuration completes in this process it never reverts, so cache it to skip the per-request
|
# Once configuration completes in this process it never reverts, so cache it to skip the per-request
|
||||||
# DB hit in the setup-mode gate (see main.setup_gate). Starts False; refreshed from the DB.
|
# DB hit in the setup-mode gate (see main.setup_gate). Starts False; refreshed from the DB.
|
||||||
_configured_cache = False
|
_configured_cache = False
|
||||||
|
|||||||
@@ -6,17 +6,16 @@ topicDetails categories and the channel's dominant video category. System tags a
|
|||||||
regenerated freely; user tags are never touched here.
|
regenerated freely; user tags are never touched here.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
|
||||||
import re
|
import re
|
||||||
|
|
||||||
from sqlalchemy import and_, exists, func, select
|
from sqlalchemy import and_, exists, func, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
log = logging.getLogger("siftlode.autotag")
|
from app import logs, progress, sysconfig
|
||||||
|
|
||||||
from app import progress, sysconfig
|
|
||||||
from app.models import Channel, ChannelTag, Tag, Video
|
from app.models import Channel, ChannelTag, Tag, Video
|
||||||
|
|
||||||
|
log = logs.get_logger(__name__)
|
||||||
|
|
||||||
# --- language id (offline, deterministic, small) ---
|
# --- language id (offline, deterministic, small) ---
|
||||||
try:
|
try:
|
||||||
from py3langid.langid import MODEL_FILE, LanguageIdentifier
|
from py3langid.langid import MODEL_FILE, LanguageIdentifier
|
||||||
@@ -29,7 +28,13 @@ try:
|
|||||||
def _restrict(codes: list[str]) -> None:
|
def _restrict(codes: list[str]) -> None:
|
||||||
_identifier.set_languages(codes)
|
_identifier.set_languages(codes)
|
||||||
|
|
||||||
except Exception: # pragma: no cover - fallback to module-level API
|
except Exception: # noqa: BLE001 — pragma: no cover, fallback to the module-level API
|
||||||
|
# The fallback classifies without the normalisation the identifier applies, so language tags
|
||||||
|
# come out subtly different — and nothing else in the app would ever hint at which path ran.
|
||||||
|
log.info(
|
||||||
|
"the langid model could not be loaded; using the module-level classifier",
|
||||||
|
extra=logs.event("autotag.langid.fallback"),
|
||||||
|
)
|
||||||
import py3langid as _langid
|
import py3langid as _langid
|
||||||
|
|
||||||
def _classify(text: str):
|
def _classify(text: str):
|
||||||
|
|||||||
@@ -8,13 +8,12 @@ stay private to users who explored (or subscribe to) the channel — the feed qu
|
|||||||
otherwise the cleanup job hard-deletes the channel and its untouched ephemeral videos after a
|
otherwise the cleanup job hard-deletes the channel and its untouched ephemeral videos after a
|
||||||
grace period (mirrors the maintenance job's grace-then-delete safety)."""
|
grace period (mirrors the maintenance job's grace-then-delete safety)."""
|
||||||
|
|
||||||
import logging
|
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
from sqlalchemy import delete, or_, select
|
from sqlalchemy import delete, or_, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app import sysconfig
|
from app import logs, sysconfig
|
||||||
from app.models import (
|
from app.models import (
|
||||||
Channel,
|
Channel,
|
||||||
ExploredChannel,
|
ExploredChannel,
|
||||||
@@ -28,7 +27,7 @@ from app.sync.videos import insert_stubs, stub_from_playlist_item, apply_video_d
|
|||||||
from app.utils import now_utc as _now
|
from app.utils import now_utc as _now
|
||||||
from app.youtube.client import YouTubeClient
|
from app.youtube.client import YouTubeClient
|
||||||
|
|
||||||
log = logging.getLogger("siftlode.explore")
|
log = logs.get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def followed_channel():
|
def followed_channel():
|
||||||
|
|||||||
@@ -20,13 +20,12 @@ Quota: phase 1 costs ~(flagged / 50) units, phase 2 ~(batch / 50). Both respect
|
|||||||
backfill reserve as the other jobs so interactive syncs never starve.
|
backfill reserve as the other jobs so interactive syncs never starve.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app import progress, quota, sysconfig
|
from app import logs, progress, quota, sysconfig
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.models import Playlist, PlaylistItem, Video
|
from app.models import Playlist, PlaylistItem, Video
|
||||||
from app.notifications import create_notification
|
from app.notifications import create_notification
|
||||||
@@ -35,8 +34,7 @@ from app.sync.videos import apply_video_details, parse_dt
|
|||||||
from app.utils import now_utc as _now
|
from app.utils import now_utc as _now
|
||||||
from app.youtube.client import YouTubeClient
|
from app.youtube.client import YouTubeClient
|
||||||
|
|
||||||
log = logging.getLogger("siftlode.maintenance")
|
log = logs.get_logger(__name__)
|
||||||
|
|
||||||
# Reasons recorded on Video.unavailable_reason. "removed" = absent from videos.list
|
# Reasons recorded on Video.unavailable_reason. "removed" = absent from videos.list
|
||||||
# (deleted or made private); "abandoned" = an upcoming premiere that never went live.
|
# (deleted or made private); "abandoned" = an upcoming premiere that never went live.
|
||||||
GRACE_DAYS_BY_REASON = {
|
GRACE_DAYS_BY_REASON = {
|
||||||
|
|||||||
+245
-44
@@ -8,27 +8,108 @@ ingested (with a stub channel) so the mirror is faithful."""
|
|||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import logging
|
from collections.abc import Callable, Iterator
|
||||||
from collections.abc import Callable
|
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 progress, quota
|
from app import logs, progress, quota
|
||||||
from app.auth import has_read_scope
|
from app.auth import has_read_scope
|
||||||
from app.models import Channel, Playlist, PlaylistItem, User, Video
|
from app.models import Channel, Playlist, PlaylistItem, User, Video
|
||||||
from app.sync.runner import token_users
|
from app.sync.runner import token_users
|
||||||
from app.sync.videos import apply_video_details, parse_dt
|
from app.sync.videos import apply_video_details, parse_dt
|
||||||
from app.youtube.client import YouTubeClient, YouTubeError
|
from app.youtube.client import YouTubeClient, YouTubeError
|
||||||
|
|
||||||
log = logging.getLogger("siftlode.sync")
|
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
|
||||||
@@ -59,7 +140,17 @@ def _load_baseline(pl: Playlist) -> tuple[list[str] | None, set[str]]:
|
|||||||
return None, set()
|
return None, set()
|
||||||
try:
|
try:
|
||||||
val = json.loads(pl.synced_video_ids)
|
val = json.loads(pl.synced_video_ids)
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError) as exc:
|
||||||
|
# No baseline means the three-way merge has no "what we last agreed on" to subtract, so
|
||||||
|
# it degrades to a union: nothing is lost, but a delete made on either side comes back.
|
||||||
|
log.warning(
|
||||||
|
"the sync baseline for a playlist could not be read; merging as a union",
|
||||||
|
extra=logs.event(
|
||||||
|
"sync.playlist.baseline_unreadable",
|
||||||
|
playlist_id=pl.id,
|
||||||
|
reason=type(exc).__name__,
|
||||||
|
),
|
||||||
|
)
|
||||||
return None, set()
|
return None, set()
|
||||||
if isinstance(val, list):
|
if isinstance(val, list):
|
||||||
return [str(v) for v in val], set()
|
return [str(v) for v in val], set()
|
||||||
@@ -83,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:
|
||||||
@@ -95,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)."""
|
||||||
@@ -151,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()
|
||||||
)
|
)
|
||||||
@@ -188,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())
|
||||||
@@ -211,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")
|
||||||
@@ -237,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}
|
||||||
|
|
||||||
@@ -410,8 +559,11 @@ class _Breaker:
|
|||||||
write site can't accidentally implement two thirds of the protocol (forgetting to record a
|
write site can't accidentally implement two thirds of the protocol (forgetting to record a
|
||||||
failure would silently disable the breaker for that operation)."""
|
failure would silently disable the breaker for that operation)."""
|
||||||
|
|
||||||
def __init__(self, limit: int = _MAX_CONSECUTIVE_FAILURES) -> None:
|
def __init__(
|
||||||
|
self, limit: int = _MAX_CONSECUTIVE_FAILURES, playlist_id: int | None = None
|
||||||
|
) -> None:
|
||||||
self.limit = limit
|
self.limit = limit
|
||||||
|
self.playlist_id = playlist_id # for the failure line only
|
||||||
self.streak = 0
|
self.streak = 0
|
||||||
self.tripped = False
|
self.tripped = False
|
||||||
|
|
||||||
@@ -432,7 +584,9 @@ class _Breaker:
|
|||||||
self.limit,
|
self.limit,
|
||||||
)
|
)
|
||||||
|
|
||||||
def run(self, op: Callable[[], T], on_fail: Callable[[], None]) -> T | _Failed:
|
def run(
|
||||||
|
self, op: Callable[[], T], on_fail: Callable[[], None], action: str = "write"
|
||||||
|
) -> T | _Failed:
|
||||||
"""Attempt one YouTube write. Returns the op's result, or `FAILED` when the breaker is
|
"""Attempt one YouTube write. Returns the op's result, or `FAILED` when the breaker is
|
||||||
already tripped (op not called) or the op raised YouTubeError. `on_fail` records the item
|
already tripped (op not called) or the op raised YouTubeError. `on_fail` records the item
|
||||||
in the caller's failure list either way."""
|
in the caller's failure list either way."""
|
||||||
@@ -441,7 +595,19 @@ class _Breaker:
|
|||||||
return FAILED
|
return FAILED
|
||||||
try:
|
try:
|
||||||
out = op()
|
out = op()
|
||||||
except YouTubeError:
|
except YouTubeError as exc:
|
||||||
|
# The client logged the call, but not which playlist it was for or what kind of write
|
||||||
|
# — and the caller's failure list only ever reaches the user as a count. Not the video:
|
||||||
|
# what is in someone's playlist is a row of their history, the same as a download.
|
||||||
|
log.debug(
|
||||||
|
"a playlist write to YouTube failed",
|
||||||
|
extra=logs.event(
|
||||||
|
"sync.playlist.item_push_failed",
|
||||||
|
playlist_id=self.playlist_id,
|
||||||
|
action=action,
|
||||||
|
reason=exc.reason,
|
||||||
|
),
|
||||||
|
)
|
||||||
on_fail()
|
on_fail()
|
||||||
self.hit(False)
|
self.hit(False)
|
||||||
return FAILED
|
return FAILED
|
||||||
@@ -460,10 +626,11 @@ def merge_playlist(db: Session, user: User, pl: Playlist, *, live=None) -> dict:
|
|||||||
local = _local_video_ids(db, pl)
|
local = _local_video_ids(db, pl)
|
||||||
inserted = deleted = reordered = created = pulled = removed_local = 0
|
inserted = deleted = reordered = created = pulled = removed_local = 0
|
||||||
failures: list[str] = []
|
failures: list[str] = []
|
||||||
breaker = _Breaker()
|
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.
|
||||||
@@ -471,11 +638,12 @@ def merge_playlist(db: Session, user: User, pl: Playlist, *, live=None) -> dict:
|
|||||||
added = breaker.run(
|
added = breaker.run(
|
||||||
lambda v=vid: yt.add_playlist_item(pl.yt_playlist_id, v),
|
lambda v=vid: yt.add_playlist_item(pl.yt_playlist_id, v),
|
||||||
lambda v=vid: failures.append(v),
|
lambda v=vid: failures.append(v),
|
||||||
|
"add",
|
||||||
)
|
)
|
||||||
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,
|
||||||
@@ -516,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 = (
|
||||||
@@ -529,11 +705,23 @@ 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:
|
except YouTubeError as exc:
|
||||||
|
# All the caller passes on is the word "title", which says the rename did not
|
||||||
|
# happen but not that YouTube refused it, or why.
|
||||||
|
log.debug(
|
||||||
|
"a playlist rename did not reach YouTube",
|
||||||
|
extra=logs.event(
|
||||||
|
"sync.playlist.title_push_failed",
|
||||||
|
playlist_id=pl.id,
|
||||||
|
reason=exc.reason,
|
||||||
|
),
|
||||||
|
)
|
||||||
failures.append("title")
|
failures.append("title")
|
||||||
# Propagate genuine local deletes.
|
# Propagate genuine local deletes.
|
||||||
for vid in mp.remove_from_yt:
|
for vid in mp.remove_from_yt:
|
||||||
@@ -543,6 +731,7 @@ def merge_playlist(db: Session, user: User, pl: Playlist, *, live=None) -> dict:
|
|||||||
dropped = breaker.run(
|
dropped = breaker.run(
|
||||||
lambda i=item_id: yt.delete_playlist_item(i),
|
lambda i=item_id: yt.delete_playlist_item(i),
|
||||||
lambda v=vid: failures.append(v),
|
lambda v=vid: failures.append(v),
|
||||||
|
"delete",
|
||||||
)
|
)
|
||||||
if not isinstance(dropped, _Failed):
|
if not isinstance(dropped, _Failed):
|
||||||
deleted += 1
|
deleted += 1
|
||||||
@@ -552,6 +741,7 @@ def merge_playlist(db: Session, user: User, pl: Playlist, *, live=None) -> dict:
|
|||||||
item_id = breaker.run(
|
item_id = breaker.run(
|
||||||
lambda v=vid: yt.add_playlist_item(pl.yt_playlist_id, v),
|
lambda v=vid: yt.add_playlist_item(pl.yt_playlist_id, v),
|
||||||
lambda v=vid: failures.append(v),
|
lambda v=vid: failures.append(v),
|
||||||
|
"add",
|
||||||
)
|
)
|
||||||
if not isinstance(item_id, _Failed):
|
if not isinstance(item_id, _Failed):
|
||||||
item_id_by_vid[vid] = item_id
|
item_id_by_vid[vid] = item_id
|
||||||
@@ -571,6 +761,7 @@ def merge_playlist(db: Session, user: User, pl: Playlist, *, live=None) -> dict:
|
|||||||
item_id_by_vid[w], pl.yt_playlist_id, w, pos
|
item_id_by_vid[w], pl.yt_playlist_id, w, pos
|
||||||
),
|
),
|
||||||
lambda w=want: failures.append(w),
|
lambda w=want: failures.append(w),
|
||||||
|
"move",
|
||||||
)
|
)
|
||||||
if isinstance(moved, _Failed):
|
if isinstance(moved, _Failed):
|
||||||
continue
|
continue
|
||||||
@@ -587,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,
|
||||||
@@ -613,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)}
|
||||||
|
|
||||||
@@ -631,11 +824,19 @@ def sync_all_playlists(db: Session) -> dict:
|
|||||||
try:
|
try:
|
||||||
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PULL):
|
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PULL):
|
||||||
total += sync_user_playlists(db, user).get("synced", 0)
|
total += sync_user_playlists(db, user).get("synced", 0)
|
||||||
except YouTubeError:
|
except YouTubeError as exc:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
log.warning("Playlist sync failed for user %s", user.id)
|
log.warning(
|
||||||
|
"Playlist sync failed for user %s",
|
||||||
|
user.id,
|
||||||
|
extra=logs.event("sync.playlists.failed", user_id=user.id, reason=exc.reason),
|
||||||
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
log.exception("Playlist sync crashed for user %s", user.id)
|
log.exception(
|
||||||
|
"Playlist sync crashed for user %s",
|
||||||
|
user.id,
|
||||||
|
extra=logs.event("sync.playlists.crashed", user_id=user.id),
|
||||||
|
)
|
||||||
progress.report(i, len(users), "playlist_sync")
|
progress.report(i, len(users), "playlist_sync")
|
||||||
return {"users": len(users), "playlists_synced": total}
|
return {"users": len(users), "playlists_synced": total}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user