Compare commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

"Reconnect" had no path: a grant Google revoked still read as granted, so
Settings and the wizard offered no connect button while every call failed.
An invalid_grant from the token endpoint now clears the stored grant.
2026-09-19 06:55:40 +02:00
peter 1cfb19bdc7 release: 0.91.0 (bug/prod-uat-2026-09-19)
gate / frontend (push) Failing after 4m0s
gate / backend (push) Successful in 5m23s
2026-09-19 05:56:19 +02:00
peter 90c30d596f chore(release): 0.91.0 2026-09-19 05:56:12 +02:00
peter 7ed6ccf171 fix(feed): the card menu's reset uses the card's own reset string
Two keys for one action had already drifted once; the menu now reads card.resetState like the
card's button.
2026-09-19 05:54:19 +02:00
peter ce30b99327 docs(nav): restoreNavPosition's doc back on restoreNavPosition 2026-09-19 05:54:19 +02:00
peter a7891d03c5 fix(sync): only a refusal about the channel spends its fallback window
A failed connection or token step answered nothing about the channel, yet stamped it: one proxy blip
during an RSS blackout delayed every due channel by a whole window. YouTubeError now names the
endpoint that answered; a failure no endpoint gave, or a spent quota, ends the round and stamps
nothing.
2026-09-19 05:54:19 +02:00
peter 038da5374f test: the boundary stays quiet while reloading for a new build; Back restores Home's whole question 2026-09-19 05:47:17 +02:00
peter 92979fa920 refactor(ui): one rule for resettable, role key and display name; an honest reset label
The card menu's reset read 'Clear watch progress' but also un-hides and un-watches; it now says so.
The resettable rule, the demo-first role key and the display-name fallback each lived in several
components tied by comments; they are shared helpers now, and the menu reuses the card's own
strings instead of duplicate keys.
2026-09-19 05:47:17 +02:00
peter 2d5bd275fa fix(diagnostics): a pivot or Back applies the search text at once
The query read the typing-debounced search, so for 300 ms after a jump it mixed the new view's
filters with the old view's text and fetched rows neither view shows. useDebounced takes a settle key
whose change applies the value immediately.
2026-09-19 05:47:16 +02:00
peter 49fe56b6e7 fix(nav): Back's focus keeps its place among items of the same name
Restoring by name picked the first item of that name, so Back on the log console (repeated
http.request lines) or two same-titled downloads landed on the wrong one. The saved place wins while
it still holds the name; otherwise the nearest item of that name.
2026-09-19 05:47:16 +02:00
peter 1fa6a0bea8 fix(sync): a failed feed-fallback read waits its window out, and spent quota ends the round
A channel whose uploads playlist also failed was never stamped, so a terminated channel was charged
again on every 20-minute poll instead of once per rss_fallback_hours. An upstream quotaExceeded now
stops the round instead of charging every remaining channel the same refusal.
2026-09-19 05:47:16 +02:00
peter 582f20fab6 fix(sync): the stored RSS warning says what the API fallback read
An INFO line outside a request never reaches log_events, so the fallback's
own summary was invisible in the Server log. Its counts ride on the
sync.rss.failed warning, the one line of a poll the console keeps.
Measured on localdev: 375 of 375 dark channels read, 403 new videos.
2026-09-19 05:05:13 +02:00
peter 6cb8231a9f fix(logs): the browser's copy of a polled read is quiet too
The server now logs polled reads at DEBUG, and the browser's copy of each
request line promises the server's level: at INFO the unread-count polls
filled My log within minutes and, at Detailed, the shipped log. The server
marks a line it logged at DEBUG with X-Log-Quiet, so the polled-route list
stays in one place.
2026-09-19 05:03:31 +02:00
peter 6bbf532b70 fix(diagnostics): count the 24 hours from the moment the level was raised
The countdown's clock was the moment Settings mounted, so a level raised
two minutes after opening the page read "24 h 2 m" until the first tick
(measured on prod). The clock is read at render; the interval only
re-renders.
2026-09-19 05:03:31 +02:00
peter 2fa5c8c394 fix(ui): nothing past the right edge of a phone
The Feed's chips + Sort + View row did not fit one line at 375px and pushed
the page 21px wide; the toast stack capped its width by the viewport while
it sits right of the sidebar. The toolbar rows (Feed, Plex browse) wrap,
and the toasts are capped by their host.
2026-09-19 04:54:18 +02:00
peter 373415a2eb feat(account): show the account its own last sign-in
Settings → Account names the latest sign-in on any device (only the
admin's user list did, though the 0.88 notes said otherwise), and the role
in the reader's language instead of the raw `user`.
2026-09-19 04:51:43 +02:00
peter 582c033e1c feat(feed): the card menu carries the card's own actions
Right-click, long-press or Shift+F10 on a feed card offered only the
comfort-shelf pin; watched, saved, hidden and the progress reset were
reachable only by hovering or Tabbing through every card.
2026-09-19 04:51:43 +02:00
peter ef652ee5d8 fix(ui): honest tab titles and a download field that names every site
Signed out, the tab read Home's question over the sign-in form; the legal
pages read just "Siftlode". The download field asked only for a YouTube
link although Facebook and the rest download too.
2026-09-19 04:51:43 +02:00
peter 250e3a9f90 fix(home): Back keeps the question, and the focus finds its card by name
Opening a card left Home, and Back came back to "Doesn't matter" with a
different hero, whose card then dropped out of the Continue rail and moved
the keyboard onto its neighbour. The budget, reroll seed and seen list live
on the history entry now, and a returning list focuses the item by its
name before its place.
2026-09-19 04:51:43 +02:00
peter 6096dd6927 fix(diagnostics): Back from a request's lines returns to the view it came from
Every filter change replaced the history entry, so Back after a request or
job pivot left the console altogether (the 0.90 notes promised otherwise).
A pivot follows a link and is its own entry now; the console reads the
landed entry back on popstate. The other filters still adjust in place.
2026-09-19 04:51:42 +02:00
peter 51e98bfc38 fix(app): reload onto a new build instead of crashing on a missing chunk
A tab left open across a release asked for a lazy chunk the server no
longer has (prod: DownloadDialog 404 an hour after 0.89) and fell to the
crash screen. `vite:preloadError` now reloads the tab once a minute at
most; a second failure is let through to the error boundary, which now
wraps every entry — the public /watch, /privacy and /terms pages crashed
to a blank page.
2026-09-19 04:51:31 +02:00
peter 3ee00fb051 fix(logs): keep polling reads and a channel-less account out of the warnings
The reads the frontend polls (unread counts, /api/me, the download index,
sync status, subtitle progress) are logged at DEBUG: at INFO they were ~95%
of the stored log on prod. A scheduler job failure and a playlist sync
failure carry event codes, and a Google account without a YouTube channel
has an empty playlist list instead of a warning every six hours.
2026-09-19 04:51:31 +02:00
peter 29094f5f51 fix(sync): read a dark RSS feed through the API instead
YouTube's RSS goes dark per channel for hours (prod 2026-09-18/19: 393 of
396 feeds answered 404 for four hours, sticky per channel, so a retry does
not help), and nothing else read a followed channel's new uploads. A
channel whose feed has not answered for `rss_fallback_hours` (admin knob,
default 2, 0 = off) now gets one page of its uploads playlist through the
Data API (1 unit), at most once per window, yielding to the backfill
reserve. Spend is attributed to its own quota action.
2026-09-19 04:51:31 +02:00
343 changed files with 15334 additions and 2645 deletions
+80
View File
@@ -7,6 +7,86 @@ edit the release notes and re-run the generator.
Upgrading, pinning a version and rolling back: [docs/self-hosting.md](docs/self-hosting.md#upgrading). Upgrading, pinning a version and rolling back: [docs/self-hosting.md](docs/self-hosting.md#upgrading).
## 0.93.0 — 2026-09-20
A way to report a fault from inside the app: record what you did, mark the steps, attach screenshots — and the app writes the report for you, with the logs of exactly that session attached.
### Features
- Start a recording (the command palette, or Settings → Diagnostics) and a pill follows you: a timer, “Step” to mark what you just did, a screenshot button, and Stop. Everything you and the server do while it runs is collected under that one session, and nothing else is.
- Stop opens the composer: a drafted title and description, the steps you marked interleaved with the pages you visited, a table of your browser and the app's version, and the attachments — each one reviewable, each one removable.
- The log lines that would be sent can be read before they go, line by line, and any of them left out.
- Screenshots come from a paste, a drop, a file, or capturing the tab — up to eight, and any of them can be dropped again before sending.
- Send opens an issue on the project's tracker with the report and its logs attached, and the preview shows it exactly as the tracker will render it. Five reports an hour.
- Without a tracker configured — or whenever you would rather keep it — the same report downloads as a zip instead.
- An administrator sets the tracker up in Settings → System (address, repository, token) and can test the connection there. The server refuses to send to a public repository: a report carries its reporter's log.
### Fixes
- The Plex player keeps the keyboard inside itself: Tab no longer walks out of a playing film into the page behind it, not even while its own settings panel is open.
- A report cannot be sent twice at once, from any tab: while one send runs the others say so, and a send that fails half-way retries only what is missing — never a second issue.
- A recording ends by itself after an hour, and both Stop and that hour return your diagnostics level to whatever it was before you started.
### Internal
- A session's logs and screenshots are purged with the session after 7 days, like every other diagnostic line, and another account's session id is ignored rather than refused.
- Four adversarial review rounds at the epic's end: 80 findings fixed across them, every regression test proven to fail with its own fix removed, and the keyboard and focus work measured in a real browser rather than argued.
## 0.92.0 — 2026-09-19
A debt burn: every item the roadmap had deferred is done or dropped — Plex playback per viewer, refusals said in your language, controls that keep the keyboard focus, and a screen reader that hears Hungarian.
### Features
- The command palette has a visible way in: a Ctrl K key cap in the header's search field, and a “Jump to…” pill on pages without one.
- The Playlists rail says, under “Missing a list? Why?”, which lists YouTube's API does not hand out (Watch later, History, lists saved from other channels).
- The last native drop-downs are the app's own controls: the live search's result count, rows per page, a download format's content, audio format and quality, and the download dialog's format.
- Plex plays one session per viewer, so two accounts can watch the same film at once; an administrator sets the ceiling on concurrent sessions (Configuration → Plex).
### Fixes
- A YouTube action YouTube refuses says what to do — wait for tomorrow's quota, reconnect the account, or try again later — instead of a server error; a passing Google hiccup no longer asks you to reconnect.
- Every refusal the server gives is said in your language, and every label a screen reader or tooltip reads is translated.
- Dragging to reorder a list is announced in your language and with the right position, and a Plex playlist keeps a drag within the list it started in.
- A button that turns itself off, or a row that removes itself, leaves the keyboard focus where it was; Back from a Plex title returns the focus to the tile you opened, even after using a button there.
- With Plex turned off, the Home shelf shows your YouTube pins only, and reordering it leaves the hidden Plex pins in their places; two reorders at once can no longer put two pins on one slot.
- The Home hero from a pinned show offers to unpin the show, and “Surprise me” from the command palette really asks for a surprise.
- A finished Plex title scrobbles once and gets no resume point pushed after it; a subtitle pass that stalled resumes instead of starting over.
- Tables keep their j/k keys after the window is resized across the phone breakpoint, and boxes grow with the text-size setting.
- Whitelisting an address and activating its account happen in one step, and the audit log names the entry.
### Internal
- Every mutation answers `ok`, and route helpers leave the commit to the request — both held by tests; a request that leaves a write uncommitted now fails its test.
- New lint rules and contracts: no native select, no hover that ignores aria-disabled, no untranslated accessible text, no unused translation key.
- Two adversarial review rounds at the epic's end: 23 findings, all fixed, each regression test proven to fail with its own fix removed.
## 0.91.0 — 2026-09-19
What a test run on the live instance turned up, fixed: new videos keep arriving while YouTube's feeds are down, an open tab survives a release, and Back returns to what you left.
### Features
- A card's menu (right-click or long press) now offers the card's own actions — watched, save, hide, reset — beside the comfort-shelf pin.
- Settings → Account shows your role and your last sign-in. 0.88 announced the last sign-in in your account panel; it is actually there now.
### Fixes
- When YouTube's feed for a channel stays dark for hours, as it did for 393 of 396 channels on 18–19 September, the channel's newest uploads are read through the YouTube API instead, at most once every 2 hours per channel (adjustable by an administrator, 0 turns it off).
- A tab left open across a release no longer falls to the crash screen when it opens something it had not loaded yet: it reloads onto the new version once.
- Back from a request's lines in the Server log returns to the view the pivot was made from, search text included, as 0.90 promised.
- Back to Home keeps the question you asked (the time budget and the hero you rerolled to), and the keyboard lands on the card you opened, even when the rail's cards moved or several share a name.
- Browser tabs are named after the page again: signed out, and on the privacy and terms pages.
- On a phone, the feed and Plex toolbars wrap instead of running past the edge, and so do notifications.
- The countdown of a raised diagnostics level counts from the moment it was raised, not from when the page was opened.
- The download box's placeholder names every site it takes, Facebook included.
### Internal
- The screens' own polling reads (the unread badges, the download list, the scheduler) are logged at debug level in the server's log and in the browser's copy of it, where they made up about 95% of the lines.
- An account with no YouTube channel no longer writes a warning every six hours, and a scheduler job that fails carries an event code.
- Two adversarial review rounds: 15 findings raised, 11 survived, all fixed, and every regression test was proven to fail with its own fix removed.
## 0.90.0 — 2026-09-19 ## 0.90.0 — 2026-09-19
The server's log gets a screen of its own: an admin can now read what the API, the downloader and the browsers wrote down, filter it, and follow one request or job from start to end. The server's log gets a screen of its own: an admin can now read what the API, the downloader and the browsers wrote down, filter it, and follow one request or job from start to end.
+5 -5
View File
@@ -56,9 +56,9 @@ how it gets built. Concretely:
## 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`, `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
@@ -82,8 +82,8 @@ CQRS, a repository/DI framework, a router library, a generic CRUD-router factory
| Admin gate import | `app.auth` | the `app.routes.admin` re-export | | Admin gate import | `app.auth` | the `app.routes.admin` re-export |
| Upstream failure status | `422` with JSON detail | `502` (reserve 502 for raw byte-proxies) | | Upstream failure status | `422` with JSON detail | `502` (reserve 502 for raw byte-proxies) |
| User-facing error | structured `detail` (`{code, reason}`) | a bare English string under a bilingual UI | | User-facing error | structured `detail` (`{code, reason}`) | a bare English string under a bilingual UI |
| Mutation response | `{"ok": true, …}` (`{"deleted": id}`) | ad-hoc `{"unsubscribed"}`/`{"cleared": n}`/echo | | Mutation response | `{"ok": true, …}` (`{"deleted": id}`), test-held | a bare `{"unsubscribed"}`/`{"cleared": n}`, no `ok` |
| Transaction commit | the handler/job-wrapper commits; helpers `flush` | helpers that commit (only `quota` owns its session) | | Transaction commit | the handler commits; helpers `flush` (test-held list) | helpers that commit (only `quota` owns its session) |
| Admin knob storage | `sysconfig` (values) | env / `app_state` / `scheduler_settings` scatter | | Admin knob storage | `sysconfig` (values) | env / `app_state` / `scheduler_settings` scatter |
| Job status word | the `downloads/status.py` StrEnum | loose `"canceled"`/`"cancelled"` literals | | Job status word | the `downloads/status.py` StrEnum | loose `"canceled"`/`"cancelled"` literals |
| Current time | inline `datetime.now(timezone.utc)` | new `now_utc`/`_now` helpers | | Current time | inline `datetime.now(timezone.utc)` | new `now_utc`/`_now` helpers |
@@ -92,7 +92,7 @@ CQRS, a repository/DI framework, a router library, a generic CRUD-router factory
| FE keyboard shortcut | a row in `lib/keymap.ts` + `dispatch()` | a private `keydown` listener with its own key literals | | FE keyboard shortcut | a row in `lib/keymap.ts` + `dispatch()` | a private `keydown` listener with its own key literals |
| FE list/grid keys | `NavGrid`/`keyboardNav` + `navItemAttrs` + `data-nav-open` | a private arrow-key / roving-tabindex handler | | FE list/grid keys | `NavGrid`/`keyboardNav` + `navItemAttrs` + `data-nav-open` | a private arrow-key / roving-tabindex handler |
| FE view push (history) | `rememberNavPosition()` right before `history.pushState` | a push that forgets: Back strands focus on `<body>` | | FE view push (history) | `rememberNavPosition()` right before `history.pushState` | a push that forgets: Back strands focus on `<body>` |
| FE control | the chip/segmented primitives (`Select` only for >6 options) | a native `<select>` | | 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 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 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 network call | `req`/`uploadReq`/`beacon`, or `lib/http.ts` `send` (public pages) | a bare `fetch`/`XMLHttpRequest`/`WebSocket` (lint-enforced) |
+1 -1
View File
@@ -1 +1 @@
0.90.0 0.93.0
@@ -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")
+5 -4
View File
@@ -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
+3
View File
@@ -58,6 +58,9 @@ class AuditAction:
DOWNLOAD_CREDENTIAL_DELETE = "download_credential_delete" DOWNLOAD_CREDENTIAL_DELETE = "download_credential_delete"
# The log itself was cleared (a single row survives the clear as tamper-evidence) # The log itself was cleared (a single row survives the clear as tamper-evidence)
AUDIT_CLEAR = "audit_clear" AUDIT_CLEAR = "audit_clear"
# E16: a user sent a debug session's issue to the tracker — the one act that takes their log
# off this instance, so it is recorded (target = the session, summary = the issue's URL).
ISSUE_CREATE = "issue_create"
def record( def record(
+12 -10
View File
@@ -4,7 +4,7 @@ from sqlalchemy import delete, func, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from starlette.requests import HTTPConnection from starlette.requests import HTTPConnection
from app import diagnostics from app import debugsession
from app import email as email_mod from app import email as email_mod
from app import logs from app import logs
from app import mailtext from app import mailtext
@@ -14,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
@@ -341,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")
@@ -366,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
@@ -418,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
@@ -429,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
+14
View File
@@ -119,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.
@@ -312,6 +315,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
@@ -344,6 +351,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).
+485
View File
@@ -0,0 +1,485 @@
"""Debug sessions (E16): Start → reproduce → Stop, and the evidence a bug report is built from.
While a session is open its user's browser names it in `X-Debug-Session` on every request. The auth
dependency accepts the header only for the caller's OWN open session (anything else is ignored, never
an error — a stale tab must not break the app) and then logs that request at the session's level with
every line tagged by its id; the browser tags its own events the same way. The issue is later built
from exactly the rows carrying the tag, so what the review list shows is what gets sent.
Starting a session IS the consent for its level, for its duration: the start dialog carries the
consent text. It needs no change to the user's own diagnostics level, and none is made — when the
session ends the level in force is simply the user's own again.
A session is capped at `MAX_MINUTES`, set by the server at Start. An open one past its cap is over
whether or not anything has closed it yet: it reads as over at once, a new Start closes it, and the
hourly log job sweeps the rest. Sessions go with the logs they point at, after `log_retention_days`.
Core layer: imports `models`, `diagnostics`.
"""
import secrets
from datetime import datetime, timedelta, timezone
from enum import StrEnum
from typing import Literal, get_args
from uuid import uuid4
from sqlalchemy import and_, delete, func, not_, select, update
from sqlalchemy.orm import Session, defer
from app import diagnostics
from app.models import DebugScreenshot, DebugSession, LogEvent, User
HEADER = "x-debug-session"
MAX_MINUTES = diagnostics.SESSION_MINUTES
SCREENSHOT_MAX_BYTES = 10 * 1024 * 1024
SCREENSHOTS_MAX = 8
# What one user may keep in screenshots across ALL their sessions (until retention takes them): the
# per-session cap alone would let Start → 8 images → Stop repeat without end.
SCREENSHOT_USER_MAX_BYTES = 100 * 1024 * 1024
# The screenshot types kept, and the extension each is exported and sent under — one table, so an
# accepted type always has a name.
IMAGE_TYPES = {"image/png": "png", "image/jpeg": "jpg", "image/webp": "webp"}
# What a screenshot is called in the zip and on the issue. `issues/bundle.py` builds the names from
# this and `IMAGE_TYPES`; this module and `issues/gitea.py` read them back to tell a screenshot's
# link from a log's. One definition, because three copies of a name is how the next rename breaks
# `images_pending` into silence.
SCREENSHOT_PREFIX = "screenshot-"
# Rows an issue can carry. A 60-minute session at Teljes is a few thousand rows; past this the
# bundle says how many it left out rather than growing without bound.
BUNDLE_MAX = 5_000
SessionLevel = Literal["DEBUG", "TRACE"]
SESSION_LEVELS: tuple[str, ...] = get_args(SessionLevel)
# Detail order of the diagnostics levels: a session raises the level in force, never lowers it.
_DETAIL = {"INFO": 0, "DEBUG": 1, "TRACE": 2}
_ID_LEN = 16
class EndReason(StrEnum):
STOPPED = "stopped"
EXPIRED = "expired"
def is_live(session: DebugSession, now: datetime | None = None) -> bool:
"""Open and within its cap — THE liveness rule: an open session past its cap is over."""
return session.ended_at is None and session.until > (now or datetime.now(timezone.utc))
def open_session(db: Session, user: User) -> DebugSession | None:
"""The user's live session, or None. Read-only: one past its cap is over and reads as None;
`start` and the hourly sweep are what close it."""
session = _unclosed(db, user)
if session is None or not is_live(session, datetime.now(timezone.utc)):
return None
return session
def _unclosed(db: Session, user: User) -> DebugSession | None:
return db.scalar(
select(DebugSession).where(DebugSession.user_id == user.id, DebugSession.ended_at.is_(None))
)
def start(db: Session, user: User, level: SessionLevel) -> DebugSession:
"""Open a session, or return the live one — a second tab pressing Start joins the recording
rather than ending it. One past its cap is closed first: the one-open-per-user index would
refuse the new row beside it. Flushes; the caller commits. Two concurrent Starts can still both
get here: the index refuses the second, and the caller joins the one that won."""
now = datetime.now(timezone.utc)
current = _unclosed(db, user)
if current is not None:
if is_live(current, now):
return current
end(current, EndReason.EXPIRED)
db.flush()
session = DebugSession(
id=secrets.token_hex(_ID_LEN // 2),
user_id=user.id,
level=level,
started_at=now,
until=now + timedelta(minutes=MAX_MINUTES),
)
db.add(session)
db.flush()
return session
def stop(session: DebugSession) -> None:
"""The user's Stop. Past its cap the session had already ended on its own, and a late Stop must
not rename that."""
end(session, EndReason.STOPPED if is_live(session) else EndReason.EXPIRED)
def end(session: DebugSession, reason: EndReason) -> None:
"""Close a session. An expired one is dated at its cap, not at whenever it was noticed."""
if session.ended_at is not None:
return
session.ended_at = (
session.until
if reason is EndReason.EXPIRED
else min(datetime.now(timezone.utc), session.until)
)
session.ended_reason = reason
def from_header(db: Session, user: User, value: str | None) -> DebugSession | None:
"""The session a request names, if it is the caller's own and still live; else None."""
if not value or len(value) != _ID_LEN or not all(c in "0123456789abcdef" for c in value):
return None
session = db.get(DebugSession, value)
if (
session is None
or session.user_id != user.id
or not is_live(session, datetime.now(timezone.utc))
):
return None
return session
def level_for(user: User, session: DebugSession | None) -> tuple[str, datetime | None]:
"""The diagnostics level (and its expiry) a request is logged at: the user's own, raised to the
session's while one is named — whichever records more.
A named session also caps the expiry at its own: this pair is what the process tracks under the
session (app/logs.py), and an expiry outliving the recording would hold the root logger down
after it ended."""
own = diagnostics.effective_level(user)
until = user.diagnostics_until if own != diagnostics.BASE else None
if session is None:
return own, until
if _DETAIL[session.level] <= _DETAIL[own]:
return own, min(until, session.until) if until else session.until
return session.level, session.until
def lock(db: Session, session: DebugSession) -> None:
"""Re-read the session under a row lock, held until the caller commits. Everything that changes
what an issue is made of — the draft, the screenshots, the send itself — takes it first: a send
in flight sees none of them change under it, and each of them sees a send that committed."""
db.refresh(session, with_for_update=True)
def save_draft(session: DebugSession, draft: dict) -> None:
"""Keep the composer's draft (the caller validated, locked and commits). Shape: `title`, `body`
(the rendered markdown an export or an issue carries), `excluded` (log row ids taken out of the
evidence) and `form` (the composer's own fields, to reopen it as it was). A delivery's progress
is not the composer's to replace, and is kept."""
progress = (session.draft or {}).get("delivery")
session.draft = {**draft, "delivery": progress} if progress else draft
# --- a delivery's progress -----------------------------------------------------------------
# Written by app/issues/gitea.py into the draft; read HERE, so the send's decision and what the
# composer shows are one reading of it.
def delivery(session: DebugSession) -> dict:
return dict((session.draft or {}).get("delivery") or {})
def not_uploaded(session: DebugSession) -> list[str]:
"""The files a send that stopped half-way has still to upload — by name, as the issue carries
them. What the composer names to the user."""
return list(delivery(session).get("pending") or [])
def images_pending(session: DebugSession) -> bool:
"""Whether the issue's body still has to be edited to show the screenshots it already carries
(every upload went, the edit that shows them did not)."""
state = delivery(session)
shown = state.get("images_shown")
return not shown and any(n.startswith(SCREENSHOT_PREFIX) for n in state.get("links") or {})
def unfinished(session: DebugSession) -> bool:
"""Whether a send owes the issue anything — the retry's question."""
return bool(not_uploaded(session)) or images_pending(session)
# A send claims the session while it talks to the tracker, instead of holding the row's lock across
# those calls: a Stop, an autosave or a screenshot would queue behind a lock for the whole send.
# They see the claim and are refused at once instead. A claim not renewed for this long was left by
# a request that died mid-send (the process restarted), and the next send takes it over — which is
# why a live send RENEWS it every time it reports progress (`set_delivery`). A claim is a lease, and
# a send slower than the lease must not lose it while it is still running.
SEND_CLAIM_SECONDS = 600
def _claim(session: DebugSession) -> dict:
claim = delivery(session).get("sending")
return claim if isinstance(claim, dict) else {}
def _stamp(token: str) -> dict:
return {"at": datetime.now(timezone.utc).isoformat(), "by": token}
def sending(session: DebugSession) -> bool:
"""Whether a send is under way right now (a live claim)."""
at = _claim(session).get("at")
if not at:
return False
return (datetime.now(timezone.utc) - datetime.fromisoformat(at)).total_seconds() < (
SEND_CLAIM_SECONDS
)
def claim_send(session: DebugSession) -> str:
"""Mark the session as being sent, and return the token that OWNS this claim. The caller holds
the lock and commits, which is what makes the claim visible to everyone else."""
token = uuid4().hex
_write_delivery(session, {**delivery(session), "sending": _stamp(token)})
return token
def release_send(session: DebugSession, token: str) -> None:
"""Give back our OWN claim, and only ours: one that aged out and was taken over belongs to the
send holding it now, and handing that one back would let a third send in beside it."""
if _claim(session).get("by") != token:
return
_write_delivery(session, {k: v for k, v in delivery(session).items() if k != "sending"})
class SendClaimLost(Exception):
"""This send's claim is no longer on the row: it aged out and another send took it over. The
send must stop — what it uploads from here it can no longer record, so a retry would send it
again — and it must not write, because the row now holds the other send's progress."""
def claim_owner(db: Session, session: DebugSession) -> str | None:
"""Who holds the row's claim RIGHT NOW, read from the ROW and not from the object.
`expire_on_commit` is off (app/db.py), so a checkpoint does not re-read: a send that stalled
past its lease still carries its own dead token in memory long after another send took over.
A targeted scalar, not `lock`/`db.refresh` — a refresh here would discard the `issue_url` that
`gitea.deliver` assigns one line before its first progress report, and that link is the one
thing the send must not lose."""
return db.scalar(
select(DebugSession.draft["delivery"]["sending"]["by"].as_string()).where(
DebugSession.id == session.id
)
)
def set_delivery(db: Session, session: DebugSession, state: dict, token: str) -> None:
"""Write a delivery's progress (app/issues/gitea.py keeps it; this module reads it), and renew
the claim while doing it — a send still reporting progress is still alive, so one slower than
the lease cannot expire while it is still running.
The claim is not the caller's to carry: `gitea.deliver` threads ONE snapshot of this dict
through a whole send, so a progress report that carried the claim wrote a stale one back at
every step. It is stamped from `token` here instead, and only after the ROW confirms the claim
is still ours. Without that check the write is a full-column overwrite with no WHERE: a send
that lost its lease came back and stomped the take-over send's committed links, then put its
own dead token back on an idle session (deep-review round 4). `release_send` has always made
the same check; this is the other half of it."""
if claim_owner(db, session) != token:
raise SendClaimLost(session.id)
state = {k: v for k, v in state.items() if k != "sending"}
state["sending"] = _stamp(token)
_write_delivery(session, state)
def _write_delivery(session: DebugSession, state: dict) -> None:
# A new dict, so the JSONB column sees the change.
session.draft = {**(session.draft or {}), "delivery": state}
def serialize(session: DebugSession | None, screenshots: int = 0) -> dict | None:
if session is None:
return None
return {
"id": session.id,
"level": session.level,
"started_at": session.started_at.isoformat(),
"until": session.until.isoformat(),
"ended_at": session.ended_at.isoformat() if session.ended_at else None,
"ended_reason": session.ended_reason,
"issue_url": session.issue_url,
# A send that stopped half-way: the files it has still to upload, and whether the issue's
# body has still to be edited to show the screenshots it already carries. Both are true of a
# send that is RUNNING too — every step commits its progress — so `sending` goes with them:
# without it a reload or a second tab renders a live send as a failed one, and offers a
# retry that is certain to be refused.
"pending_files": not_uploaded(session),
"images_pending": images_pending(session),
"sending": sending(session),
"screenshots": screenshots,
"draft": session.draft,
}
def view(db: Session, session: DebugSession) -> dict:
"""The session as the API serves it — one shape wherever it is served."""
return serialize(session, screenshot_count(db, session)) or {}
def screenshot_count(db: Session, session: DebugSession) -> int:
return (
db.scalar(
select(func.count())
.select_from(DebugScreenshot)
.where(DebugScreenshot.session_id == session.id)
)
or 0
)
# --- screenshots ---------------------------------------------------------------------------
# By content, not by the declared type: what is stored is served back to a browser as an image.
_SIGNATURES = (
(b"\x89PNG\r\n\x1a\n", "image/png"),
(b"\xff\xd8\xff", "image/jpeg"),
)
def sniff(data: bytes) -> str | None:
"""The image type `data` really is — one of IMAGE_TYPES — or None."""
for magic, mime in _SIGNATURES:
if data.startswith(magic):
return mime
if len(data) >= 12 and data[:4] == b"RIFF" and data[8:12] == b"WEBP":
return "image/webp"
return None
def lock_user(db: Session, user_id: int) -> None:
"""Hold this user's own lock for the rest of the transaction. A session's row lock bounds what
THAT session may keep; the byte cap below is the user's across all of them, and a lock on one
session cannot serialize a check that reads them all — two uploads to two sessions both read the
total before either commits, and both pass."""
db.execute(select(func.pg_advisory_xact_lock(user_id)))
def stored_screenshot_bytes(db: Session, user_id: int) -> int:
return (
db.scalar(
select(func.coalesce(func.sum(DebugScreenshot.size), 0))
.join(DebugSession, DebugSession.id == DebugScreenshot.session_id)
.where(DebugSession.user_id == user_id)
)
or 0
)
class ScreenshotRefused(Exception):
"""A screenshot that cannot be kept; `code` is the refusal's machine word."""
def __init__(self, code: str) -> None:
super().__init__(code)
self.code = code
def add_screenshot(db: Session, session: DebugSession, data: bytes) -> DebugScreenshot:
"""Keep one screenshot. Locks the user and the session, and flushes; the caller commits.
Refused while a send is under way or once the issue was sent (it can no longer carry it), past
the per-session count or the user's storage, or when it is not an image we can show."""
if not data:
raise ScreenshotRefused("empty")
mime = sniff(data)
if mime is None:
raise ScreenshotRefused("not_an_image")
# Under both locks: two uploads at once cannot both pass the count (the session's), nor both
# pass the user's byte cap from two different sessions (the user's). Always in this order.
lock_user(db, session.user_id)
lock(db, session)
if sending(session):
raise ScreenshotRefused("sending")
if session.issue_url is not None:
raise ScreenshotRefused("already_sent")
if screenshot_count(db, session) >= SCREENSHOTS_MAX:
raise ScreenshotRefused("too_many")
if stored_screenshot_bytes(db, session.user_id) + len(data) > SCREENSHOT_USER_MAX_BYTES:
raise ScreenshotRefused("storage_full")
shot = DebugScreenshot(
session_id=session.id,
created_at=datetime.now(timezone.utc),
mime=mime,
size=len(data),
data=data,
)
db.add(shot)
db.flush()
return shot
def remove_screenshot(db: Session, session: DebugSession, shot: DebugScreenshot) -> None:
"""Delete one screenshot, under the lock. Refused while a send is under way or unfinished: a
delivery names the screenshots by their order, and removing one would renumber what it still
has to send. Once the issue has everything, this only drops OUR copy — the issue keeps its
own. The caller commits."""
lock(db, session)
if sending(session):
raise ScreenshotRefused("sending")
if unfinished(session):
raise ScreenshotRefused("already_sent")
db.delete(shot)
db.flush()
def screenshots(db: Session, session: DebugSession) -> list[DebugScreenshot]:
"""The session's screenshots, oldest first, WITHOUT their bytes loaded."""
return list(
db.scalars(
select(DebugScreenshot)
.where(DebugScreenshot.session_id == session.id)
.options(defer(DebugScreenshot.data))
.order_by(DebugScreenshot.id)
)
)
# --- the evidence --------------------------------------------------------------------------
# The browser's own log upload (`POST` here, every few seconds while recording) is how the evidence
# arrives, not part of it: its server lines would fill the bundle with the recording's plumbing. The
# browser leaves its copies out for the same reason (frontend lib/diagnostics `isOwnRequest`).
INGEST_PATH = "/api/diagnostics/events"
def _evidence(session: DebugSession):
upload_line = and_(
LogEvent.event == "http.request", LogEvent.attrs["path"].astext == INGEST_PATH
)
# `IS TRUE`, not the bare condition: for a row with no event or no attrs it is NULL, and
# `NOT NULL` would drop exactly the rows that are evidence.
return (LogEvent.debug_session_id == session.id, not_(upload_line.is_(True)))
def bundle(
db: Session, session: DebugSession, *, drop_excluded: bool = False
) -> tuple[list[LogEvent], int]:
"""The session's log rows, browser and server interleaved in time, capped at BUNDLE_MAX — and
how many rows it has in all, so a cut bundle can say so. `drop_excluded` leaves out the rows
the user took out in the composer: what an export or an issue carries."""
where = list(_evidence(session))
excluded = (session.draft or {}).get("excluded") or [] if drop_excluded else []
if excluded:
where.append(LogEvent.id.not_in(excluded))
rows = list(
db.scalars(
select(LogEvent).where(*where).order_by(LogEvent.ts, LogEvent.id).limit(BUNDLE_MAX)
)
)
total = len(rows)
if total == BUNDLE_MAX:
total = db.scalar(select(func.count()).select_from(LogEvent).where(*where)) or total
return rows, total
# --- housekeeping (the hourly log job) -----------------------------------------------------
def sweep(db: Session) -> int:
"""Stage closing every open session past its cap; returns how many."""
now = datetime.now(timezone.utc)
result = db.execute(
update(DebugSession)
.where(DebugSession.ended_at.is_(None), DebugSession.until <= now)
.values(ended_at=DebugSession.until, ended_reason=EndReason.EXPIRED.value)
.execution_options(synchronize_session=False)
)
return result.rowcount or 0
def gc(db: Session, retention_days: int) -> int:
"""Stage deleting sessions (and by cascade their screenshots) started before the log retention
window — the rows they would bundle are gone by then too. Returns how many."""
cutoff = datetime.now(timezone.utc) - timedelta(days=max(1, retention_days))
result = db.execute(delete(DebugSession).where(DebugSession.started_at < cutoff))
return result.rowcount or 0
+4
View File
@@ -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,
} }
+5 -1
View File
@@ -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
+107 -15
View File
@@ -8,14 +8,16 @@ second one.
Every shelf card carries a resolved `next`: the thing that actually plays when you click the tile — Every shelf card carries a resolved `next`: the thing that actually plays when you click the tile —
a show's in-progress-or-next episode, a channel's newest unwatched video, a playlist's first a show's in-progress-or-next episode, a channel's newest unwatched video, a playlist's first
unwatched item. It costs a query per non-leaf pin, which is affordable precisely because this shelf is unwatched item. That buys the property the whole surface exists for: one click, no intermediate page,
short and hand-curated, and it buys the property the whole surface exists for: one click, no no second decision. Channels and playlists are resolved for the whole shelf at once (`_Batch`), so the
intermediate page, no second decision. cost does not grow a query or two per pin.
Domain module, so nothing here raises HTTP. `resolve_ref` returns None for "no such thing, or not Domain module, so nothing here raises HTTP. `resolve_ref` returns None for "no such thing, or not
yours" and the route turns that into its 404 — core never imports routes. yours" and the route turns that into its 404 — core never imports routes.
""" """
from dataclasses import dataclass, field
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -73,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(
@@ -103,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
@@ -117,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:
@@ -142,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
@@ -159,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
@@ -171,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")
]
+44 -31
View File
@@ -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 ──────────────────────────────────────────────────────────────────────────────────────────
+3
View File
@@ -0,0 +1,3 @@
"""Bug reports built from debug sessions (E16): the files an issue carries, and where they go —
downloaded as a zip, or (S4) sent to a Gitea issue tracker. The session itself — what is recorded
and tagged — is the core module `app/debugsession.py`."""
+79
View File
@@ -0,0 +1,79 @@
"""What an issue from a debug session carries, as files — one definition for the zip export and the
Gitea upload, so the two cannot drift apart.
* `issue.md` — the title and the body the user wrote and reviewed in the composer (rendered there,
in their language; the server does not rewrite it);
* `client.log` / `server.log` — the session's evidence (`debugsession.bundle`) minus the rows the
user took out, one JSON object per line, browser rows and server rows apart. `.log`, not
`.jsonl`: Gitea refuses that extension (measured, plans/e16 S0);
* the screenshots, in the order they were taken.
Every row is already redacted twice (at capture, and again when stored), and nothing here adds a
field the review list did not show.
"""
import io
import json
import zipfile
from dataclasses import dataclass
from sqlalchemy import select
from sqlalchemy.orm import Session
from app import debugsession, logsink
from app.models import DebugScreenshot, DebugSession
@dataclass(frozen=True)
class IssueFile:
name: str
data: bytes
mime: str
def title_of(session: DebugSession) -> str:
# Not a sentence of ours: the composer always saves a title in the user's language, and a
# session exported before it ever saved is named by its date alone, in no language at all.
title = ((session.draft or {}).get("title") or "").strip()
return title or f"{session.started_at:%Y-%m-%d %H:%M} · {session.id}"
def body_of(session: DebugSession) -> str:
return ((session.draft or {}).get("body") or "").strip()
def _lines(rows) -> bytes:
return "".join(
json.dumps(logsink.serialize(r), ensure_ascii=False, default=str) + "\n" for r in rows
).encode()
def files(db: Session, session: DebugSession) -> list[IssueFile]:
"""The session's issue as files: the text, the two logs, the screenshots."""
rows, _ = debugsession.bundle(db, session, drop_excluded=True)
text = f"# {title_of(session)}\n\n{body_of(session)}\n"
out = [
IssueFile("issue.md", text.encode(), "text/markdown"),
IssueFile("client.log", _lines(r for r in rows if r.source == "web"), "text/plain"),
IssueFile("server.log", _lines(r for r in rows if r.source != "web"), "text/plain"),
]
shots = db.scalars(
select(DebugScreenshot)
.where(DebugScreenshot.session_id == session.id)
.order_by(DebugScreenshot.id)
)
for n, shot in enumerate(shots, start=1):
ext = debugsession.IMAGE_TYPES[shot.mime]
name = f"{debugsession.SCREENSHOT_PREFIX}{n}.{ext}"
out.append(IssueFile(name, shot.data, shot.mime))
return out
def zipped(items: list[IssueFile]) -> bytes:
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z:
for item in items:
# The images are compressed already: deflating them again costs time and wins nothing.
method = zipfile.ZIP_STORED if item.mime.startswith("image/") else zipfile.ZIP_DEFLATED
z.writestr(item.name, item.data, compress_type=method)
return buf.getvalue()
+275
View File
@@ -0,0 +1,275 @@
"""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 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)."""
with _client(_settings(db)) as (client, repo):
r = _call(client, "POST", "/markdown", json={"Text": text, "Mode": "gfm", "Context": repo})
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"![{n}]({state['links'][n]})" for n in sorted(shots))
body = f"{bundle.body_of(session)}\n\n---\n\n{images}"
_call(client, "PATCH", f"/repos/{repo}/issues/{number}", json={"body": body})
debugsession.set_delivery(db, session, {**state, "images_shown": True}, token)
checkpoint()
return session.issue_url or ""
+76 -13
View File
@@ -100,12 +100,14 @@ class _RequestScope:
request; every copied context shares the same object, so what the dependency writes is seen by request; every copied context shares the same object, so what the dependency writes is seen by
the handler and by the request line itself.""" the handler and by the request line itself."""
__slots__ = ("user_id", "level") __slots__ = ("user_id", "level", "debug_session_id")
def __init__(self) -> None: def __init__(self) -> None:
self.user_id: int | None = None self.user_id: int | None = None
# The user's consented diagnostics level as a logging number; None at the base level. # The user's consented diagnostics level as a logging number; None at the base level.
self.level: int | None = None self.level: int | None = None
# The user's own open debug session this request named (E16), else None.
self.debug_session_id: str | None = None
_scope: contextvars.ContextVar[_RequestScope | None] = contextvars.ContextVar( _scope: contextvars.ContextVar[_RequestScope | None] = contextvars.ContextVar(
@@ -113,11 +115,23 @@ _scope: contextvars.ContextVar[_RequestScope | None] = contextvars.ContextVar(
) )
def set_user(user_id: int | None, level: str = "INFO", until: datetime | None = None) -> None: def set_user(
user_id: int | None,
level: str = "INFO",
until: datetime | None = None,
debug_session: str | None = None,
) -> None:
"""Attribute the rest of this request's log lines to `user_id`, and log them at the user's """Attribute the rest of this request's log lines to `user_id`, and log them at the user's
consented diagnostics `level` until `until` (E22 S5; "INFO" = no elevation). Called from the consented diagnostics `level` until `until` (E22 S5; "INFO" = no elevation). Called from the
auth dependency — the one place the session is actually resolved into an account — rather than auth dependency — the one place the session is actually resolved into an account — rather than
from the request middleware, which runs OUTSIDE the session middleware.""" from the request middleware, which runs OUTSIDE the session middleware.
`debug_session` tags every line with the user's open debug session (E16). The caller has
already checked it is theirs and open; a session's level arrives here as `level`/`until`.
The root logger's floor is tracked per elevation: a request without the session's header (an
`<img>`, a `<video>` — the browser sends those itself) carries only the user's own level, and
must not lower the floor a concurrent request of the session still needs."""
_user_id.set(user_id) _user_id.set(user_id)
scope = _scope.get() scope = _scope.get()
if scope is None: # outside log_requests (a script, a unit test): this context only if scope is None: # outside log_requests (a script, a unit test): this context only
@@ -125,9 +139,11 @@ def set_user(user_id: int | None, level: str = "INFO", until: datetime | None =
_scope.set(scope) _scope.set(scope)
elevated = level != "INFO" and until is not None and until > datetime.now(timezone.utc) elevated = level != "INFO" and until is not None and until > datetime.now(timezone.utc)
scope.user_id = user_id scope.user_id = user_id
scope.debug_session_id = debug_session
scope.level = resolve_level(level) if elevated else None scope.level = resolve_level(level) if elevated else None
if user_id is not None: if user_id is not None:
_track_elevation(user_id, scope.level, until) key = user_id if debug_session is None else (user_id, debug_session)
_track_elevation(key, scope.level, until)
def set_job(job_id: int | None) -> None: def set_job(job_id: int | None) -> None:
@@ -328,6 +344,12 @@ class ContextFilter(logging.Filter):
scope = _scope.get() scope = _scope.get()
if scope is not None and scope.user_id is not None and not hasattr(record, "user_id"): if scope is not None and scope.user_id is not None and not hasattr(record, "user_id"):
record.user_id = scope.user_id record.user_id = scope.user_id
if (
scope is not None
and scope.debug_session_id is not None
and not hasattr(record, "debug_session_id")
):
record.debug_session_id = scope.debug_session_id
return True return True
@@ -545,18 +567,21 @@ def current_level() -> str:
# The base level (`LOG_LEVEL`, then the admin knob). NOTSET until configure_logging runs — a unit # The base level (`LOG_LEVEL`, then the admin knob). NOTSET until configure_logging runs — a unit
# test, a script — so the gate lets everything through there, as it did before the gate existed. # test, a script — so the gate lets everything through there, as it did before the gate existed.
_base_level = logging.NOTSET _base_level = logging.NOTSET
# user id → (level number, expiry) for every elevation this process has seen and not yet expired. # → (level number, expiry) for every elevation this process has seen and not yet expired: a user's
_elevations: dict[int, tuple[int, datetime]] = {} # own, by user id, and a debug session's (E16), by (user id, session id) — its requests alone carry
# its level, so it is tracked apart from the user's own requests.
_ElevationKey = int | tuple[int, str]
_elevations: dict[_ElevationKey, tuple[int, datetime]] = {}
_elevations_lock = threading.Lock() _elevations_lock = threading.Lock()
_root_applied: int | None = None _root_applied: int | None = None
def _track_elevation(user_id: int, level: int | None, until: datetime | None) -> None: def _track_elevation(key: _ElevationKey, level: int | None, until: datetime | None) -> None:
with _elevations_lock: with _elevations_lock:
if level is None or until is None: if level is None or until is None:
_elevations.pop(user_id, None) _elevations.pop(key, None)
else: else:
_elevations[user_id] = (level, until) _elevations[key] = (level, until)
active = bool(_elevations) active = bool(_elevations)
if active or _root_applied != _base_level: if active or _root_applied != _base_level:
_apply_root_level() _apply_root_level()
@@ -573,8 +598,8 @@ def _apply_root_level(force: bool = False) -> None:
# different answer would otherwise apply them in either order, and the older one could win — # different answer would otherwise apply them in either order, and the older one could win —
# the root back at the base while an elevation is still active. # the root back at the base while an elevation is still active.
with _elevations_lock: with _elevations_lock:
for user_id in [u for u, (_, until) in _elevations.items() if until <= now]: for key in [k for k, (_, until) in _elevations.items() if until <= now]:
del _elevations[user_id] del _elevations[key]
lowest = min([_base_level, *(lvl for lvl, _ in _elevations.values())]) lowest = min([_base_level, *(lvl for lvl, _ in _elevations.values())])
if force or lowest != _root_applied: if force or lowest != _root_applied:
logging.getLogger().setLevel(lowest) logging.getLogger().setLevel(lowest)
@@ -611,13 +636,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
@@ -644,6 +680,27 @@ _REQUEST_ID_RE = re.compile(r"^[A-Za-z0-9._-]{8,64}$")
# — this lowers the floor, it does not hide. # — this lowers the floor, it does not hide.
_QUIET_PREFIXES = ("/healthz", "/assets/", "/static/", "/api/admin/logs") _QUIET_PREFIXES = ("/healthz", "/assets/", "/static/", "/api/admin/logs")
# The reads the frontend POLLS (a `useLiveQuery`, the `me` heartbeat, the sync status, the subtitle
# progress), by route template, GET only — logged at DEBUG like the prefixes above. One open tab asked
# for the two unread counts, `/api/me` and the download index every 30 s, and at INFO those were ~95%
# of the stored log (measured on prod 2026-09-19): 11k lines a day per tab burying every line someone
# caused. The same route answered with a failure still logs at WARNING/ERROR.
_POLLED_ROUTES = frozenset(
{
"/api/me",
"/api/me/notifications/unread_count",
"/api/messages/unread_count",
"/api/downloads",
"/api/downloads/index",
"/api/downloads/usage",
"/api/sync/my-status",
"/api/admin/scheduler",
"/api/admin/downloads",
"/api/admin/downloads/storage",
"/api/plex/subtitle/{rating_key}/{sub_ord}/status",
}
)
# Not `siftlode.logs`: this is the replacement for uvicorn's ACCESS log, and `siftlode.http` is # 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 # 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. # happens to live in is not what the name is about.
@@ -689,7 +746,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:
@@ -715,6 +774,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
+22
View File
@@ -44,6 +44,7 @@ from app import logs
from app.config import settings from app.config import settings
from app.db import engine from app.db import engine
from app.models import LogEvent from app.models import LogEvent
from app.utils import iso
QUEUE_MAX = 10_000 QUEUE_MAX = 10_000
BATCH_MAX = 500 BATCH_MAX = 500
@@ -343,3 +344,24 @@ def gc(db: Session, retention_days: int) -> int:
cutoff = datetime.now(timezone.utc) - timedelta(days=max(1, retention_days)) cutoff = datetime.now(timezone.utc) - timedelta(days=max(1, retention_days))
result = db.execute(delete(LogEvent).where(LogEvent.ts < cutoff)) result = db.execute(delete(LogEvent).where(LogEvent.ts < cutoff))
return result.rowcount or 0 return result.rowcount or 0
def serialize(row: LogEvent) -> dict:
"""A stored row as the API sends it — one shape for every reader: the admin console (E22.6) and
a debug session's evidence list (E16)."""
return {
"id": row.id,
"ts": iso(row.ts),
"level": row.level,
"source": row.source,
"logger": row.logger,
"event": row.event,
"msg": row.msg,
"attrs": row.attrs,
"exc": row.exc,
"request_id": row.request_id,
"user_id": row.user_id,
"job_id": row.job_id,
"debug_session_id": row.debug_session_id,
"version": row.version,
}
+2
View File
@@ -37,6 +37,7 @@ from app.routes import (
auth as auth_routes, auth as auth_routes,
channels, channels,
config as config_routes, config as config_routes,
debug_sessions as debug_sessions_routes,
diagnostics as diagnostics_routes, diagnostics as diagnostics_routes,
discover as discover_routes, discover as discover_routes,
downloads, downloads,
@@ -273,6 +274,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)
+59 -1
View File
@@ -12,6 +12,7 @@ from sqlalchemy import (
Index, Index,
Integer, Integer,
JSON, JSON,
LargeBinary,
SmallInteger, SmallInteger,
String, String,
Text, Text,
@@ -241,6 +242,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 +566,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 +593,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)."""
+3 -1
View File
@@ -43,7 +43,9 @@ def rollup(cards: list[dict]) -> dict:
] ]
resume = None resume = None
if inprog: if inprog:
resume = inprog[-1] # continue the latest-started episode # The LAST in-progress episode in broadcast order — no timestamp is consulted. Home's
# `discover.queries.next_episode` picks the same one, so both Resume buttons agree.
resume = inprog[-1]
else: else:
resume = next((c for c in cards if c.get("status") != "watched"), None) resume = next((c for c in cards if c.get("status") != "watched"), None)
return { return {
+80 -42
View File
@@ -60,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.
@@ -72,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".
@@ -88,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:
@@ -102,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
@@ -637,29 +651,47 @@ def _kill(s: HlsSession) -> None:
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
# Somebody's playback stops here. The route turns the missing session into a retryable by the transcode cap, N distinct items were N ffmpegs and N film-sized scratch directories.
# 404 and the player restarts at the playhead, so from the server nothing failed — and
# "my film keeps rebuffering" has no other explanation anywhere. Somebody's playback stops at each drop. The route turns the missing session into a retryable 404
log.info( and the player restarts at the playhead, so from the server nothing failed — and "my film keeps
"a transcode session was dropped to stay under the cap", rebuffering" has no other explanation anywhere, hence the line."""
extra=logs.event("plex.stream.session_culled", key=key, cap=cap, sessions=len(tx)),
) def cull(pool: list[tuple[tuple[int, str], HlsSession]], cap: int, bound: str) -> None:
_kill(s) if len(pool) <= cap:
_sessions.pop(key, None) 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,
@@ -679,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
@@ -691,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)
@@ -717,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)
@@ -753,8 +786,9 @@ def start_session(
transcode=transcode, transcode=transcode,
hw=hw, hw=hw,
tonemap=tonemap, tonemap=tonemap,
user_id=user_id,
) )
_sessions[key] = s _sessions[reg] = s
# The one line that says which pipeline a session actually got. Everything downstream # 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 # — 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. # now it was only inferable from the ffmpeg command nobody keeps.
@@ -770,9 +804,13 @@ def start_session(
start_s=round(start_s, 1), start_s=round(start_s, 1),
), ),
) )
# Admin-configurable concurrency cap (Configuration → Plex → Max concurrent transcodes); # Admin-configurable caps (Configuration → Plex); at least 1 each so playback can't be
# at least 1 so playback can't be capped to zero. # capped to zero.
_enforce_cap(max(1, sysconfig.get_int(db, "plex_max_transcodes"))) _enforce_caps(
user_id,
max(1, sysconfig.get_int(db, "plex_max_transcodes")),
max(1, sysconfig.get_int(db, "plex_max_sessions")),
)
return s return s
s = _spawn(*_select_encode(db, item, transcode, src)) s = _spawn(*_select_encode(db, item, transcode, src))
@@ -798,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:
@@ -829,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
@@ -1060,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()
+67 -8
View File
@@ -461,11 +461,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).
@@ -515,6 +522,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()
@@ -598,7 +606,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``
@@ -641,8 +649,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]:
@@ -659,7 +682,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
@@ -692,8 +720,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
@@ -757,6 +792,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(
@@ -822,10 +859,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
@@ -846,6 +886,24 @@ 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}"))
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."""
with PlexClient(db) as plex: with PlexClient(db) as plex:
@@ -884,9 +942,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).
+1
View File
@@ -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"
+37
View File
@@ -0,0 +1,37 @@
"""A request the app refuses, said in a way the UI can put in the reader's language.
Every refusal the API answers carries a structured `detail` — `{code, reason, …params}` — and the client
translates it (`lib/api/core.ts` `localizeDetail`). A bare English string reached the Hungarian UI
verbatim: the blocking error dialog, and every inline surface that printed `HttpError.detail`, showed
whatever sentence the route happened to hand-write. `refused` is the generic family
(`errors.refused.<reason>`, the params interpolated into the sentence); the older specific codes
(`not_found`, `youtube`, `playlist`, the admin refusals, …) stay, since each is already translated.
A test (`test_http_details.py`) reads every `HTTPException` in the app and fails on a string detail,
and the frontend's contract test requires every `refused(…, "<reason>")` to be named in both
languages — so the rule is enforced, not described.
"""
from fastapi import HTTPException
def refused(status: int, reason: str, **params: object) -> HTTPException:
"""The refusal to raise: `raise refused(409, "view_name_taken", name=name)`. `params` travel in
the detail for the sentence to interpolate (`{{name}}`); keep them to values the reader may see."""
return HTTPException(status_code=status, detail={"code": "refused", "reason": reason, **params})
# `sysconfig.InvalidSetting.reason` → the refusal it answers as. A dict of literals, so the frontend's
# contract test can read every reason this can produce.
_SETTING_REASONS = {
"not_a_number": "setting_not_a_number",
"too_low": "setting_too_low",
"too_high": "setting_too_high",
"not_a_choice": "setting_not_a_choice",
"no_encryption_key": "no_encryption_key",
}
def setting_refused(exc) -> HTTPException:
"""A value the admin entered that `sysconfig.set_value` refused (`exc`: its `InvalidSetting`)."""
return refused(400, _SETTING_REASONS[exc.reason], key=exc.key, **exc.params)
+50 -3
View File
@@ -8,11 +8,14 @@ one existence-leak policy.
from typing import Any, TypeVar from typing import 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)
@@ -45,9 +48,53 @@ def owned_or_404(
) -> 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. 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)
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)
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
View File
@@ -39,6 +39,7 @@ from app.routes.auth import (
send_verification_link, send_verification_link,
) )
from app.utils import canonical_email, iso, valid_email from app.utils import canonical_email, iso, valid_email
from app.refusal import refused
router = APIRouter(prefix="/api/admin", tags=["admin"]) router = APIRouter(prefix="/api/admin", tags=["admin"])
@@ -169,7 +170,7 @@ def _activate_user_by_email(db: Session, email: str) -> None:
user = next((u for u in candidates if u.email == email), None) or next(iter(candidates), None) user = next((u for u in candidates if u.email == email), None) or next(iter(candidates), None)
if user is not None and not user.is_active: if user is not None and not user.is_active:
user.is_active = True user.is_active = True
db.commit() db.flush() # the handler commits (CLAUDE.md: helpers flush)
def _decide(db: Session, invite_id: int, admin: User, approved: bool) -> Invite: def _decide(db: Session, invite_id: int, admin: User, approved: bool) -> Invite:
@@ -179,7 +180,7 @@ def _decide(db: Session, invite_id: int, admin: User, approved: bool) -> Invite:
# sending a duplicate decision mail, even flipping an already-approved invite to "denied" and # sending a duplicate decision mail, even flipping an already-approved invite to "denied" and
# mailing a rejection to an already-active account. # mailing a rejection to an already-active account.
if inv.status != "pending": if inv.status != "pending":
raise HTTPException(status_code=409, detail="Invite already decided") raise refused(409, "invite_decided")
inv.status = "approved" if approved else "denied" inv.status = "approved" if approved else "denied"
inv.decided_at = datetime.now(timezone.utc) inv.decided_at = datetime.now(timezone.utc)
inv.decided_by = admin.email inv.decided_by = admin.email
@@ -191,7 +192,7 @@ def _decide(db: Session, invite_id: int, admin: User, approved: bool) -> Invite:
target_id=inv.id, target_id=inv.id,
summary=inv.email, summary=inv.email,
) )
db.commit() db.flush() # the handler commits, with the activation that goes with it
return inv return inv
@@ -210,6 +211,9 @@ def approve_invite(
link, has_account, hours = _welcome_mail_parts(db, inv.email) link, has_account, hours = _welcome_mail_parts(db, inv.email)
lang = mailtext.lang_for_email(db, inv.email) lang = mailtext.lang_for_email(db, inv.email)
background.add_task(email_mod.send_access_approved, inv.email, lang, link, has_account, hours) background.add_task(email_mod.send_access_approved, inv.email, lang, link, has_account, hours)
# Before the response, so the mail (a background task, after it) never describes a decision
# the database does not hold yet.
db.commit()
return _serialize(inv, has_account=bool(_accounts_for(db, [inv.email]))) return _serialize(inv, has_account=bool(_accounts_for(db, [inv.email])))
@@ -226,6 +230,7 @@ def deny_invite(
background.add_task( background.add_task(
email_mod.send_access_denied, inv.email, mailtext.lang_for_email(db, inv.email) email_mod.send_access_denied, inv.email, mailtext.lang_for_email(db, inv.email)
) )
db.commit()
return _serialize(inv, has_account=bool(_accounts_for(db, [inv.email]))) return _serialize(inv, has_account=bool(_accounts_for(db, [inv.email])))
@@ -242,7 +247,7 @@ def add_invite(
"""Manually whitelist an email (approved straight away). Convenience for the admin.""" """Manually whitelist an email (approved straight away). Convenience for the admin."""
email = body.email.strip().lower() email = body.email.strip().lower()
if not valid_email(email): if not valid_email(email):
raise HTTPException(status_code=400, detail="Enter a valid email address.") raise refused(400, "invalid_email")
# Reuse a canonically-equal row instead of adding a second one for the same mailbox. Prod carries # Reuse a canonically-equal row instead of adding a second one for the same mailbox. Prod carries
# exactly that pair for one person from 2026-06-16 (hartmann.peter1986@ / hartmannpeter1986@), # exactly that pair for one person from 2026-06-16 (hartmann.peter1986@ / hartmannpeter1986@),
# which is how an admin ends up approving the same human twice and still seeing them bounce. # which is how an admin ends up approving the same human twice and still seeing them bounce.
@@ -259,11 +264,13 @@ def add_invite(
inv.status = "approved" inv.status = "approved"
inv.decided_at = datetime.now(timezone.utc) inv.decided_at = datetime.now(timezone.utc)
inv.decided_by = admin.email inv.decided_by = admin.email
db.flush() # a new invite's id, for the audit row — before it, the row named no target
audit.record( audit.record(
db, admin.id, AuditAction.INVITE_ADD, target_type="invite", target_id=inv.id, summary=email db, admin.id, AuditAction.INVITE_ADD, target_type="invite", target_id=inv.id, summary=email
) )
db.commit() # One transaction: the whitelisting and the activation it implies land together or not at all.
_activate_user_by_email(db, email) _activate_user_by_email(db, email)
db.commit()
return _serialize(inv, has_account=bool(_accounts_for(db, [email]))) return _serialize(inv, has_account=bool(_accounts_for(db, [email])))
@@ -366,10 +373,7 @@ def delete_invite(
promoted to admin on its next Google sign-in). The admin has to hear that, not guess it.""" promoted to admin on its next Google sign-in). The admin has to hear that, not guess it."""
inv = get_or_404(db, Invite, invite_id, what="invite") inv = get_or_404(db, Invite, invite_id, what="invite")
if _accounts_for(db, [inv.email]): if _accounts_for(db, [inv.email]):
raise HTTPException( raise refused(409, "address_has_account")
status_code=409,
detail="That address already has an account — delete the account instead.",
)
still_allowed_by_env = _env_allowed(inv.email) still_allowed_by_env = _env_allowed(inv.email)
audit.record( audit.record(
db, db,
@@ -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}
+3 -21
View File
@@ -26,10 +26,11 @@ from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import Text, and_, cast, func, or_, select from sqlalchemy import Text, and_, cast, func, or_, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app import logsink
from app.auth import admin_user from app.auth import admin_user
from app.db import get_db from app.db import get_db
from app.models import LogEvent, User from app.models import LogEvent, User
from app.utils import iso, like_escape from app.utils import like_escape
router = APIRouter(prefix="/api/admin/logs", tags=["admin"]) router = APIRouter(prefix="/api/admin/logs", tags=["admin"])
@@ -66,25 +67,6 @@ def _decode_cursor(cursor: str) -> tuple[datetime, int]:
raise HTTPException(status_code=400, detail={"code": "bad_cursor"}) from exc raise HTTPException(status_code=400, detail={"code": "bad_cursor"}) from exc
def _serialize(row: LogEvent) -> dict:
return {
"id": row.id,
"ts": iso(row.ts),
"level": row.level,
"source": row.source,
"logger": row.logger,
"event": row.event,
"msg": row.msg,
"attrs": row.attrs,
"exc": row.exc,
"request_id": row.request_id,
"user_id": row.user_id,
"job_id": row.job_id,
"debug_session_id": row.debug_session_id,
"version": row.version,
}
def _accounts(db: Session, user_ids: set[int]) -> dict[str, str]: def _accounts(db: Session, user_ids: set[int]) -> dict[str, str]:
"""The accounts a page names, by id → address. An id with no account left (erased after the """The accounts a page names, by id → address. An id with no account left (erased after the
line was written) is simply absent, and the client shows the bare id.""" line was written) is simply absent, and the client shows the bare id."""
@@ -196,7 +178,7 @@ def list_logs(
more = len(rows) > limit more = len(rows) > limit
rows = rows[:limit] rows = rows[:limit]
return { return {
"items": [_serialize(r) for r in rows], "items": [logsink.serialize(r) for r in rows],
"accounts": _accounts(db, {r.user_id for r in rows if r.user_id is not None}), "accounts": _accounts(db, {r.user_id for r in rows if r.user_id is not None}),
"next_cursor": _encode_cursor(rows[-1]) if more else None, "next_cursor": _encode_cursor(rows[-1]) if more else None,
} }
+1 -1
View File
@@ -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}
+20 -25
View File
@@ -43,6 +43,7 @@ from app.models import AuthToken, DemoWhitelist, Invite, OAuthToken, User
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
@@ -378,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()
@@ -653,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")
@@ -672,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)
@@ -683,8 +684,8 @@ def demo_login(body: EmailIn, request: Request, db: Session = Depends(get_db)) -
_remember_epoch(request, demo) _remember_epoch(request, demo)
# The demo key IS an address, so it is not in the line — see the denied branch above. # The demo key IS an address, so it is not in the line — see the denied branch above.
log.info("signed in", extra=logs.event("auth.login.ok", method="demo", uid=demo.id)) log.info("signed in", extra=logs.event("auth.login.ok", method="demo", uid=demo.id))
return {"authenticated": True} return {"ok": True, "authenticated": True}
return {"authenticated": False} return {"ok": True, "authenticated": False}
def _establish_session(request: Request, user: User) -> None: def _establish_session(request: Request, user: User) -> None:
@@ -815,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:
@@ -997,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:
@@ -1091,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:
@@ -1293,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
+19 -24
View File
@@ -4,7 +4,7 @@ hidden, tags) and per-channel sync state. Local reads/writes plus YouTube-side w
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
@@ -25,12 +25,13 @@ 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
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"])
@@ -383,12 +384,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.
@@ -424,8 +422,9 @@ def explore_channel(
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"],
@@ -507,6 +506,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,
@@ -541,7 +541,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")
@@ -562,7 +562,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,
@@ -583,9 +583,7 @@ def subscribe(
if "subscriptionDuplicate" in exc.reason.split(","): 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
@@ -622,7 +620,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")
@@ -636,10 +634,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),
@@ -649,10 +644,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")
@@ -693,7 +688,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")
@@ -708,7 +703,7 @@ def unblock_channel(
) )
) )
db.commit() db.commit()
return {"unblocked": channel_id} return {"ok": True, "unblocked": channel_id}
class AttachTagIn(BaseModel): class AttachTagIn(BaseModel):
@@ -743,7 +738,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}")
@@ -763,4 +758,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}
+27 -13
View File
@@ -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
+412
View File
@@ -0,0 +1,412 @@
"""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
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) -> bytes:
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}
+26 -19
View File
@@ -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
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,18 +73,6 @@ 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:
"""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: 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,20 @@ 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) 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 +117,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 +129,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 +147,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
] ]
+12 -17
View File
@@ -11,9 +11,9 @@ thing it exists to remove.
from typing import Literal from typing import Literal
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy import select, update from sqlalchemy import select
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -23,7 +23,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}
+43 -47
View File
@@ -38,10 +38,11 @@ from app.models import (
MediaAsset, MediaAsset,
User, User,
) )
from app.routes._common import get_or_404, not_found from app.routes._common import 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 +64,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 +94,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 +167,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 +177,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 +187,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 +234,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 +262,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 +283,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 +313,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 +335,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 +356,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 +367,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
@@ -484,7 +478,7 @@ 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 = (body.display_name or "").strip() or None
try: try:
@@ -614,7 +608,7 @@ 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 = ((body.display_name or "").strip())[:255] or None
@@ -668,9 +662,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)
@@ -699,7 +693,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(
@@ -708,7 +702,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
@@ -722,10 +716,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
@@ -764,7 +758,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}
@@ -785,10 +779,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")
@@ -799,10 +793,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")
@@ -820,12 +814,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()
@@ -834,7 +828,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
@@ -853,7 +847,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}")
@@ -863,7 +857,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()
@@ -895,7 +891,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) ------------------------------
@@ -925,10 +921,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
@@ -944,7 +940,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)
@@ -975,9 +971,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,
+13 -12
View File
@@ -1,6 +1,6 @@
from datetime import date, datetime, timezone from datetime import date, datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy import and_, func, or_, select from sqlalchemy import and_, func, or_, select
from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.dialects.postgresql import insert as pg_insert
@@ -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)
@@ -260,7 +261,7 @@ def set_video_state(
# or moved this row. The state the caller asked for is the state that holds either way. # 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
@@ -281,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")
@@ -303,7 +304,7 @@ def clear_video_state(
# silent-ok: the row went under the delete, which is the outcome the delete wanted. # 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):
@@ -361,7 +362,7 @@ def set_video_progress(
# silent-ok: exactly the race the comment above describes; the response stays true. # 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)
@@ -369,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}")
@@ -402,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 (
@@ -411,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
View File
@@ -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):
+17 -15
View File
@@ -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()
+4 -10
View File
@@ -5,7 +5,7 @@ All endpoints use `current_user` (not `require_human`): notifications and their
state are personal UI state with no quota or YouTube involvement, so the shared demo account state are personal UI state with no quota or YouTube involvement, so the shared demo account
manages its own inbox normally.""" manages its own inbox normally."""
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, Query
from sqlalchemy import func, select, update from sqlalchemy import func, select, update
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -14,6 +14,7 @@ from app.db import get_db
from app.models import Notification, User from app.models import Notification, User
from app.notifications import trim_read from app.notifications import trim_read
from app.utils import iso from app.utils import iso
from app.routes._common import owned_or_404
router = APIRouter(prefix="/api/me/notifications", tags=["notifications"]) router = APIRouter(prefix="/api/me/notifications", tags=["notifications"])
@@ -72,20 +73,13 @@ def unread_count(user: User = Depends(current_user), db: Session = Depends(get_d
return {"count": count or 0} return {"count": count or 0}
def _owned(db: Session, notification_id: int, user: User) -> Notification:
n = db.get(Notification, notification_id)
if n is None or n.user_id != user.id:
raise HTTPException(status_code=404, detail="Notification not found")
return n
@router.post("/{notification_id}/read") @router.post("/{notification_id}/read")
def mark_read( def mark_read(
notification_id: int, notification_id: int,
user: User = Depends(current_user), user: User = Depends(current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> dict:
n = _owned(db, notification_id, user) n = owned_or_404(db, Notification, notification_id, user, what="notification")
n.read = True n.read = True
db.commit() db.commit()
trim_read(db, user.id) trim_read(db, user.id)
@@ -110,7 +104,7 @@ def dismiss(
user: User = Depends(current_user), user: User = Depends(current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> dict:
n = _owned(db, notification_id, user) n = owned_or_404(db, Notification, notification_id, user, what="notification")
# Dismiss implies read (it no longer counts toward the unread badge). # Dismiss implies read (it no longer counts toward the unread badge).
n.dismissed = True n.dismissed = True
n.read = True n.read = True
+68 -48
View File
@@ -3,11 +3,10 @@ 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
from sqlalchemy import func, select, and_ from sqlalchemy import func, select, and_
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session, aliased from sqlalchemy.orm import Session, aliased
from app import quota from app import quota
@@ -23,7 +22,7 @@ from app.sync.playlists import (
sync_user_playlists, sync_user_playlists,
) )
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, 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 +47,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 +59,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:
@@ -108,7 +106,7 @@ 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
@@ -126,7 +124,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 +278,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 +293,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 +308,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")
@@ -322,8 +322,12 @@ def sync_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) _check_pull_cooldown(user)
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PULL): try:
return sync_user_playlists(db, user) with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PULL):
return {"ok": True, **sync_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,7 +339,7 @@ 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):
@@ -343,16 +347,16 @@ def revert_youtube(
_check_pull_cooldown(user) _check_pull_cooldown(user)
try: try:
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PULL): with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PULL):
return repull_playlist(db, user, pl) return {"ok": True, **repull_playlist(db, user, pl)}
except YouTubeError: except YouTubeError as exc:
db.rollback() db.rollback()
raise _refused(422, "youtube_failed") 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 +375,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
@@ -403,10 +407,10 @@ def push(
plan = plan_merge(db, user, pl, live=live) plan = plan_merge(db, user, pl, live=live)
if plan["units_estimate"] > quota.remaining_today(db): if plan["units_estimate"] > quota.remaining_today(db):
raise _refused(429, "quota_exceeded") raise _refused(429, "quota_exceeded")
return merge_playlist(db, user, pl, live=live) return {"ok": True, **merge_playlist(db, user, pl, live=live)}
except YouTubeError: except YouTubeError as exc:
db.rollback() db.rollback()
raise _refused(422, "youtube_failed") raise youtube_refusal(exc)
@router.get("/{playlist_id}") @router.get("/{playlist_id}")
@@ -415,7 +419,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))
@@ -447,7 +451,7 @@ 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) pl = owned_or_404(db, Playlist, playlist_id, user, what="playlist")
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,7 +488,7 @@ 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: if on_youtube:
@@ -496,8 +500,8 @@ def delete_playlist(
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_DELETE): with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_DELETE):
with YouTubeClient(db, user) as yt: with YouTubeClient(db, user) as yt:
yt.delete_playlist(pl.yt_playlist_id) yt.delete_playlist(pl.yt_playlist_id)
except YouTubeError: except YouTubeError as exc:
raise _refused(422, "youtube_failed") raise youtube_refusal(exc)
db.delete(pl) # items cascade db.delete(pl) # items cascade
db.commit() db.commit()
return {"deleted": playlist_id} return {"deleted": playlist_id}
@@ -510,11 +514,12 @@ def 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(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) _add_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}
@router.delete("/{playlist_id}/items/{video_id}") @router.delete("/{playlist_id}/items/{video_id}")
@@ -524,9 +529,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 +554,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")
@@ -571,19 +577,33 @@ 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
item.playlist_id = dst.id # check above is not a lock: a second tab's add or the scheduler's YouTube merge can insert
item.position = _next_item_position(db, dst.id) # the same (playlist, video) in between, and uq_playlist_video then refuses the re-parent.
# Both sides changed, so both get the dirty recompute and the timestamp — the asymmetric # That is the already-present outcome arriving late, not a failure — answer it the same way
# version of this (touching only the source) is the recurring smell CLAUDE.md warns about. # instead of a raw 500.
try:
with db.begin_nested():
item.playlist_id = dst.id
item.position = _next_item_position(db, dst.id)
db.flush()
dst_changed = True
# silent-ok: the conflict IS the answer — the target already holds the video, which is the
# idempotent outcome; the savepoint put the row back in the source list, dropped below.
except IntegrityError:
pass
if not dst_changed:
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 +619,7 @@ 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) pl = owned_or_404(db, Playlist, playlist_id, user, what="playlist")
order = body.video_ids order = body.video_ids
items = { items = {
it.video_id: it it.video_id: it
@@ -625,4 +645,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}
+2 -2
View File
@@ -1,11 +1,11 @@
"""Shared helpers, serializers and constants for the plex route package.""" """Shared helpers, serializers and constants for the plex route package."""
from fastapi import HTTPException
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app import logs 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 = logs.get_logger(__name__) log = logs.get_logger(__name__)
@@ -13,7 +13,7 @@ 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
+18 -10
View File
@@ -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}
+21 -20
View File
@@ -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 (
@@ -26,6 +27,10 @@ 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, 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.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).
@@ -234,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)
@@ -325,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
@@ -362,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):
@@ -389,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()
@@ -399,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:
@@ -473,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
@@ -502,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}
@@ -521,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)
+3 -2
View File
@@ -5,7 +5,7 @@ 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
@@ -30,6 +30,7 @@ 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__) log = logs.get_logger(__name__)
@@ -109,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)
+1 -1
View File
@@ -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)
+92 -56
View File
@@ -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],
+16 -19
View File
@@ -2,7 +2,7 @@
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 delete, func, select, update from sqlalchemy import delete, func, select, update
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -19,15 +19,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 +117,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 +137,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 +180,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 +200,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 +217,10 @@ 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) pl = owned_or_404(db, PlexPlaylist, pid, user, what="playlist")
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 +249,7 @@ 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) pl = owned_or_404(db, PlexPlaylist, pid, user, what="playlist")
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(
@@ -297,7 +294,7 @@ def playlist_add_bulk(
) -> 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. Returns how many were added + the new total."""
pl = _own_playlist_or_404(db, pid, user) pl = owned_or_404(db, PlexPlaylist, pid, user, what="playlist")
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 +325,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 +337,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")
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 +356,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 +367,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")
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
+15 -11
View File
@@ -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")
+9 -12
View File
@@ -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}
+11 -12
View File
@@ -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("")
+17 -18
View File
@@ -16,7 +16,7 @@ dropped from the search response up front)."""
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
@@ -44,6 +44,8 @@ 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 = logs.get_logger(__name__) log = logs.get_logger(__name__)
router = APIRouter(prefix="/api/search", tags=["search"]) router = APIRouter(prefix="/api/search", tags=["search"])
@@ -258,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).
@@ -275,20 +277,14 @@ def search_youtube(
"a live search was refused: the per-user daily cap is spent", "a live search was refused: the per-user daily cap is spent",
extra=logs.event("search.refused", reason="daily_cap", limit=daily_limit), extra=logs.event("search.refused", reason="daily_cap", limit=daily_limit),
) )
raise HTTPException( raise refused(429, "search_daily_limit", limit=daily_limit)
status_code=429,
detail=f"You've reached today's YouTube search limit ({daily_limit}). Try again tomorrow.",
)
# 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):
log.info( log.info(
"a live search was refused: the shared YouTube budget is spent", "a live search was refused: the shared YouTube budget is spent",
extra=logs.event("search.refused", reason="shared_quota", cost=SEARCH_COST), extra=logs.event("search.refused", reason="shared_quota", cost=SEARCH_COST),
) )
raise HTTPException( raise refused(429, "quota_spent")
status_code=429,
detail="The shared YouTube quota for today is used up. Try again tomorrow.",
)
# 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(
@@ -313,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 = [
@@ -357,9 +358,7 @@ def search_youtube(
) )
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:
@@ -412,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))
) )
@@ -432,4 +431,4 @@ def clear_search(
) )
).rowcount ).rowcount
db.commit() db.commit()
return {"removed": removed} return {"ok": True, "removed": removed}
+14 -14
View File
@@ -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
View File
@@ -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
View File
@@ -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}
+8 -4
View File
@@ -9,7 +9,7 @@ from typing import Callable
from apscheduler.schedulers.background import BackgroundScheduler from apscheduler.schedulers.background import BackgroundScheduler
from sqlalchemy import select from sqlalchemy import select
from app import audit, logs, logsink, progress, quota from app import audit, debugsession, logs, logsink, progress, quota
from app.audit import AuditAction from app.audit import AuditAction
from app.config import settings from app.config import settings
from app.db import SessionLocal from app.db import SessionLocal
@@ -208,7 +208,7 @@ def _job(name: str, fn) -> None:
_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()
log.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:
@@ -386,9 +386,13 @@ def _log_gc_job() -> None:
def work(db): def work(db):
from app import sysconfig from app import sysconfig
reaped = logsink.gc(db, sysconfig.get_int(db, "log_retention_days")) days = sysconfig.get_int(db, "log_retention_days")
reaped = logsink.gc(db, days)
# Debug sessions (E16) go with the rows they bundle; an open one past its cap is closed.
closed = debugsession.sweep(db)
sessions = debugsession.gc(db, days)
db.commit() db.commit()
return {"reaped": reaped} return {"reaped": reaped, "sessions_closed": closed, "sessions_reaped": sessions}
_job("log_gc", work) _job("log_gc", work)
+11 -3
View File
@@ -670,11 +670,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}
+126 -5
View File
@@ -9,8 +9,9 @@ import logging
import threading import threading
from collections import Counter from collections import Counter
from concurrent.futures import ThreadPoolExecutor, as_completed from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime, timedelta, timezone
from sqlalchemy import select from sqlalchemy import func, or_, select, update
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app import logs, progress, quota, sysconfig from app import logs, progress, quota, sysconfig
@@ -25,11 +26,13 @@ from app.sync.videos import (
backfill_channel_deep, backfill_channel_deep,
backfill_channel_recent, backfill_channel_recent,
enrich_pending, enrich_pending,
insert_stubs,
reconcile_full_history, reconcile_full_history,
refresh_live, refresh_live,
run_shorts_classification, run_shorts_classification,
stub_from_playlist_item,
) )
from app.youtube.client import YouTubeClient from app.youtube.client import YouTubeClient, YouTubeError, is_quota_refusal
from app.youtube.rss import RssError, fetch_channel_feed from app.youtube.rss import RssError, fetch_channel_feed
# RSS feeds are free and network-bound, so fetch many at once; DB writes stay single-threaded. # RSS feeds are free and network-bound, so fetch many at once; DB writes stay single-threaded.
@@ -85,6 +88,9 @@ def run_rss_poll(db: Session, channels: list[Channel] | None = None) -> dict[str
# of 2026-09-16 into 11k warnings in four hours; each channel's own line is DEBUG now. # of 2026-09-16 into 11k warnings in four hours; each channel's own line is DEBUG now.
reasons: Counter[str] = Counter() reasons: Counter[str] = Counter()
examples: list[str] = [] examples: list[str] = []
# Channels whose FEED would not answer (not those whose rows failed to apply: the API would
# hand the same rows to the same broken write) — the feed fallback's candidates.
unread: list[str] = []
def fail(reason: str, detail: str, *, unexpected: str | None = None) -> None: def fail(reason: str, detail: str, *, unexpected: str | None = None) -> None:
"""Count a channel that did not get polled. `unexpected` is the message for a failure that """Count a channel that did not get polled. `unexpected` is the message for a failure that
@@ -120,6 +126,7 @@ def run_rss_poll(db: Session, channels: list[Channel] | None = None) -> dict[str
except RssError as exc: except RssError as exc:
log.debug("RSS poll skipped for channel %s: %s", channel.id, exc) log.debug("RSS poll skipped for channel %s: %s", channel.id, exc)
fail(exc.reason, f"{channel.id}: {exc}") fail(exc.reason, f"{channel.id}: {exc}")
unread.append(channel.id)
# silent-ok: `fail(unexpected=…)` is the report — it logs at ERROR with the # silent-ok: `fail(unexpected=…)` is the report — it logs at ERROR with the
# traceback, once per reason per poll, which is the flood control this loop needs. # traceback, once per reason per poll, which is the flood control this loop needs.
except Exception as exc: # noqa: BLE001 — one channel must not stop the poll except Exception as exc: # noqa: BLE001 — one channel must not stop the poll
@@ -143,17 +150,24 @@ def run_rss_poll(db: Session, channels: list[Channel] | None = None) -> dict[str
# One place that advances the counter, whatever happened above. # One place that advances the counter, whatever happened above.
done += 1 done += 1
progress.report(done, total, "rss_poll") progress.report(done, total, "rss_poll")
fallback = run_feed_fallback(db, unread)
if failed: if failed:
# The fallback's count rides on this line because this is the one the stored log keeps (an
# INFO line outside a request never reaches `log_events`): the console should show whether
# the uploads came in another way.
log.warning( log.warning(
"RSS poll: %d/%d channels could not be polled", "RSS poll: %d/%d channels could not be polled; %d read through the API instead",
failed, failed,
total, total,
fallback["read"],
extra=logs.event( extra=logs.event(
"sync.rss.failed", "sync.rss.failed",
failed=failed, failed=failed,
total=total, total=total,
reasons=dict(reasons), reasons=dict(reasons),
examples=examples, examples=examples,
fallback_read=fallback["read"],
fallback_new=fallback["new"],
), ),
) )
if total and failed == total: if total and failed == total:
@@ -164,8 +178,115 @@ def run_rss_poll(db: Session, channels: list[Channel] | None = None) -> dict[str
# subscriptions "all channels failed" is also what ONE dead feed id looks like, and a red # subscriptions "all channels failed" is also what ONE dead feed id looks like, and a red
# card the user can't act on is its own kind of dishonest. `channel: HTTP 404` says # card the user can't act on is its own kind of dishonest. `channel: HTTP 404` says
# "fix this subscription"; `Connection refused` says "the egress is down". # "fix this subscription"; `Connection refused` says "the egress is down".
raise RuntimeError(f"RSS poll failed for all {total} channels — first: {first_error}") # The fallback's count rides along: a red card during an RSS outage should still say
return {"new": new, "failed": failed} # whether the uploads are arriving another way.
raise RuntimeError(
f"RSS poll failed for all {total} channels — first: {first_error}"
f" (read through the API instead: {fallback['read']})"
)
return {"new": new + fallback["new"], "failed": failed, "fallback": fallback["read"]}
def run_feed_fallback(db: Session, channel_ids: list[str]) -> dict[str, int]:
"""Read the newest uploads through the Data API for the channels among `channel_ids` whose feed
has not answered for `rss_fallback_hours` — and that were not read this way within as long.
YouTube's RSS goes dark per channel for hours at a time (2026-09-18/19: 393 of 396 feeds answered
404 for four hours, and a retry of the same channel gets the same 404), and nothing else reads a
followed channel's new uploads: recent backfill runs once per channel, ever. One page of the
uploads playlist is 1 unit, so a whole day of total outage costs 12 units per channel at the
default 2 hours; it yields to the backfill reserve like every other background read. The clock is
`last_rss_at` or `feed_fallback_at`, whichever is later, so a channel is read at most once per
window whether its feed came back in between or not. A read the API REFUSED stamps the clock
too: a terminated channel's uploads playlist answers 404 for good, and without the stamp it
would be charged again on every 20-minute poll instead of once per window. A failure that is not
about the channel (no connection, no token, the quota spent) stamps nothing and ends the round."""
hours = sysconfig.get_int(db, "rss_fallback_hours")
if hours <= 0 or not channel_ids:
return {"read": 0, "new": 0, "failed": 0}
now = datetime.now(timezone.utc)
last_read = func.greatest(Channel.last_rss_at, Channel.feed_fallback_at)
due = (
db.execute(
select(Channel)
.where(
Channel.id.in_(channel_ids),
Channel.uploads_playlist_id.is_not(None),
or_(last_read.is_(None), last_read < now - timedelta(hours=hours)),
)
.order_by(Channel.id)
)
.scalars()
.all()
)
user = get_service_user(db) if due else None
if user is None:
return {"read": 0, "new": 0, "failed": 0}
read = new = failed = 0
with (
quota.attribute(None, quota.QuotaAction.VIDEOS_FEED_FALLBACK),
YouTubeClient(db, user) as yt,
):
for channel in due:
if quota.backfill_reserve_reached():
break
try:
data = yt.get_playlist_items(channel.uploads_playlist_id, None)
# silent-ok: counted, and the one summary line below names the count — the client
# already logged the call's own failure (`youtube.api.error`).
except YouTubeError as exc:
failed += 1
# Not an answer about THIS channel — no connection, no token, no quota: every
# further channel would fail the same way, and none of them has used its window.
if exc.path != "playlistItems" or is_quota_refusal(exc):
break
_stamp_fallback_attempt(db, channel.id)
continue
rows = [
s
for s in (stub_from_playlist_item(it, channel.id) for it in data.get("items", []))
if s is not None
]
try:
new += insert_stubs(db, rows)
channel.feed_fallback_at = datetime.now(timezone.utc)
db.add(channel)
db.commit()
except Exception: # noqa: BLE001 — one channel must not stop the rest
db.rollback()
log.exception(
"Feed fallback could not store channel %s",
channel.id,
extra=logs.event("sync.rss.fallback_failed"),
)
failed += 1
_stamp_fallback_attempt(db, channel.id)
continue
read += 1
log.info(
"Feed fallback: read %d of %d channels through the API, %d new videos",
read,
len(due),
new,
extra=logs.event("sync.rss.fallback", read=read, due=len(due), new=new, failed=failed),
)
return {"read": read, "new": new, "failed": failed}
def _stamp_fallback_attempt(db: Session, channel_id: str) -> None:
"""Start a channel's fallback window after a read that did not land, so it waits the window out
like a read that did. Its own commit: the failure already rolled the channel's work back."""
try:
db.execute(
update(Channel)
.where(Channel.id == channel_id)
.values(feed_fallback_at=datetime.now(timezone.utc))
)
db.commit()
# silent-ok: the failure that brought us here was already logged and counted; an unstamped
# channel is only read again at the next poll.
except Exception: # noqa: BLE001
db.rollback()
def run_enrich(db: Session, max_batches: int = 200, floor: int = 200) -> int: def run_enrich(db: Session, max_batches: int = 200, floor: int = 200) -> int:
+27 -6
View File
@@ -91,6 +91,7 @@ SPECS: tuple[ConfigSpec, ...] = (
ConfigSpec( ConfigSpec(
"backfill_recent_max_days", "int", "backfill", "backfill_recent_max_days", min=1, max=36_500 "backfill_recent_max_days", "int", "backfill", "backfill_recent_max_days", min=1, max=36_500
), ),
ConfigSpec("rss_fallback_hours", "int", "backfill", "rss_fallback_hours", min=0, max=168),
# --- Shorts probe --- # --- Shorts probe ---
ConfigSpec( ConfigSpec(
"shorts_probe_max_seconds", "int", "shorts", "shorts_probe_max_seconds", min=1, max=3_600 "shorts_probe_max_seconds", "int", "shorts", "shorts_probe_max_seconds", min=1, max=3_600
@@ -175,6 +176,7 @@ SPECS: tuple[ConfigSpec, ...] = (
ConfigSpec("plex_overlay_map", "str", "plex", "plex_overlay_map"), ConfigSpec("plex_overlay_map", "str", "plex", "plex_overlay_map"),
# (plex_sync_interval_min is tuned from the Scheduler dashboard, not here — it's a scheduler job.) # (plex_sync_interval_min is tuned from the Scheduler dashboard, not here — it's a scheduler job.)
ConfigSpec("plex_max_transcodes", "int", "plex", "plex_max_transcodes", min=0, max=16), ConfigSpec("plex_max_transcodes", "int", "plex", "plex_max_transcodes", min=0, max=16),
ConfigSpec("plex_max_sessions", "int", "plex", "plex_max_sessions", min=1, max=64),
# Transcode re-encode tuning (E6 S1). The libx264 knobs apply only to the software fallback. # Transcode re-encode tuning (E6 S1). The libx264 knobs apply only to the software fallback.
# crf 0–51 (x264 range); max_height 0 = source. # crf 0–51 (x264 range); max_height 0 = source.
ConfigSpec("plex_transcode_hw", "choice", "plex", "plex_transcode_hw"), ConfigSpec("plex_transcode_hw", "choice", "plex", "plex_transcode_hw"),
@@ -190,6 +192,10 @@ SPECS: tuple[ConfigSpec, ...] = (
# --- TMDB enrichment (E5 S2; optional) — the API key is the only secret --- # --- TMDB enrichment (E5 S2; optional) — the API key is the only secret ---
ConfigSpec("tmdb_api_key", "str", "tmdb", "tmdb_api_key", secret=True), ConfigSpec("tmdb_api_key", "str", "tmdb", "tmdb_api_key", secret=True),
ConfigSpec("tmdb_language", "str", "tmdb", "tmdb_language"), ConfigSpec("tmdb_language", "str", "tmdb", "tmdb_language"),
# --- Bug reports (E16): the Gitea issue tracker a debug session's issue goes to ---
ConfigSpec("issues_gitea_url", "str", "issues", "issues_gitea_url"),
ConfigSpec("issues_gitea_repo", "str", "issues", "issues_gitea_repo"),
ConfigSpec("issues_gitea_token", "str", "issues", "issues_gitea_token", secret=True),
# (tmdb_enrich_interval_min is a scheduler job, tuned from the Scheduler dashboard; tmdb_enrich_batch # (tmdb_enrich_interval_min is a scheduler job, tuned from the Scheduler dashboard; tmdb_enrich_batch
# is a config.py default — both stay out of SPECS, like plex_sync_interval_min.) # is a config.py default — both stay out of SPECS, like plex_sync_interval_min.)
) )
@@ -210,11 +216,23 @@ def _default(s: ConfigSpec):
return getattr(settings, s.default_attr) return getattr(settings, s.default_attr)
class InvalidSetting(ValueError):
"""A value `set_value` refuses, as a reason the UI translates (`errors.refused.setting_<reason>`)
plus what the sentence needs — the English message is for logs only. A ValueError still, so a
caller that only needs "refused or not" keeps working."""
def __init__(self, key: str, reason: str, **params: object):
super().__init__(f"{key}: {reason} {params}")
self.key = key
self.reason = reason
self.params = params
def _choice(s: ConfigSpec, raw: object) -> str: def _choice(s: ConfigSpec, raw: object) -> str:
"""The canonical spelling of `raw` among the key's choices (any letter case), or ValueError.""" """The canonical spelling of `raw` among the key's choices (any letter case), or InvalidSetting."""
option = canonical_choice(s.choices, raw) option = canonical_choice(s.choices, raw)
if option is None: if option is None:
raise ValueError(f"{s.key} must be one of: {', '.join(s.choices)}") raise InvalidSetting(s.key, "not_a_choice", choices=", ".join(s.choices))
return option return option
@@ -304,11 +322,14 @@ def set_value(db: Session, key: str, value) -> None:
(B-core-10). The staged value is visible to a same-session `get()` before the commit.""" (B-core-10). The staged value is visible to a same-session `get()` before the commit."""
s = _BY_KEY[key] s = _BY_KEY[key]
if s.type == "int": if s.type == "int":
v = int(value) try:
v = int(value)
except (TypeError, ValueError):
raise InvalidSetting(key, "not_a_number") from None
if s.min is not None and v < s.min: if s.min is not None and v < s.min:
raise ValueError(f"{key} must be >= {s.min}") raise InvalidSetting(key, "too_low", min=s.min)
if s.max is not None and v > s.max: if s.max is not None and v > s.max:
raise ValueError(f"{key} must be <= {s.max}") raise InvalidSetting(key, "too_high", max=s.max)
raw = str(v) raw = str(v)
elif s.type == "bool": elif s.type == "bool":
raw = "true" if value else "false" raw = "true" if value else "false"
@@ -320,7 +341,7 @@ def set_value(db: Session, key: str, value) -> None:
stored, encrypted = raw, False stored, encrypted = raw, False
if s.secret: if s.secret:
if not secrets_manageable(): if not secrets_manageable():
raise RuntimeError("TOKEN_ENCRYPTION_KEY is not configured") raise InvalidSetting(key, "no_encryption_key")
stored, encrypted = security.encrypt(raw) or "", True stored, encrypted = security.encrypt(raw) or "", True
row = db.get(SystemConfig, key) row = db.get(SystemConfig, key)
+95 -9
View File
@@ -6,8 +6,10 @@ so they don't depend on a specific user's token; subscriptions.list?mine=true al
uses OAuth. uses OAuth.
""" """
import logging
from collections.abc import Iterator from collections.abc import Iterator
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import Literal
import httpx import httpx
@@ -25,11 +27,15 @@ class YouTubeError(Exception):
`reason` is the API's own error reason code (`quotaExceeded`, `subscriptionDuplicate`, `reason` is the API's own error reason code (`quotaExceeded`, `subscriptionDuplicate`,
`videoNotFound`, …) when the body carried one. Branch on THAT, never on the message: the `videoNotFound`, …) when the body carried one. Branch on THAT, never on the message: the
message is prose, it changes under us, and it is deliberately the poorer of the two here.""" message is prose, it changes under us, and it is deliberately the poorer of the two here.
def __init__(self, message: str, *, reason: str = ""): `path` is the API endpoint that ANSWERED with the failure (`playlistItems`); empty when no
endpoint did — the request never got through (`transport`) or the token step refused first."""
def __init__(self, message: str, *, reason: str = "", path: str = ""):
super().__init__(message) super().__init__(message)
self.reason = reason self.reason = reason
self.path = path
def _error_reason(resp: httpx.Response) -> str: def _error_reason(resp: httpx.Response) -> str:
@@ -50,7 +56,9 @@ def _error_reason(resp: httpx.Response) -> str:
return reasons or "<none>" return reasons or "<none>"
def _upstream_failure(method: str, path: str, resp: httpx.Response) -> YouTubeError: def _upstream_failure(
method: str, path: str, resp: httpx.Response, expected: frozenset[str] = frozenset()
) -> YouTubeError:
"""Report one upstream failure and build the error to raise. Every YouTube response that is """Report one upstream failure and build the error to raise. Every YouTube response that is
not a success goes through here — the Data API's reads, writes and deletes and the OAuth not a success goes through here — the Data API's reads, writes and deletes and the OAuth
token refresh — so what may appear in a log line cannot drift apart between them, and the token refresh — so what may appear in a log line cannot drift apart between them, and the
@@ -59,7 +67,11 @@ def _upstream_failure(method: str, path: str, resp: httpx.Response) -> YouTubeEr
response at all is `_send`'s, with the reason `transport`.) The body is logged only under response at all is `_send`'s, with the reason `transport`.) The body is logged only under
the requesting user's own Teljes consent: it is the request quoted back.""" the requesting user's own Teljes consent: it is the request quoted back."""
reason = _error_reason(resp) reason = _error_reason(resp)
log.warning( # A reason the caller named as an answer rather than a failure (`channelNotFound` on a `mine`
# read: the account simply has no channel) is still raised, but it is not a warning — it came
# back every six hours for an account that will never have one.
log.log(
logging.DEBUG if reason in expected else logging.WARNING,
"YouTube %s %s -> %s (%s)", "YouTube %s %s -> %s (%s)",
method, method,
path, path,
@@ -80,7 +92,45 @@ def _upstream_failure(method: str, path: str, resp: httpx.Response) -> YouTubeEr
# log-pii-ok: only under this user's own Teljes consent, which is what it is for # log-pii-ok: only under this user's own Teljes consent, which is what it is for
extra=logs.event("youtube.api.body", body=resp.text[:500]), extra=logs.event("youtube.api.body", body=resp.text[:500]),
) )
return YouTubeError(f"{method} {path} -> {resp.status_code}: {reason}", reason=reason) return YouTubeError(
f"{method} {path} -> {resp.status_code}: {reason}", reason=reason, path=path
)
# The `mine` reads' answer for a Google account that has no YouTube channel.
_NO_CHANNEL = frozenset({"channelNotFound"})
# The reasons that mean the project's quota is spent: every further call today gets the same answer.
_QUOTA_REASONS = frozenset({"quotaExceeded", "dailyLimitExceeded"})
def is_quota_refusal(exc: YouTubeError) -> bool:
"""Whether the API refused for want of quota. `reason` can list several codes, comma-joined."""
return any(r in _QUOTA_REASONS for r in exc.reason.split(","))
# The token endpoint's answer for a revoked or expired grant — the one refusal a reconnect fixes.
_DEAD_GRANT = "invalid_grant"
# What a refused YouTube action means to the person who asked for it — the three things they can
# act on differently: wait for tomorrow's budget, reconnect the account, or try again later.
YouTubeRefusal = Literal["quota", "reconnect", "unavailable"]
def refusal_reason(exc: YouTubeError) -> YouTubeRefusal:
"""Sort a failure by what the user can do about it. A dead grant (Google refused the refresh
with `invalid_grant`, which also drops the grant — see `_drop_dead_grant`) or no usable token at
all (raised before any endpoint answered, so with no path and no transport reason) means the
account must be reconnected; retrying cannot help. Any other token-endpoint failure — a 5xx, a
429, a misconfigured client — leaves the grant as it was: reconnecting would not fix it, and
the account would still read as connected, with no connect button to press."""
if is_quota_refusal(exc):
return "quota"
if exc.path == "oauth2/token":
return "reconnect" if exc.reason == _DEAD_GRANT else "unavailable"
if not exc.path and exc.reason != "transport":
return "reconnect"
return "unavailable"
def _chunks(items: list, size: int) -> Iterator[list]: def _chunks(items: list, size: int) -> Iterator[list]:
@@ -156,7 +206,10 @@ class YouTubeClient:
}, },
) )
if resp.status_code != 200: if resp.status_code != 200:
raise _upstream_failure("POST", "oauth2/token", resp) exc = _upstream_failure("POST", "oauth2/token", resp)
if exc.reason == _DEAD_GRANT:
self._drop_dead_grant(tok)
raise exc
data = resp.json() data = resp.json()
access = data["access_token"] access = data["access_token"]
tok.access_token = encrypt(access) tok.access_token = encrypt(access)
@@ -166,8 +219,34 @@ class YouTubeClient:
log.info("Refreshed access token for user %s", self.user.id) log.info("Refreshed access token for user %s", self.user.id)
return access return access
def _drop_dead_grant(self, tok) -> None:
"""Google answered the refresh with `invalid_grant`: the grant was revoked or expired, and no
retry will revive it. Forget it, so the account reads as NOT connected — Settings and the
onboarding wizard then offer the connect button again (a fresh consent issues a new refresh
token). Kept as "granted", the UI showed a live connection with no way to renew it while
every call failed. Committed like the refresh above: the caller rolls back on the error."""
tok.scopes = None
tok.access_token = None
tok.refresh_token_enc = None
tok.expiry = None
self.db.add(tok)
self.db.commit()
# INFO: the refusal itself is already the warning (`youtube.api.error`, invalid_grant).
log.info(
"cleared the dead YouTube grant of user %s",
self.user.id,
extra=logs.event("youtube.grant.cleared"),
)
# --- core request --- # --- core request ---
def _get(self, path: str, params: dict, cost: int = 1, allow_key: bool = True) -> dict: def _get(
self,
path: str,
params: dict,
cost: int = 1,
allow_key: bool = True,
expected: frozenset[str] = frozenset(),
) -> dict:
p = dict(params) p = dict(params)
headers = {} headers = {}
api_key = sysconfig.get_str(self.db, "youtube_api_key") api_key = sysconfig.get_str(self.db, "youtube_api_key")
@@ -178,7 +257,7 @@ class YouTubeClient:
resp = self._send("GET", f"{API_BASE}/{path}", params=p, headers=headers) resp = self._send("GET", f"{API_BASE}/{path}", params=p, headers=headers)
quota.record_usage(cost) quota.record_usage(cost)
if resp.status_code != 200: if resp.status_code != 200:
raise _upstream_failure("GET", path, resp) raise _upstream_failure("GET", path, resp, expected)
return resp.json() return resp.json()
# --- endpoints --- # --- endpoints ---
@@ -218,7 +297,14 @@ class YouTubeClient:
params = {"part": "snippet,contentDetails", "mine": "true", "maxResults": 50} params = {"part": "snippet,contentDetails", "mine": "true", "maxResults": 50}
if page_token: if page_token:
params["pageToken"] = page_token params["pageToken"] = page_token
data = self._get("playlists", params, cost=1, allow_key=False) try:
data = self._get("playlists", params, cost=1, allow_key=False, expected=_NO_CHANNEL)
except YouTubeError as exc:
# A Google account without a YouTube channel has no playlists, and YouTube says
# so as a 404. That is an empty list, not a failed sync.
if exc.reason in _NO_CHANNEL:
return
raise
for item in data.get("items", []): for item in data.get("items", []):
sn = item.get("snippet", {}) sn = item.get("snippet", {})
yield { yield {
+55 -4
View File
@@ -21,7 +21,8 @@ from alembic import command
from alembic.config import Config from alembic.config import Config
from fastapi import HTTPException, Request from fastapi import HTTPException, Request
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from sqlalchemy import create_engine, text from sqlalchemy import create_engine, event, text
from starlette.requests import HTTPConnection
import app.models # noqa: F401 — register every model on Base.metadata import app.models # noqa: F401 — register every model on Base.metadata
import app.state as app_state import app.state as app_state
@@ -100,17 +101,67 @@ def pytest_configure(config):
) )
def _request_dep(db, unfinished: list[str]):
"""`get_db` for a test request: the test's own session, plus the check a shared session hides.
A route helper flushes and its HANDLER commits (CLAUDE.md). Production's `get_db` closes the
session after the request, which rolls back anything flushed but not committed — yet here every
request shares the test's session, so a follow-up GET or an assertion reads the flushed write
and the suite stays green over a handler that forgot to commit (deep review, debt burn). So a
request that ends normally with a write it did not commit — flushed, or still only staged — is
recorded, and the test fails on it at teardown. Only the ROOT transaction's end clears it: a
savepoint released inside the handler commits nothing. The test's own staged rows are flushed before the request starts — they are the
test's business, not the handler's. A request that raises is rolled back, as production's
`close()` rolls it back: without that, what a failing handler flushed but never committed stayed
readable to the rest of the test — the one state production cannot be in (E16: an issue created
on the tracker before a failed upload was kept only by an explicit commit, and nothing saw it
missing)."""
def dep(conn: HTTPConnection):
db.flush()
state = {"dirty": False}
def flushed(session, ctx):
state["dirty"] = True
def ended(session, transaction):
if transaction.parent is None:
state["dirty"] = False
hooks = (("after_flush", flushed), ("after_transaction_end", ended))
for name, fn in hooks:
event.listen(db, name, fn)
try:
yield db
staged = db.new or db.deleted or any(db.is_modified(o) for o in db.dirty)
if state["dirty"] or staged:
unfinished.append(f"{conn.scope.get('method', 'WS')} {conn.url.path}")
except Exception:
db.rollback()
raise
finally:
for name, fn in hooks:
event.remove(db, name, fn)
return dep
@pytest.fixture @pytest.fixture
def db(_schema): def db(_schema):
"""A real session against the test Postgres, on a clean (empty) database. The code under test """A real session against the test Postgres, on a clean (empty) database. The code under test
may commit freely.""" may commit freely. `db.info["request"]` is the `get_db` override for a request against it — the
one every test client uses — and a handler that left a write uncommitted fails the test here."""
_truncate_all() _truncate_all()
session = SessionLocal() session = SessionLocal()
unfinished: list[str] = []
session.info["request"] = _request_dep(session, unfinished)
session.info["unfinished"] = unfinished # the requests recorded so far (the check's own test)
try: try:
yield session yield session
finally: finally:
session.rollback() session.rollback()
session.close() session.close()
assert unfinished == [], "a handler left a write uncommitted"
@pytest.fixture @pytest.fixture
@@ -168,7 +219,7 @@ def client(db, override_dep, _configured):
`current_user` override — so login, the wallet gate and epoch revoke are exercised end to end. `current_user` override — so login, the wallet gate and epoch revoke are exercised end to end.
Starlette's SessionMiddleware round-trips the signed cookie, so a login in one call authenticates Starlette's SessionMiddleware round-trips the signed cookie, so a login in one call authenticates
the next on this same instance. No lifespan (the scheduler / HLS sweep stay off).""" the next on this same instance. No lifespan (the scheduler / HLS sweep stay off)."""
override_dep(get_db, lambda: db) override_dep(get_db, db.info["request"])
return TestClient(app) return TestClient(app)
@@ -187,7 +238,7 @@ def as_user(db, override_dep, _configured):
raise HTTPException(status_code=401, detail="Not authenticated") raise HTTPException(status_code=401, detail="Not authenticated")
return registry[uid] return registry[uid]
override_dep(get_db, lambda: db) override_dep(get_db, db.info["request"])
override_dep(current_user, _resolver) override_dep(current_user, _resolver)
def _as(user: User) -> TestClient: def _as(user: User) -> TestClient:
+12
View File
@@ -168,3 +168,15 @@ def test_deleting_the_user_also_clears_the_way_to_re_invite(db, make_user, as_us
assert ( assert (
db.execute(select(Invite).where(Invite.email == member.email)).scalar_one_or_none() is None db.execute(select(Invite).where(Invite.email == member.email)).scalar_one_or_none() is None
) )
def test_whitelisting_a_new_address_audits_it_by_id(db, make_user, as_user):
"""The audit row was written before the new invite had an id, so it named no target."""
from app.models import AuditLog
admin = make_user(role="admin")
r = as_user(admin).post("/api/admin/invites", json={"email": "fresh-e27@example.com"})
assert r.status_code == 200
inv = db.scalar(select(Invite).where(Invite.email == "fresh-e27@example.com"))
row = db.scalar(select(AuditLog).where(AuditLog.target_type == "invite"))
assert row is not None and row.target_id == str(inv.id)
+1 -1
View File
@@ -447,7 +447,7 @@ def test_register_route_creates_the_account_end_to_end(client, db, monkeypatch):
r = client.post("/auth/register", json={"email": "newbie@example.com", "password": _PW}) r = client.post("/auth/register", json={"email": "newbie@example.com", "password": _PW})
assert r.status_code == 200 and r.json() == {"status": "ok"} assert r.status_code == 200 and r.json() == {"ok": True, "status": "ok"}
db.expire_all() db.expire_all()
user = db.execute(_select(User).where(User.email == "newbie@example.com")).scalar_one_or_none() user = db.execute(_select(User).where(User.email == "newbie@example.com")).scalar_one_or_none()
assert user is not None, "POST /auth/register answered ok but created nothing" assert user is not None, "POST /auth/register answered ok but created nothing"
+20 -32
View File
@@ -18,7 +18,7 @@ from app.sync import runner, videos
_PUB = datetime(2020, 1, 1, tzinfo=timezone.utc) _PUB = datetime(2020, 1, 1, tzinfo=timezone.utc)
def test_channel_detail_returns_normal_shorts_live_counts(db, monkeypatch): def test_channel_detail_returns_normal_shorts_live_counts(db, monkeypatch, override_dep):
# published_at set so channel_detail skips the YouTube-enrich path (no quota / network in tests). # published_at set so channel_detail skips the YouTube-enrich path (no quota / network in tests).
ch = Channel(id="UC_nsl", title="N/S/L Channel", published_at=_PUB) ch = Channel(id="UC_nsl", title="N/S/L Channel", published_at=_PUB)
db.add(ch) db.add(ch)
@@ -40,12 +40,9 @@ def test_channel_detail_returns_normal_shorts_live_counts(db, monkeypatch):
# The request gate 503s an unconfigured instance; flip the process-level flag (auto-restored). # The request gate 503s an unconfigured instance; flip the process-level flag (auto-restored).
monkeypatch.setattr(state, "_configured_cache", True) monkeypatch.setattr(state, "_configured_cache", True)
app.dependency_overrides[get_db] = lambda: db override_dep(get_db, db.info["request"])
app.dependency_overrides[current_user] = lambda: me override_dep(current_user, lambda: me)
try: resp = TestClient(app).get(f"/api/channels/{ch.id}")
resp = TestClient(app).get(f"/api/channels/{ch.id}")
finally:
app.dependency_overrides.clear()
assert resp.status_code == 200, resp.text assert resp.status_code == 200, resp.text
body = resp.json() body = resp.json()
@@ -56,7 +53,7 @@ def test_channel_detail_returns_normal_shorts_live_counts(db, monkeypatch):
@pytest.mark.parametrize("hidden", [True, False]) @pytest.mark.parametrize("hidden", [True, False])
def test_channel_detail_surfaces_subscription_hidden_flag(db, monkeypatch, hidden): def test_channel_detail_surfaces_subscription_hidden_flag(db, monkeypatch, hidden, override_dep):
# S6: the detail GET must expose the per-subscription `hidden` flag so the channel page can offer # S6: the detail GET must expose the per-subscription `hidden` flag so the channel page can offer
# the same Unhide toggle the manager has (the flag was previously omitted from the detail dict). # the same Unhide toggle the manager has (the flag was previously omitted from the detail dict).
ch = Channel(id="UC_hid", title="Hidden?", published_at=_PUB) ch = Channel(id="UC_hid", title="Hidden?", published_at=_PUB)
@@ -68,18 +65,15 @@ def test_channel_detail_surfaces_subscription_hidden_flag(db, monkeypatch, hidde
db.commit() db.commit()
monkeypatch.setattr(state, "_configured_cache", True) monkeypatch.setattr(state, "_configured_cache", True)
app.dependency_overrides[get_db] = lambda: db override_dep(get_db, db.info["request"])
app.dependency_overrides[current_user] = lambda: me override_dep(current_user, lambda: me)
try: resp = TestClient(app).get(f"/api/channels/{ch.id}")
resp = TestClient(app).get(f"/api/channels/{ch.id}")
finally:
app.dependency_overrides.clear()
assert resp.status_code == 200, resp.text assert resp.status_code == 200, resp.text
assert resp.json()["hidden"] is hidden assert resp.json()["hidden"] is hidden
def test_channel_detail_hidden_defaults_false_when_not_subscribed(db, monkeypatch): def test_channel_detail_hidden_defaults_false_when_not_subscribed(db, monkeypatch, override_dep):
# No subscription row → the flag can't be True; it must serialize as False, not raise. # No subscription row → the flag can't be True; it must serialize as False, not raise.
ch = Channel(id="UC_hid_ns", title="Unsub", published_at=_PUB) ch = Channel(id="UC_hid_ns", title="Unsub", published_at=_PUB)
me = User(email="hidns@example.com") me = User(email="hidns@example.com")
@@ -87,12 +81,9 @@ def test_channel_detail_hidden_defaults_false_when_not_subscribed(db, monkeypatc
db.commit() db.commit()
monkeypatch.setattr(state, "_configured_cache", True) monkeypatch.setattr(state, "_configured_cache", True)
app.dependency_overrides[get_db] = lambda: db override_dep(get_db, db.info["request"])
app.dependency_overrides[current_user] = lambda: me override_dep(current_user, lambda: me)
try: resp = TestClient(app).get(f"/api/channels/{ch.id}")
resp = TestClient(app).get(f"/api/channels/{ch.id}")
finally:
app.dependency_overrides.clear()
assert resp.status_code == 200, resp.text assert resp.status_code == 200, resp.text
assert resp.json()["hidden"] is False assert resp.json()["hidden"] is False
@@ -115,33 +106,30 @@ def _setup_channel_and_sub(db, *, email, is_demo=False):
return ch, user return ch, user
def _patch_deep_requested(db, monkeypatch, user): def _patch_deep_requested(db, monkeypatch, override_dep, user):
calls: list[tuple] = [] calls: list[tuple] = []
# background.add_task runs after the response in TestClient; record what would be scheduled. # background.add_task runs after the response in TestClient; record what would be scheduled.
monkeypatch.setattr(channels_route, "backfill_channel_now", lambda *a, **k: calls.append(a)) monkeypatch.setattr(channels_route, "backfill_channel_now", lambda *a, **k: calls.append(a))
monkeypatch.setattr(state, "_configured_cache", True) monkeypatch.setattr(state, "_configured_cache", True)
app.dependency_overrides[get_db] = lambda: db override_dep(get_db, db.info["request"])
app.dependency_overrides[current_user] = lambda: user override_dep(current_user, lambda: user)
try: resp = TestClient(app).patch("/api/channels/UC_wire", json={"deep_requested": True})
resp = TestClient(app).patch("/api/channels/UC_wire", json={"deep_requested": True})
finally:
app.dependency_overrides.clear()
return resp, calls return resp, calls
def test_deep_request_schedules_immediate_backfill_for_non_demo(db, monkeypatch): def test_deep_request_schedules_immediate_backfill_for_non_demo(db, monkeypatch, override_dep):
ch, me = _setup_channel_and_sub(db, email="wire@example.com") ch, me = _setup_channel_and_sub(db, email="wire@example.com")
resp, calls = _patch_deep_requested(db, monkeypatch, me) resp, calls = _patch_deep_requested(db, monkeypatch, override_dep, me)
assert resp.status_code == 200, resp.text assert resp.status_code == 200, resp.text
assert resp.json()["deep_requested"] is True assert resp.json()["deep_requested"] is True
# scheduled exactly once, with (channel_id, user_id) — the args backfill_channel_now needs. # scheduled exactly once, with (channel_id, user_id) — the args backfill_channel_now needs.
assert calls == [(ch.id, me.id)] assert calls == [(ch.id, me.id)]
def test_deep_request_does_not_schedule_immediate_backfill_for_demo(db, monkeypatch): def test_deep_request_does_not_schedule_immediate_backfill_for_demo(db, monkeypatch, override_dep):
# The SHARED demo account must not be able to kick immediate full backfills (budget drain). # The SHARED demo account must not be able to kick immediate full backfills (budget drain).
_ch, demo = _setup_channel_and_sub(db, email="demo@example.com", is_demo=True) _ch, demo = _setup_channel_and_sub(db, email="demo@example.com", is_demo=True)
resp, calls = _patch_deep_requested(db, monkeypatch, demo) resp, calls = _patch_deep_requested(db, monkeypatch, override_dep, demo)
assert resp.status_code == 200, resp.text assert resp.status_code == 200, resp.text
assert calls == [], "demo must not schedule an immediate backfill" assert calls == [], "demo must not schedule an immediate backfill"
# …and deep_requested is not persisted on the shared demo subscription either. # …and deep_requested is not persisted on the shared demo subscription either.
+5 -8
View File
@@ -17,7 +17,7 @@ from app.main import app
from app.models import Channel, ChannelTag, Tag, User from app.models import Channel, ChannelTag, Tag, User
def test_channel_detail_returns_only_this_users_tag_ids(db, monkeypatch): def test_channel_detail_returns_only_this_users_tag_ids(db, monkeypatch, override_dep):
# published_at set so channel_detail skips the YouTube-enrich path (no quota / network in tests). # published_at set so channel_detail skips the YouTube-enrich path (no quota / network in tests).
ch = Channel( ch = Channel(
id="UC_tags_test", id="UC_tags_test",
@@ -43,13 +43,10 @@ def test_channel_detail_returns_only_this_users_tag_ids(db, monkeypatch):
# The request gate 503s an unconfigured instance; flip the process-level flag (auto-restored). # The request gate 503s an unconfigured instance; flip the process-level flag (auto-restored).
monkeypatch.setattr(state, "_configured_cache", True) monkeypatch.setattr(state, "_configured_cache", True)
app.dependency_overrides[get_db] = lambda: db override_dep(get_db, db.info["request"])
app.dependency_overrides[current_user] = lambda: me override_dep(current_user, lambda: me)
try: client = TestClient(app) # no context-manager: skip lifespan (scheduler/HLS sweep)
client = TestClient(app) # no context-manager: skip lifespan (scheduler/HLS sweep) resp = client.get(f"/api/channels/{ch.id}")
resp = client.get(f"/api/channels/{ch.id}")
finally:
app.dependency_overrides.clear()
assert resp.status_code == 200, resp.text assert resp.status_code == 200, resp.text
assert resp.json()["tag_ids"] == [mine.id] assert resp.json()["tag_ids"] == [mine.id]
File diff suppressed because it is too large Load Diff
+2
View File
@@ -68,6 +68,7 @@ def test_raising_the_level_sets_the_servers_expiry_whatever_the_client_sends(db,
"level": "INFO", "level": "INFO",
"until": None, "until": None,
"retention_days": 7, "retention_days": 7,
"session_minutes": 60,
} }
@@ -82,6 +83,7 @@ def test_a_lapsed_level_reads_as_the_base_in_me(db, client, make_user):
"level": "INFO", "level": "INFO",
"until": None, "until": None,
"retention_days": 7, "retention_days": 7,
"session_minutes": 60,
} }
+179
View File
@@ -544,6 +544,73 @@ def test_shelf_resolves_a_playlist_to_its_first_unwatched_item(db):
assert card["next"]["id"] == "vmed" assert card["next"]["id"] == "vmed"
def _count_statements(db, fn) -> int:
"""How many SQL statements `fn` sends through the session's connection."""
from sqlalchemy import event
seen: list[str] = []
engine = db.get_bind()
listener = lambda *a, **k: seen.append(a[2]) # noqa: E731 — (conn, cursor, statement, …)
event.listen(engine, "before_cursor_execute", listener)
try:
fn()
finally:
event.remove(engine, "before_cursor_execute", listener)
return len(seen)
def test_the_shelf_resolves_each_channel_and_playlist_on_its_own_terms(db):
"""Batched, the per-channel count and newest video come from one windowed query: each channel
must still get ITS count and ITS newest, not the shelf's overall first row."""
user, ch = _seed_yt(db)
other = Channel(id="UCother", title="Other")
db.add(other)
db.flush()
db.add(Video(id="vother", channel_id=other.id, title="x", published_at=NOW - timedelta(days=9)))
pl = Playlist(user_id=user.id, name="Két elem")
empty = Playlist(user_id=user.id, name="Üres")
db.add_all([pl, empty])
db.flush()
db.add(PlaylistItem(playlist_id=pl.id, video_id="vlong", position=0))
for i, target in enumerate(
[
{"channel_id": ch.id},
{"channel_id": other.id},
{"playlist_id": pl.id},
{"playlist_id": empty.id},
]
):
db.add(ComfortPin(user_id=user.id, position=i, **target))
db.commit()
got = [
(c.get("unwatched_count", c.get("item_count")), (c["next"] or {}).get("id"))
for c in pins.shelf(db, user)
]
assert got == [(3, "vshort"), (1, "vother"), (1, "vlong"), (0, None)]
def test_the_shelf_costs_the_same_queries_for_one_pin_or_many(db):
"""A channel pin used to cost three queries and a playlist pin two, each; the shelf now
resolves every channel and every playlist together."""
user, ch = _seed_yt(db)
extra = [Channel(id=f"UCx{i}", title=f"x{i}") for i in range(3)]
lists = [Playlist(user_id=user.id, name=f"p{i}") for i in range(4)]
db.add_all(extra + lists)
db.flush()
db.add(ComfortPin(user_id=user.id, channel_id=ch.id, position=0))
db.add(ComfortPin(user_id=user.id, playlist_id=lists[0].id, position=1))
db.commit()
few = _count_statements(db, lambda: pins.shelf(db, user))
for i, c in enumerate(extra):
db.add(ComfortPin(user_id=user.id, channel_id=c.id, position=10 + i))
for i, p in enumerate(lists[1:]):
db.add(ComfortPin(user_id=user.id, playlist_id=p.id, position=20 + i))
db.commit()
assert _count_statements(db, lambda: pins.shelf(db, user)) == few
def test_a_pinned_video_survives_leaving_the_feed_scope(db): def test_a_pinned_video_survives_leaving_the_feed_scope(db):
"""The shelf is the fallback surface. An entry silently vanishing because the user unsubscribed """The shelf is the fallback surface. An entry silently vanishing because the user unsubscribed
from the channel is exactly the failure it must not have.""" from the channel is exactly the failure it must not have."""
@@ -940,3 +1007,115 @@ def test_deleting_a_pin_reports_what_went(db, as_user):
r = c.delete(f"/api/discover/pins/{pin_id}") r = c.delete(f"/api/discover/pins/{pin_id}")
assert r.status_code == 200 and r.json() == {"deleted": pin_id} assert r.status_code == 200 and r.json() == {"deleted": pin_id}
assert c.get("/api/discover/pins").json()["items"] == [] assert c.get("/api/discover/pins").json()["items"] == []
def test_with_plex_off_the_home_shelf_hides_plex_pins_but_keeps_them(db, as_user):
"""The owner's call (2026-09-19): with the deployment's Plex toggle off, the Home shelf goes
YouTube-only like the rails beside it — a Plex tile there offered a Play that landed on a gated
module. The hero's comfort tier draws from the same filtered shelf, and the pin menus' own
listing still sees every pin, so nothing the user curated is lost."""
user, _ = _seed_yt(db)
_seed_plex(db, user)
db.add_all(
[
ComfortPin(
user_id=user.id,
plex_item_id=db.scalar(select(PlexItem.id).where(PlexItem.rating_key == "pm1")),
position=0,
),
ComfortPin(user_id=user.id, video_id="vlong", position=1),
]
)
db.commit()
c = as_user(user)
assert [t["id"] for t in c.get("/api/discover").json()["comfort"]] == ["vlong"]
assert c.get("/api/discover/pick?start=1").json()["hero"]["card"]["id"] == "vlong"
assert {t["id"] for t in c.get("/api/discover/pins").json()["items"]} == {"pm1", "vlong"}
_enable_plex(db)
assert [t["id"] for t in c.get("/api/discover").json()["comfort"]] == ["pm1", "vlong"]
def test_a_reorder_of_the_visible_pins_keeps_the_hidden_ones_in_place(db, as_user):
"""The client sends the order it SHOWS. With Plex off that leaves the Plex pins out, and
numbering only the listed ids 0..n-1 put two pins on one position and moved the hidden one about
when Plex came back. The listed pins trade the slots they hold; the rest stay put."""
user, ch = _seed_yt(db)
_seed_plex(db, user)
c = as_user(user)
_enable_plex(db)
a = c.post("/api/discover/pins", json={"kind": "video", "ref": "vmed"}).json()["id"]
hidden = c.post("/api/discover/pins", json={"kind": "plex_item", "ref": "pm1"}).json()["id"]
b = c.post("/api/discover/pins", json={"kind": "channel", "ref": ch.id}).json()["id"]
assert c.post("/api/discover/pins/reorder", json={"ids": [b, b, a]}).status_code == 200
assert [p["pin_id"] for p in c.get("/api/discover/pins").json()["items"]] == [b, hidden, a]
positions = db.scalars(select(ComfortPin.position).where(ComfortPin.user_id == user.id)).all()
assert sorted(positions) == [0, 1, 2]
def test_a_hero_from_a_show_pin_names_the_pin_it_came_from(db, as_user):
"""A show's next EPISODE is not itself pinned, so the hero's menu used to look the episode up,
miss, and offer "Pin" under "From your shelf" — creating a second pin. The card now carries
the pin it was resolved from, which the menu acts on (owner's call: "Unpin")."""
user, _ = _seed_yt(db)
_seed_plex(db, user)
_enable_plex(db)
c = as_user(user)
assert c.post("/api/discover/pins", json={"kind": "plex_show", "ref": "ps1"}).status_code == 200
got = c.get("/api/discover/pick?start=1").json()["hero"]
assert got["tier"] == "comfort" and got["card"]["id"] == "ps1e1"
assert got["card"]["pinned_from"] == {
"source": "plex",
"kind": "show",
"id": "ps1",
"title": "A Show",
}
def test_two_overlapping_reorders_never_share_a_slot(db, as_user):
"""`reorder` writes only the rows that change. Unlocked, two tabs reordering from the same
snapshot each skipped the rows that already matched in THEIR snapshot and interleaved row by
row — two pins on one slot (deep review, debt burn). Locked, the second waits for the first and
deals from its result: here a partial list (a tab with Plex off, one pin hidden), whose left-out
pin must stay where the FIRST reorder put it — a lock taken in position order handed the waiter
fresh rows in its stale order, and it dealt that pin back to its old slot (round 2)."""
import threading
from app.db import SessionLocal
user, ch = _seed_yt(db)
c = as_user(user)
p1 = c.post("/api/discover/pins", json={"kind": "video", "ref": "vmed"}).json()["id"]
p2 = c.post("/api/discover/pins", json={"kind": "channel", "ref": ch.id}).json()["id"]
p3 = c.post("/api/discover/pins", json={"kind": "video", "ref": "vlong"}).json()["id"]
uid = user.id
with SessionLocal() as tab_a, SessionLocal() as tab_b, SessionLocal() as probe:
pins.reorder(tab_a, tab_a.get(User, uid), [p2, p3, p1]) # written, not yet committed
errors: list[BaseException] = []
def second_tab():
try:
pins.reorder(tab_b, tab_b.get(User, uid), [p3, p2])
tab_b.commit()
except BaseException as exc: # noqa: BLE001 — re-raised in the test thread below
errors.append(exc)
worker = threading.Thread(target=second_tab)
worker.start()
# Commit A only once B is blocked on a row lock, so B's read is the stale snapshot.
waiting = 0
for _ in range(200):
waiting = probe.scalar(text("SELECT count(*) FROM pg_locks WHERE NOT granted"))
probe.rollback()
if waiting:
break
worker.join(0.05)
assert waiting, "tab B never blocked — the race was not exercised"
tab_a.commit()
worker.join(10)
assert not worker.is_alive() and errors == []
db.expire_all()
positions = db.scalars(select(ComfortPin.position).where(ComfortPin.user_id == uid)).all()
assert sorted(positions) == [0, 1, 2]
assert [p["pin_id"] for p in c.get("/api/discover/pins").json()["items"]] == [p3, p2, p1]
+10 -13
View File
@@ -15,34 +15,31 @@ from app.main import app
from app.models import User from app.models import User
def _get(db, user, monkeypatch, path): def _get(db, user, monkeypatch, override_dep, path):
monkeypatch.setattr( monkeypatch.setattr(
state, "_configured_cache", True state, "_configured_cache", True
) # the request gate 503s an unconfigured app ) # the request gate 503s an unconfigured app
app.dependency_overrides[get_db] = lambda: db override_dep(get_db, db.info["request"])
app.dependency_overrides[current_user] = lambda: user override_dep(current_user, lambda: user)
try: client = TestClient(app) # no context manager: skip lifespan (scheduler/HLS sweep)
client = TestClient(app) # no context manager: skip lifespan (scheduler/HLS sweep) return client.get(path)
return client.get(path)
finally:
app.dependency_overrides.clear()
def test_malformed_feed_cursor_returns_400(db, monkeypatch): def test_malformed_feed_cursor_returns_400(db, monkeypatch, override_dep):
user = User(email="feedcursor@example.com") user = User(email="feedcursor@example.com")
db.add(user) db.add(user)
db.commit() db.commit()
resp = _get(db, user, monkeypatch, "/api/feed?cursor=not-a-real-cursor") resp = _get(db, user, monkeypatch, override_dep, "/api/feed?cursor=not-a-real-cursor")
assert resp.status_code == 400 assert resp.status_code == 400
assert resp.json()["detail"] == "Invalid feed cursor" assert resp.json()["detail"] == {"code": "refused", "reason": "invalid_cursor"}
def test_feed_without_cursor_is_200(db, monkeypatch): def test_feed_without_cursor_is_200(db, monkeypatch, override_dep):
# Sanity: the same endpoint without a cursor is a normal 200 (proves the 400 above is the # Sanity: the same endpoint without a cursor is a normal 200 (proves the 400 above is the
# cursor path, not a globally-broken request). A fresh user has no subscriptions → empty feed. # cursor path, not a globally-broken request). A fresh user has no subscriptions → empty feed.
user = User(email="feednocursor@example.com") user = User(email="feednocursor@example.com")
db.add(user) db.add(user)
db.commit() db.commit()
resp = _get(db, user, monkeypatch, "/api/feed") resp = _get(db, user, monkeypatch, override_dep, "/api/feed")
assert resp.status_code == 200 assert resp.status_code == 200
assert resp.json()["items"] == [] assert resp.json()["items"] == []
+150
View File
@@ -0,0 +1,150 @@
"""The RSS poll's API fallback (`sync/runner.run_feed_fallback`), DB-lane: which channels are read
through the Data API when their feed stays dark, and how often."""
from datetime import datetime, timedelta, timezone
import pytest
from app import sysconfig
from app.models import Channel, Video
from app.sync import runner
from app.youtube.client import YouTubeError
NOW = datetime.now(timezone.utc)
_PUB = datetime(2020, 1, 1, tzinfo=timezone.utc)
class _FakeYt:
# playlist id -> the reason its read fails with (the tests put entries here)
failing: dict[str, str] = {}
def __init__(self, calls):
self.calls = calls
def __enter__(self):
return self
def __exit__(self, *exc):
return False
def get_playlist_items(self, playlist_id, page_token=None):
self.calls.append(playlist_id)
if playlist_id in self.failing:
reason = self.failing[playlist_id]
# A request that never got through has no endpoint that answered.
path = "" if reason == "transport" else "playlistItems"
raise YouTubeError("refused", reason=reason, path=path)
cid = "UC" + playlist_id[2:]
return {
"items": [
{
"contentDetails": {"videoId": f"{cid}-v1", "videoPublishedAt": NOW.isoformat()},
"snippet": {"title": "new upload"},
}
]
}
@pytest.fixture
def calls(monkeypatch):
made: list[str] = []
monkeypatch.setattr(runner, "get_service_user", lambda db: object())
monkeypatch.setattr(runner, "YouTubeClient", lambda db, user: _FakeYt(made))
monkeypatch.setattr(runner.quota, "backfill_reserve_reached", lambda: False)
monkeypatch.setattr(_FakeYt, "failing", {})
return made
def _channel(db, cid, *, rss=None, fallback=None, uploads=True):
ch = Channel(
id=cid,
title=cid,
published_at=_PUB,
uploads_playlist_id=("UU" + cid[2:]) if uploads else None,
last_rss_at=rss,
feed_fallback_at=fallback,
)
db.add(ch)
db.commit()
return ch
def test_a_feed_dark_past_the_window_is_read_through_the_api(db, calls):
_channel(db, "UCdark", rss=NOW - timedelta(hours=3))
out = runner.run_feed_fallback(db, ["UCdark"])
assert out == {"read": 1, "new": 1, "failed": 0}
assert calls == ["UUdark"]
assert db.get(Video, "UCdark-v1") is not None
assert db.get(Channel, "UCdark").feed_fallback_at is not None
@pytest.mark.parametrize(
("rss", "fallback"),
[
(NOW - timedelta(minutes=30), None), # the feed answered recently: a blip, not an outage
(NOW - timedelta(hours=5), NOW - timedelta(minutes=30)), # already read this window
],
)
def test_a_channel_read_within_the_window_is_left_alone(db, calls, rss, fallback):
_channel(db, "UCrecent", rss=rss, fallback=fallback)
assert runner.run_feed_fallback(db, ["UCrecent"])["read"] == 0
assert calls == []
def test_the_window_is_the_admins_and_zero_turns_it_off(db, calls):
_channel(db, "UCdark", rss=NOW - timedelta(hours=3))
sysconfig.set_value(db, "rss_fallback_hours", 4)
db.commit()
assert runner.run_feed_fallback(db, ["UCdark"])["read"] == 0
sysconfig.set_value(db, "rss_fallback_hours", 0)
db.commit()
_channel(db, "UCnever")
assert runner.run_feed_fallback(db, ["UCdark", "UCnever"])["read"] == 0
assert calls == []
def test_only_the_named_channels_and_only_those_with_an_uploads_playlist(db, calls):
_channel(db, "UCdark")
_channel(db, "UCother")
_channel(db, "UCnolist", uploads=False)
assert runner.run_feed_fallback(db, ["UCdark", "UCnolist"])["read"] == 1
assert calls == ["UUdark"]
def test_it_yields_to_the_backfill_reserve(db, calls, monkeypatch):
_channel(db, "UCdark")
monkeypatch.setattr(runner.quota, "backfill_reserve_reached", lambda: True)
assert runner.run_feed_fallback(db, ["UCdark"])["read"] == 0
assert calls == []
def test_a_read_that_fails_waits_the_window_out_too(db, calls):
# A terminated channel: its feed 404s and so does its uploads playlist, for good. Without a
# stamp it was charged on every 20-minute poll instead of once per window.
_channel(db, "UCgone", rss=NOW - timedelta(hours=3))
_FakeYt.failing["UUgone"] = "playlistNotFound"
assert runner.run_feed_fallback(db, ["UCgone"]) == {"read": 0, "new": 0, "failed": 1}
assert db.get(Channel, "UCgone").feed_fallback_at is not None
assert runner.run_feed_fallback(db, ["UCgone"])["failed"] == 0
assert calls == ["UUgone"]
@pytest.mark.parametrize("reason", ["quotaExceeded", "transport"])
def test_a_failure_not_about_the_channel_ends_the_round_and_costs_no_window(db, calls, reason):
# Out of quota, or no connection: every further channel would fail the same way, and a channel
# that was never answered about keeps its turn for the next poll.
_channel(db, "UCa")
_channel(db, "UCb")
_FakeYt.failing["UUa"] = reason
assert runner.run_feed_fallback(db, ["UCa", "UCb"])["failed"] == 1
assert calls == ["UUa"]
assert db.get(Channel, "UCa").feed_fallback_at is None
+1 -1
View File
@@ -27,7 +27,7 @@ def test_the_rss_poll_skips_channels_nobody_follows(db, make_user, monkeypatch):
polled: list[str] = [] polled: list[str] = []
monkeypatch.setattr(runner, "fetch_channel_feed", lambda cid: polled.append(cid) or []) monkeypatch.setattr(runner, "fetch_channel_feed", lambda cid: polled.append(cid) or [])
monkeypatch.setattr(runner, "apply_rss_feed", lambda db, ch, entries: 0) monkeypatch.setattr(runner, "apply_rss_feed", lambda db, ch, entries: 0)
assert runner.run_rss_poll(db) == {"new": 0, "failed": 0} assert runner.run_rss_poll(db) == {"new": 0, "failed": 0, "fallback": 0}
assert sorted(polled) == ["UC_explored", "UC_sub"] assert sorted(polled) == ["UC_explored", "UC_sub"]
+20 -24
View File
@@ -116,10 +116,10 @@ def test_the_periodic_pass_still_reaps_what_nobody_has_touched_for_the_idle_limi
def test_touching_a_session_keeps_it_through_the_short_rule(tmp_path, sessions): def test_touching_a_session_keeps_it_through_the_short_rule(tmp_path, sessions):
s = _Session(tmp_path, "paused", finished=True, idle_s=90) s = _Session(tmp_path, "paused", finished=True, idle_s=90)
sessions["12345"] = s sessions[(1, "12345")] = s
stream.touch_session("12345") stream.touch_session(1, "12345")
assert stream.reap_idle() == 0 assert stream.reap_idle() == 0
assert "12345" in sessions and s.dir.exists() and not s.proc.killed assert (1, "12345") in sessions and s.dir.exists() and not s.proc.killed
# --- the two call sites, driven through the real routes (DB lane) ------------------------------- # --- the two call sites, driven through the real routes (DB lane) -------------------------------
@@ -142,13 +142,15 @@ def _library_item(db, rating_key: str, *, playable: str = "remux"):
return it return it
def _client(db, user): def _client(db, user, override_dep):
app.dependency_overrides[get_db] = lambda: db override_dep(get_db, db.info["request"])
app.dependency_overrides[current_user] = lambda: user override_dep(current_user, lambda: user)
return TestClient(app) return TestClient(app)
def test_a_paused_viewers_checkpoint_keeps_their_session_alive(db, monkeypatch, tmp_path, sessions): def test_a_paused_viewers_checkpoint_keeps_their_session_alive(
db, monkeypatch, tmp_path, sessions, override_dep
):
"""The whole reason the short rule is safe again: a paused player fetches nothing, so """The whole reason the short rule is safe again: a paused player fetches nothing, so
`last_access` never moved on its own — but it does keep checkpointing its position.""" `last_access` never moved on its own — but it does keep checkpointing its position."""
monkeypatch.setattr(state, "_configured_cache", True) monkeypatch.setattr(state, "_configured_cache", True)
@@ -157,21 +159,18 @@ def test_a_paused_viewers_checkpoint_keeps_their_session_alive(db, monkeypatch,
db.add(user) db.add(user)
db.commit() db.commit()
s = _Session(tmp_path, "paused", finished=True, idle_s=90) s = _Session(tmp_path, "paused", finished=True, idle_s=90)
sessions["paused1"] = s sessions[(user.id, "paused1")] = s
try: resp = _client(db, user, override_dep).post(
resp = _client(db, user).post( f"/api/plex/item/{it.rating_key}/progress",
f"/api/plex/item/{it.rating_key}/progress", json={"position_seconds": 600, "duration_seconds": 3000, "final": False},
json={"position_seconds": 600, "duration_seconds": 3000, "final": False}, )
)
finally:
app.dependency_overrides.clear()
assert resp.status_code == 200, resp.text assert resp.status_code == 200, resp.text
assert stream.reap_idle() == 0, "the checkpoint did not say the viewer is still there" assert stream.reap_idle() == 0, "the checkpoint did not say the viewer is still there"
assert "paused1" in sessions and not s.proc.killed assert (user.id, "paused1") in sessions and not s.proc.killed
def test_starting_a_session_leaves_another_viewers_paused_one_alone( def test_starting_a_session_leaves_another_viewers_paused_one_alone(
db, monkeypatch, tmp_path, sessions db, monkeypatch, tmp_path, sessions, override_dep
): ):
"""The opportunistic sweep this route used to run was not scoped to the key being started: it """The opportunistic sweep this route used to run was not scoped to the key being started: it
walked the WHOLE registry with the 30 s finished rule, so pressing play on any item killed a walked the WHOLE registry with the 30 s finished rule, so pressing play on any item killed a
@@ -183,9 +182,9 @@ def test_starting_a_session_leaves_another_viewers_paused_one_alone(
db.add(user) db.add(user)
db.commit() db.commit()
paused = _Session(tmp_path, "paused", finished=True, idle_s=90) paused = _Session(tmp_path, "paused", finished=True, idle_s=90)
sessions["someone-else"] = paused sessions[(999, "someone-else")] = paused
def fake_start(db_, item, start, audio=None, aoff=0.0, multi=False, transcode=False): def fake_start(db_, item, user_id, start, audio=None, aoff=0.0, multi=False, transcode=False):
return SimpleNamespace( return SimpleNamespace(
id="sid", id="sid",
media_start_s=start, media_start_s=start,
@@ -196,9 +195,6 @@ def test_starting_a_session_leaves_another_viewers_paused_one_alone(
) )
monkeypatch.setattr(stream, "start_session", fake_start) monkeypatch.setattr(stream, "start_session", fake_start)
try: resp = _client(db, user, override_dep).post(f"/api/plex/stream/{started.rating_key}/session")
resp = _client(db, user).post(f"/api/plex/stream/{started.rating_key}/session")
finally:
app.dependency_overrides.clear()
assert resp.status_code == 200, resp.text assert resp.status_code == 200, resp.text
assert "someone-else" in sessions and not paused.proc.killed assert (999, "someone-else") in sessions and not paused.proc.killed
+53
View File
@@ -0,0 +1,53 @@
"""No refusal answers in a hand-written English sentence.
The UI is bilingual and translates a structured `detail` (`{code, reason, …}`), but a string detail it
can only print as it came — so ~150 of them reached the Hungarian UI verbatim, in the blocking error
dialog and on every inline surface that shows `HttpError.detail`. `app/refusal.py` is the family they
became; this test is what keeps a new one from being written the old way. Pure: it reads the sources.
"""
import ast
import pathlib
APP = pathlib.Path(__file__).resolve().parents[1] / "app"
def _is_text(node: ast.AST) -> bool:
"""A detail that is a sentence: a literal, an f-string, `str(exc)`, or `"…" + x`."""
if isinstance(node, ast.Constant) and isinstance(node.value, str):
return True
if isinstance(node, ast.JoinedStr):
return True
if isinstance(node, ast.Call) and getattr(node.func, "id", "") == "str":
return True
if isinstance(node, ast.BoolOp):
return any(_is_text(v) for v in node.values)
if isinstance(node, ast.BinOp):
return _is_text(node.left) or _is_text(node.right)
return False
def test_no_http_exception_carries_a_sentence():
offenders = []
for path in sorted(APP.rglob("*.py")):
for node in ast.walk(ast.parse(path.read_text(encoding="utf-8"))):
if not (
isinstance(node, ast.Call)
and getattr(node.func, "id", getattr(node.func, "attr", "")) == "HTTPException"
):
continue
kw = {k.arg: k.value for k in node.keywords}
detail = kw.get("detail", node.args[1] if len(node.args) > 1 else None)
if detail is not None and _is_text(detail):
offenders.append(f"{path.relative_to(APP).as_posix()}:{node.lineno}")
assert offenders == [], (
"a refusal with an English sentence for a detail — raise `refused(status, reason)` "
f"(app/refusal.py) and name the reason in both locales: {offenders}"
)
def test_the_scan_sees_a_sentence_when_there_is_one():
"""A guard that matches nothing is green over nothing."""
sample = ast.parse('HTTPException(status_code=400, detail=f"bad {x}")').body[0].value
assert _is_text(sample.keywords[1].value)
assert not _is_text(ast.parse('{"code": "refused", "reason": "x"}').body[0].value)
+11 -1
View File
@@ -79,7 +79,7 @@ def test_the_demo_entry_does_not_stamp_the_shared_account(client, db):
r = client.post("/auth/demo", json={"email": "visitor@example.com"}) r = client.post("/auth/demo", json={"email": "visitor@example.com"})
assert r.json() == {"authenticated": True} assert r.json() == {"ok": True, "authenticated": True}
demo = auth_routes.get_or_create_demo_user(db) demo = auth_routes.get_or_create_demo_user(db)
db.refresh(demo) db.refresh(demo)
assert demo.last_login_at is None assert demo.last_login_at is None
@@ -95,3 +95,13 @@ def test_the_admin_user_list_carries_last_login(as_user, db, make_user):
assert rows["boss@example.com"]["last_login_at"].startswith("2026-09-16T08:30") assert rows["boss@example.com"]["last_login_at"].startswith("2026-09-16T08:30")
assert rows[never.email]["last_login_at"] is None assert rows[never.email]["last_login_at"] is None
def test_the_account_sees_its_own_last_sign_in(client, db, make_user):
# Settings → Account shows it (prod UAT 2026-09-19: only the admin's user list did).
make_user(email="me@example.com", password=_PW)
client.post("/auth/password-login", json={"email": "me@example.com", "password": _PW})
stamp = client.get("/api/me").json()["last_login_at"]
assert _recent(datetime.fromisoformat(stamp))
+2 -2
View File
@@ -140,9 +140,9 @@ def test_a_refused_token_refresh_names_the_oauth_reason_and_not_its_prose(sink,
request=httpx.Request("POST", "https://oauth2.googleapis.com/token"), request=httpx.Request("POST", "https://oauth2.googleapis.com/token"),
) )
client = yt.YouTubeClient.__new__(yt.YouTubeClient) # no HTTP client: `_send` is the stub client = yt.YouTubeClient.__new__(yt.YouTubeClient) # no HTTP client: `_send` is the stub
client.db = None client.db = SimpleNamespace(add=lambda obj: None, commit=lambda: None)
client.user = SimpleNamespace( client.user = SimpleNamespace(
token=SimpleNamespace(access_token=None, expiry=None, refresh_token_enc=b"x") id=1, token=SimpleNamespace(access_token=None, expiry=None, refresh_token_enc=b"x")
) )
monkeypatch.setattr(yt, "decrypt_optional", lambda value: None) monkeypatch.setattr(yt, "decrypt_optional", lambda value: None)
monkeypatch.setattr(yt, "decrypt", lambda value: "a-refresh-token") monkeypatch.setattr(yt, "decrypt", lambda value: "a-refresh-token")
+35
View File
@@ -219,6 +219,14 @@ def _client() -> TestClient:
raise HTTPException(status_code=400, detail={"code": "bad_cursor"}) raise HTTPException(status_code=400, detail={"code": "bad_cursor"})
return {"ok": True} return {"ok": True}
@app.get("/api/messages/unread_count")
def _unread():
return {"count": 0}
@app.post("/api/messages/unread_count")
def _unread_write():
return {"ok": True}
@app.get("/boom") @app.get("/boom")
def _boom(): def _boom():
raise RuntimeError("kaboom") raise RuntimeError("kaboom")
@@ -323,6 +331,33 @@ def test_the_log_consoles_own_reads_are_debug_but_its_failures_are_not(sink):
assert sink.objects()[-1]["level"] == "WARNING" assert sink.objects()[-1]["level"] == "WARNING"
def test_a_polled_read_is_debug_a_write_to_the_same_route_is_not(sink):
# One open tab polled the unread counts, /api/me and the download index every 30 s: at INFO
# that was ~95% of the stored log on prod.
http = logging.getLogger("siftlode.http")
http.setLevel(logs.TRACE)
client = _client()
r = client.get("/api/messages/unread_count")
assert sink.objects()[-1]["level"] == "DEBUG"
# ...and says so, for the browser's copy of the line (frontend lib/http.ts).
assert r.headers["X-Log-Quiet"] == "1"
r = client.post("/api/messages/unread_count")
assert sink.objects()[-1]["level"] == "INFO"
assert "X-Log-Quiet" not in r.headers
client.get("/api/videos/abc")
assert sink.objects()[-1]["level"] == "INFO"
def test_every_polled_route_is_a_route_the_app_has():
# The list names route TEMPLATES; one renamed on the router side would silently fall back to
# INFO, so the list is held to the app's own routes.
from app.main import app
# The schema, not `app.routes`: FastAPI 0.141 keeps an included router behind a wrapper there.
gets = {path for path, ops in app.openapi()["paths"].items() if "get" in ops}
assert logs._POLLED_ROUTES - gets == set()
def test_a_failing_request_is_logged_by_status_and_an_unhandled_error_is_logged_at_all(sink): def test_a_failing_request_is_logged_by_status_and_an_unhandled_error_is_logged_at_all(sink):
client = _client() client = _client()
client.get("/api/videos/missing/deeper") # 404 from the router client.get("/api/videos/missing/deeper") # 404 from the router
@@ -0,0 +1,84 @@
"""Dependency overrides go through `conftest.override_dep`, never `app.dependency_overrides` directly.
The hand-rolled shape — assign, then `app.dependency_overrides.clear()` in a `finally` — wipes EVERY
override, not the ones the test set, so it stops fixtures composing: one test's teardown silently
clobbers an override another fixture installed. `override_dep` restores each key's prior value
instead. 22 files had drifted to the hand-rolled shape (it was the house norm at ~20 files to 3), and
the sweep that converted them is only as good as the check that keeps it converted — this one.
"""
from pathlib import Path
TESTS = Path(__file__).parent
def test_no_test_writes_dependency_overrides_directly():
offenders = [
f"{p.name}:{n}"
for p in sorted(TESTS.rglob("*.py"))
if p.name not in {"conftest.py", Path(__file__).name}
for n, line in enumerate(p.read_text(encoding="utf-8").splitlines(), 1)
if "dependency_overrides" in line
]
assert offenders == [], "use the override_dep fixture: " + ", ".join(offenders)
def test_a_request_gets_the_session_that_checks_its_commit():
"""`get_db` is overridden with `db.info["request"]` — the test session plus the check that the
handler committed what it flushed (conftest `_request_dep`). A bare `lambda: db` hands the route
the same session without the check, and a handler that forgot its commit passes again."""
offenders = [
f"{p.name}:{n}"
for p in sorted(TESTS.rglob("*.py"))
if p.name != Path(__file__).name
for n, line in enumerate(p.read_text(encoding="utf-8").splitlines(), 1)
if "override_dep(get_db," in line and 'override_dep(get_db, db.info["request"])' not in line
]
assert offenders == [], 'override get_db with db.info["request"]: ' + ", ".join(offenders)
def test_the_commit_check_counts_only_the_root_transaction(db):
"""A savepoint released inside a handler commits nothing to the database; ending it must not
clear the check (round 2). Driven directly, as a request would drive the dependency."""
import pytest
from starlette.requests import HTTPConnection
from app.models import User
conn = HTTPConnection(
{"type": "http", "method": "POST", "path": "/probe", "headers": [], "query_string": b""}
)
request = db.info["request"](conn)
session = next(request)
session.add(User(email="savepoint-probe@example.com"))
session.flush()
with session.begin_nested():
pass
with pytest.raises(StopIteration):
next(request)
assert db.info["unfinished"] == ["POST /probe"]
db.info["unfinished"].clear() # recorded as it should be — not this test's failure
def test_a_request_that_raises_leaves_nothing_it_did_not_commit(db):
"""Production closes a failing request's session, which rolls back whatever it flushed and did
not commit; the shared test session must do the same, or a later read in the test sees a write
production would have lost (E16: the tracker's issue link, kept only by an explicit commit in
the handler's failure path). Driven directly, as a request would drive the dependency."""
import pytest
from sqlalchemy import select
from starlette.requests import HTTPConnection
from app.models import User
conn = HTTPConnection(
{"type": "http", "method": "POST", "path": "/probe", "headers": [], "query_string": b""}
)
request = db.info["request"](conn)
session = next(request)
session.add(User(email="rolled-back-probe@example.com"))
session.flush()
with pytest.raises(RuntimeError):
request.throw(RuntimeError("the handler failed"))
assert db.scalar(select(User).where(User.email == "rolled-back-probe@example.com")) is None
assert db.info["unfinished"] == [] # a failing request is rolled back, not reported
+53
View File
@@ -235,6 +235,13 @@ def test_item_edits_bump_updated_at(db, make_user, as_user):
assert db.get(Playlist, src.id).updated_at > old assert db.get(Playlist, src.id).updated_at > old
assert db.get(Playlist, dst.id).updated_at > old assert db.get(Playlist, dst.id).updated_at > old
# …and removing one, the fourth item-only write (it had no test: E10 review coverage debt).
src.updated_at = old
db.commit()
assert client.delete(f"/api/playlists/{src.id}/items/v1").status_code == 200
db.expire_all()
assert db.get(Playlist, src.id).updated_at > old
def test_the_list_endpoint_publishes_updated_at(db, make_user, as_user): def test_the_list_endpoint_publishes_updated_at(db, make_user, as_user):
user = make_user() user = make_user()
@@ -242,3 +249,49 @@ def test_the_list_endpoint_publishes_updated_at(db, make_user, as_user):
rows = as_user(user).get("/api/playlists").json() rows = as_user(user).get("/api/playlists").json()
assert len(rows) == 1 assert len(rows) == 1
assert rows[0]["updated_at"] # the rail cannot sort by a field the API never sends assert rows[0]["updated_at"] # the rail cannot sort by a field the API never sends
def test_a_target_that_already_had_it_is_not_recently_changed(db, make_user, as_user):
"""Moving into a list that already holds the video only removes it from the source: the
target did not change, and bumping it promoted an untouched list in the rail's sort."""
user = make_user()
_seed_videos(db, ["v1"])
src = _playlist(db, user, "Source", ["v1"])
dst = _playlist(db, user, "Target", ["v1"])
old = datetime.now(timezone.utc) - timedelta(days=3)
src.updated_at = old
dst.updated_at = old
db.commit()
assert _move(as_user(user), src.id, "v1", dst.id).status_code == 200
db.expire_all()
assert db.get(Playlist, src.id).updated_at > old
assert db.get(Playlist, dst.id).updated_at == old
def test_a_concurrent_add_to_the_target_lands_as_the_already_present_move(
db, make_user, as_user, monkeypatch
):
"""The already-in-target check is a read, not a lock: another tab's add (or the scheduler's
YouTube merge) can commit the same video to the target in between, and the re-parent then
hits uq_playlist_video. That is the idempotent outcome arriving late — not a raw 500."""
from app.db import SessionLocal
from app.routes import playlists as plroutes
user = make_user()
_seed_videos(db, ["v1"])
src = _playlist(db, user, "Source", ["v1"])
dst = _playlist(db, user, "Target", [])
real = plroutes._next_item_position
def add_first(session, playlist_id):
with SessionLocal() as other: # the other tab, committing between check and re-parent
other.add(PlaylistItem(playlist_id=dst.id, video_id="v1", position=0))
other.commit()
return real(session, playlist_id)
monkeypatch.setattr(plroutes, "_next_item_position", add_first)
assert _move(as_user(user), src.id, "v1", dst.id).status_code == 200
db.expire_all()
assert _ids(db, src.id) == []
assert _ids(db, dst.id) == ["v1"]
+11 -14
View File
@@ -42,7 +42,7 @@ def _seed(db, *, status: str, position: int):
return it, user return it, user
def _clear(db, user, it, monkeypatch) -> list[tuple]: def _clear(db, user, it, monkeypatch, override_dep) -> list[tuple]:
"""Drive the endpoint with the Plex link satisfied and the background push recorded.""" """Drive the endpoint with the Plex link satisfied and the background push recorded."""
pushes: list[tuple] = [] pushes: list[tuple] = []
monkeypatch.setattr(state, "_configured_cache", True) # pass the instance-setup request gate monkeypatch.setattr(state, "_configured_cache", True) # pass the instance-setup request gate
@@ -52,32 +52,29 @@ def _clear(db, user, it, monkeypatch) -> list[tuple]:
"push_state_to_plex", "push_state_to_plex",
lambda user_id, item_id, rating_key, action, *rest: pushes.append((rating_key, action)), lambda user_id, item_id, rating_key, action, *rest: pushes.append((rating_key, action)),
) )
app.dependency_overrides[get_db] = lambda: db override_dep(get_db, db.info["request"])
app.dependency_overrides[current_user] = lambda: user override_dep(current_user, lambda: user)
try: resp = TestClient(app).post(f"/api/plex/item/{it.rating_key}/state", json={"status": "new"})
resp = TestClient(app).post(f"/api/plex/item/{it.rating_key}/state", json={"status": "new"}) assert resp.status_code == 200, resp.text
assert resp.status_code == 200, resp.text
finally:
app.dependency_overrides.clear()
return pushes return pushes
def test_clearing_an_in_progress_item_tells_plex(db, monkeypatch): def test_clearing_an_in_progress_item_tells_plex(db, monkeypatch, override_dep):
# The regression: status "new" + a position IS the in-progress shape, and it must push. # The regression: status "new" + a position IS the in-progress shape, and it must push.
it, user = _seed(db, status="new", position=92) it, user = _seed(db, status="new", position=92)
assert _clear(db, user, it, monkeypatch) == [("98614", "unwatched")] assert _clear(db, user, it, monkeypatch, override_dep) == [("98614", "unwatched")]
assert db.query(PlexState).count() == 0 assert db.query(PlexState).count() == 0
def test_clearing_a_watched_item_still_tells_plex(db, monkeypatch): def test_clearing_a_watched_item_still_tells_plex(db, monkeypatch, override_dep):
# The case the old guard did cover — kept, so the fix cannot narrow into a new hole. # The case the old guard did cover — kept, so the fix cannot narrow into a new hole.
it, user = _seed(db, status="watched", position=0) it, user = _seed(db, status="watched", position=0)
assert _clear(db, user, it, monkeypatch) == [("98614", "unwatched")] assert _clear(db, user, it, monkeypatch, override_dep) == [("98614", "unwatched")]
def test_unhiding_touches_plex_not_at_all(db, monkeypatch): def test_unhiding_touches_plex_not_at_all(db, monkeypatch, override_dep):
# `hidden` is a Siftlode-only concept "with no Plex analogue, so it never touches Plex" — the # `hidden` is a Siftlode-only concept "with no Plex analogue, so it never touches Plex" — the
# endpoint's own docstring. The old status-only guard pushed here anyway, un-marking an item on # endpoint's own docstring. The old status-only guard pushed here anyway, un-marking an item on
# Plex that the user had merely hidden. # Plex that the user had merely hidden.
it, user = _seed(db, status="hidden", position=0) it, user = _seed(db, status="hidden", position=0)
assert _clear(db, user, it, monkeypatch) == [] assert _clear(db, user, it, monkeypatch, override_dep) == []
+28 -19
View File
@@ -231,61 +231,70 @@ def _seed(db):
return user return user
def _get(db, user, monkeypatch, path): def _get(db, user, monkeypatch, override_dep, path):
monkeypatch.setattr(state, "_configured_cache", True) monkeypatch.setattr(state, "_configured_cache", True)
app.dependency_overrides[get_db] = lambda: db override_dep(get_db, db.info["request"])
app.dependency_overrides[current_user] = lambda: user override_dep(current_user, lambda: user)
try: return TestClient(app).get(path)
return TestClient(app).get(path)
finally:
app.dependency_overrides.clear()
def _order(r) -> list[str]: def _order(r) -> list[str]:
return [i["title"] for i in r.json()["items"]] return [i["title"] for i in r.json()["items"]]
def test_collection_detail_defaults_to_release_year_ascending(db, monkeypatch): def test_collection_detail_defaults_to_release_year_ascending(db, monkeypatch, override_dep):
"""The order the page had before it grew a control — a franchise reads in release order.""" """The order the page had before it grew a control — a franchise reads in release order."""
user = _seed(db) user = _seed(db)
r = _get(db, user, monkeypatch, "/api/plex/collections/col1") r = _get(db, user, monkeypatch, override_dep, "/api/plex/collections/col1")
assert r.status_code == 200 assert r.status_code == 200
assert _order(r) == ["Zulu", "Mike", "Series", "Alpha"] assert _order(r) == ["Zulu", "Mike", "Series", "Alpha"]
def test_collection_detail_honours_sort_and_direction(db, monkeypatch): def test_collection_detail_honours_sort_and_direction(db, monkeypatch, override_dep):
user = _seed(db) user = _seed(db)
r = _get(db, user, monkeypatch, "/api/plex/collections/col1?sort=year&sort_dir=desc") r = _get(
db, user, monkeypatch, override_dep, "/api/plex/collections/col1?sort=year&sort_dir=desc"
)
assert _order(r) == ["Alpha", "Series", "Mike", "Zulu"] assert _order(r) == ["Alpha", "Series", "Mike", "Zulu"]
r = _get(db, user, monkeypatch, "/api/plex/collections/col1?sort=title&sort_dir=asc") r = _get(
db, user, monkeypatch, override_dep, "/api/plex/collections/col1?sort=title&sort_dir=asc"
)
assert _order(r) == ["Alpha", "Mike", "Series", "Zulu"] assert _order(r) == ["Alpha", "Mike", "Series", "Zulu"]
r = _get(db, user, monkeypatch, "/api/plex/collections/col1?sort=rating&sort_dir=desc") r = _get(
db, user, monkeypatch, override_dep, "/api/plex/collections/col1?sort=rating&sort_dir=desc"
)
# The show has no rating, so it sinks — it is not "rated 0". # The show has no rating, so it sinks — it is not "rated 0".
assert _order(r) == ["Zulu", "Mike", "Alpha", "Series"] assert _order(r) == ["Zulu", "Mike", "Alpha", "Series"]
def test_lastwatched_ranks_a_shows_episodes_alongside_the_movies(db, monkeypatch): def test_lastwatched_ranks_a_shows_episodes_alongside_the_movies(db, monkeypatch, override_dep):
"""The show leg needs the rollup's `resumed_at`; with only a status map this key would order """The show leg needs the rollup's `resumed_at`; with only a status map this key would order
every show as never-watched.""" every show as never-watched."""
user = _seed(db) user = _seed(db)
r = _get(db, user, monkeypatch, "/api/plex/collections/col1?sort=lastwatched&sort_dir=desc") r = _get(
db,
user,
monkeypatch,
override_dep,
"/api/plex/collections/col1?sort=lastwatched&sort_dir=desc",
)
# Series (episode resumed NOW) > Zulu (watched 2 days ago) > the two never-touched, title-asc. # Series (episode resumed NOW) > Zulu (watched 2 days ago) > the two never-touched, title-asc.
assert _order(r) == ["Series", "Zulu", "Alpha", "Mike"] assert _order(r) == ["Series", "Zulu", "Alpha", "Mike"]
def test_collections_list_resolves_titles_by_key(db, monkeypatch): def test_collections_list_resolves_titles_by_key(db, monkeypatch, override_dep):
"""What a shared Plex link needs: the URL carries bare rating_keys, so the sidebar token and the """What a shared Plex link needs: the URL carries bare rating_keys, so the sidebar token and the
filter chip have nothing to show but `17045` until these are resolved.""" filter chip have nothing to show but `17045` until these are resolved."""
user = _seed(db) user = _seed(db)
r = _get(db, user, monkeypatch, "/api/plex/collections?keys=col1") r = _get(db, user, monkeypatch, override_dep, "/api/plex/collections?keys=col1")
assert r.status_code == 200 assert r.status_code == 200
assert [c["title"] for c in r.json()["collections"]] == ["Franchise"] assert [c["title"] for c in r.json()["collections"]] == ["Franchise"]
def test_an_empty_keys_param_asks_for_nothing(db, monkeypatch): def test_an_empty_keys_param_asks_for_nothing(db, monkeypatch, override_dep):
"""`keys=` with nothing in it must not fall through to "every collection" — that would be a """`keys=` with nothing in it must not fall through to "every collection" — that would be a
200 KB answer to a question about zero ids.""" 200 KB answer to a question about zero ids."""
user = _seed(db) user = _seed(db)
r = _get(db, user, monkeypatch, "/api/plex/collections?keys=") r = _get(db, user, monkeypatch, override_dep, "/api/plex/collections?keys=")
assert r.status_code == 200 assert r.status_code == 200
assert r.json()["collections"] == [] assert r.json()["collections"] == []
+77 -94
View File
@@ -58,44 +58,44 @@ def _seed(db):
return user return user
def _get(db, user, monkeypatch, path): def _get(db, user, monkeypatch, override_dep, path):
monkeypatch.setattr(state, "_configured_cache", True) monkeypatch.setattr(state, "_configured_cache", True)
app.dependency_overrides[get_db] = lambda: db override_dep(get_db, db.info["request"])
app.dependency_overrides[current_user] = lambda: user override_dep(current_user, lambda: user)
try: return TestClient(app).get(path)
return TestClient(app).get(path)
finally:
app.dependency_overrides.clear()
def _titles(resp): def _titles(resp):
return {i["title"] for i in resp.json()["items"]} return {i["title"] for i in resp.json()["items"]}
def test_single_collection_filters_to_its_members(db, monkeypatch): def test_single_collection_filters_to_its_members(db, monkeypatch, override_dep):
user = _seed(db) user = _seed(db)
resp = _get(db, user, monkeypatch, "/api/plex/library?scope=movie&collections=A") resp = _get(db, user, monkeypatch, override_dep, "/api/plex/library?scope=movie&collections=A")
assert _titles(resp) == {"Only A", "A and B"} assert _titles(resp) == {"Only A", "A and B"}
def test_multiple_collections_any_is_the_union(db, monkeypatch): def test_multiple_collections_any_is_the_union(db, monkeypatch, override_dep):
user = _seed(db) user = _seed(db)
resp = _get(db, user, monkeypatch, "/api/plex/library?scope=movie&collections=A,B") resp = _get(
db, user, monkeypatch, override_dep, "/api/plex/library?scope=movie&collections=A,B"
)
assert _titles(resp) == {"Only A", "Only B", "A and B"} assert _titles(resp) == {"Only A", "Only B", "A and B"}
def test_multiple_collections_all_is_the_intersection(db, monkeypatch): def test_multiple_collections_all_is_the_intersection(db, monkeypatch, override_dep):
user = _seed(db) user = _seed(db)
resp = _get( resp = _get(
db, db,
user, user,
monkeypatch, monkeypatch,
override_dep,
"/api/plex/library?scope=movie&collections=A,B&collection_mode=all", "/api/plex/library?scope=movie&collections=A,B&collection_mode=all",
) )
assert _titles(resp) == {"A and B"} assert _titles(resp) == {"A and B"}
def test_collection_cards_carry_montage_art_and_total_runtime(db, monkeypatch): def test_collection_cards_carry_montage_art_and_total_runtime(db, monkeypatch, override_dep):
# The browse-tile montage (top films, best-rated first) + the collection's total runtime. # The browse-tile montage (top films, best-rated first) + the collection's total runtime.
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True) lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
db.add(lib) db.add(lib)
@@ -127,14 +127,16 @@ def test_collection_cards_carry_montage_art_and_total_runtime(db, monkeypatch):
db.commit() db.commit()
card = next( card = next(
c c
for c in _get(db, user, monkeypatch, "/api/plex/collections").json()["collections"] for c in _get(db, user, monkeypatch, override_dep, "/api/plex/collections").json()[
"collections"
]
if c["id"] == "A" if c["id"] == "A"
) )
assert card["art"] == ["m2", "m1"] # best-rated first (m2 9.0 > m1 8.0) assert card["art"] == ["m2", "m1"] # best-rated first (m2 9.0 > m1 8.0)
assert card["total_runtime_s"] == 5400 # 3600 + 1800 assert card["total_runtime_s"] == 5400 # 3600 + 1800
def test_create_collection_rejects_duplicate_name(db, monkeypatch): def test_create_collection_rejects_duplicate_name(db, monkeypatch, override_dep):
# Same-named collections conflate in Plex, so a create with an existing name (case-insensitive) is # Same-named collections conflate in Plex, so a create with an existing name (case-insensitive) is
# rejected 409 — BEFORE any Plex call (the guard runs first, so the DB lane can assert it). # rejected 409 — BEFORE any Plex call (the guard runs first, so the DB lane can assert it).
monkeypatch.setattr(state, "_configured_cache", True) monkeypatch.setattr(state, "_configured_cache", True)
@@ -146,21 +148,18 @@ def test_create_collection_rejects_duplicate_name(db, monkeypatch):
user = User(email="dup@example.com") user = User(email="dup@example.com")
db.add(user) db.add(user)
db.commit() db.commit()
app.dependency_overrides[get_db] = lambda: db override_dep(get_db, db.info["request"])
app.dependency_overrides[current_user] = lambda: user override_dep(current_user, lambda: user)
app.dependency_overrides[admin_user] = lambda: user override_dep(admin_user, lambda: user)
try: r = TestClient(app).post(
r = TestClient(app).post( "/api/plex/collections",
"/api/plex/collections", json={"library": "1", "title": "marvel", "item_rating_key": "m1"},
json={"library": "1", "title": "marvel", "item_rating_key": "m1"}, )
) assert r.status_code == 409, r.text
assert r.status_code == 409, r.text assert r.json()["detail"] == {"code": "collection", "reason": "name_taken"}
assert r.json()["detail"] == {"code": "collection", "reason": "name_taken"}
finally:
app.dependency_overrides.clear()
def test_collection_detail_hero_and_ordered_members(db, monkeypatch): def test_collection_detail_hero_and_ordered_members(db, monkeypatch, override_dep):
# The Collection info page: hero aggregates (year range, most-restrictive cert, total runtime) + # The Collection info page: hero aggregates (year range, most-restrictive cert, total runtime) +
# members ordered by release year then title. # members ordered by release year then title.
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True) lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
@@ -195,7 +194,7 @@ def test_collection_detail_hero_and_ordered_members(db, monkeypatch):
user = User(email="cd@example.com") user = User(email="cd@example.com")
db.add(user) db.add(user)
db.commit() db.commit()
d = _get(db, user, monkeypatch, "/api/plex/collections/A").json() d = _get(db, user, monkeypatch, override_dep, "/api/plex/collections/A").json()
assert d["title"] == "Coll A" and d["summary"] == "S" assert d["title"] == "Coll A" and d["summary"] == "S"
assert d["year_min"] == 1992 and d["year_max"] == 1996 assert d["year_min"] == 1992 and d["year_max"] == 1996
assert d["content_rating"] == "PG" # most restrictive of {G, PG} assert d["content_rating"] == "PG" # most restrictive of {G, PG}
@@ -270,38 +269,32 @@ def _seed_editable(db):
return lib, user return lib, user
def _admin_client(db, user, monkeypatch, stub, resync=_resync_ok): def _admin_client(db, user, monkeypatch, override_dep, stub, resync=_resync_ok):
monkeypatch.setattr(state, "_configured_cache", True) monkeypatch.setattr(state, "_configured_cache", True)
monkeypatch.setattr(coll_routes, "PlexClient", lambda db: stub) monkeypatch.setattr(coll_routes, "PlexClient", lambda db: stub)
monkeypatch.setattr(coll_routes.plex_sync, "resync_collection", resync) monkeypatch.setattr(coll_routes.plex_sync, "resync_collection", resync)
app.dependency_overrides[get_db] = lambda: db override_dep(get_db, db.info["request"])
app.dependency_overrides[current_user] = lambda: user override_dep(current_user, lambda: user)
app.dependency_overrides[admin_user] = lambda: user override_dep(admin_user, lambda: user)
return TestClient(app) return TestClient(app)
def test_rename_writes_through_to_plex(db, monkeypatch): def test_rename_writes_through_to_plex(db, monkeypatch, override_dep):
_lib, user = _seed_editable(db) _lib, user = _seed_editable(db)
stub = _CollStub() stub = _CollStub()
client = _admin_client(db, user, monkeypatch, stub) client = _admin_client(db, user, monkeypatch, override_dep, stub)
try: r = client.patch("/api/plex/collections/A", json={"title": "Renamed"})
r = client.patch("/api/plex/collections/A", json={"title": "Renamed"}) assert r.status_code == 200, r.text
assert r.status_code == 200, r.text assert "rename_collection" in stub.calls
assert "rename_collection" in stub.calls
finally:
app.dependency_overrides.clear()
def test_remove_item_writes_through_to_plex(db, monkeypatch): def test_remove_item_writes_through_to_plex(db, monkeypatch, override_dep):
_lib, user = _seed_editable(db) _lib, user = _seed_editable(db)
stub = _CollStub() stub = _CollStub()
client = _admin_client(db, user, monkeypatch, stub) client = _admin_client(db, user, monkeypatch, override_dep, stub)
try: r = client.delete("/api/plex/collections/A/items/m1")
r = client.delete("/api/plex/collections/A/items/m1") assert r.status_code == 200, r.text
assert r.status_code == 200, r.text assert "remove_collection_item" in stub.calls
assert "remove_collection_item" in stub.calls
finally:
app.dependency_overrides.clear()
def _resync_create(db, plex, lib, rk): def _resync_create(db, plex, lib, rk):
@@ -312,25 +305,22 @@ def _resync_create(db, plex, lib, rk):
return col return col
def test_create_writes_through_to_plex(db, monkeypatch): def test_create_writes_through_to_plex(db, monkeypatch, override_dep):
_lib, user = _seed_editable(db) _lib, user = _seed_editable(db)
stub = _CollStub() stub = _CollStub()
client = _admin_client(db, user, monkeypatch, stub, resync=_resync_create) client = _admin_client(db, user, monkeypatch, override_dep, stub, resync=_resync_create)
try: r = client.post(
r = client.post( "/api/plex/collections",
"/api/plex/collections", json={"library": "1", "title": "Fresh Coll", "item_rating_key": "m1"},
json={"library": "1", "title": "Fresh Coll", "item_rating_key": "m1"}, )
) assert r.status_code == 200, r.text
assert r.status_code == 200, r.text assert "create_collection" in stub.calls
assert "create_collection" in stub.calls body = r.json()
body = r.json() assert body["id"] == "NEW" # the stub's new ratingKey
assert body["id"] == "NEW" # the stub's new ratingKey assert body["editable"] is True # create marks the new collection editable
assert body["editable"] is True # create marks the new collection editable
finally:
app.dependency_overrides.clear()
def test_create_resync_failure_is_422(db, monkeypatch): def test_create_resync_failure_is_422(db, monkeypatch, override_dep):
# The collection was created in Plex (wrote=True) but the follow-up resync read failed → 422 with # The collection was created in Plex (wrote=True) but the follow-up resync read failed → 422 with
# the honest "reconcile on the next sync" detail, not a false "write failed". # the honest "reconcile on the next sync" detail, not a false "write failed".
_lib, user = _seed_editable(db) _lib, user = _seed_editable(db)
@@ -339,34 +329,28 @@ def test_create_resync_failure_is_422(db, monkeypatch):
def _boom(db_, plex, lib_, rk): def _boom(db_, plex, lib_, rk):
raise PlexError("read failed") raise PlexError("read failed")
client = _admin_client(db, user, monkeypatch, stub, resync=_boom) client = _admin_client(db, user, monkeypatch, override_dep, stub, resync=_boom)
try: r = client.post(
r = client.post( "/api/plex/collections",
"/api/plex/collections", json={"library": "1", "title": "Fresh Coll", "item_rating_key": "m1"},
json={"library": "1", "title": "Fresh Coll", "item_rating_key": "m1"}, )
) assert r.status_code == 422, r.text
assert r.status_code == 422, r.text assert r.json()["detail"] == {"code": "refused", "reason": "collection_pending_sync"}
assert "reconcile" in r.json()["detail"].lower() assert "create_collection" in stub.calls
assert "create_collection" in stub.calls
finally:
app.dependency_overrides.clear()
def test_mutation_failure_returns_422_write_failed(db, monkeypatch): def test_mutation_failure_returns_422_write_failed(db, monkeypatch, override_dep):
# The Plex mutation itself raised → the write never landed → 422 "Plex write failed", and resync # The Plex mutation itself raised → the write never landed → 422 "Plex write failed", and resync
# is never reached (stub.calls holds only the mutating call). # is never reached (stub.calls holds only the mutating call).
_lib, user = _seed_editable(db) _lib, user = _seed_editable(db)
stub = _CollStub(fail_on="rename_collection") stub = _CollStub(fail_on="rename_collection")
client = _admin_client(db, user, monkeypatch, stub) client = _admin_client(db, user, monkeypatch, override_dep, stub)
try: r = client.patch("/api/plex/collections/A", json={"title": "Renamed"})
r = client.patch("/api/plex/collections/A", json={"title": "Renamed"}) assert r.status_code == 422, r.text
assert r.status_code == 422, r.text assert r.json()["detail"] == {"code": "refused", "reason": "plex_write_failed"}
assert "write failed" in r.json()["detail"].lower()
finally:
app.dependency_overrides.clear()
def test_resync_failure_after_successful_write_is_422_and_rolls_back(db, monkeypatch): def test_resync_failure_after_successful_write_is_422_and_rolls_back(db, monkeypatch, override_dep):
# The mutation landed on Plex; only the follow-up refresh READ failed. We must (a) NOT claim a # The mutation landed on Plex; only the follow-up refresh READ failed. We must (a) NOT claim a
# write failure and NOT use a 5xx (which the frontend treats as offline) → 422 with a "reconcile # write failure and NOT use a 5xx (which the frontend treats as offline) → 422 with a "reconcile
# on the next sync" detail, and (b) roll back any partial local upsert the resync had flushed. # on the next sync" detail, and (b) roll back any partial local upsert the resync had flushed.
@@ -385,21 +369,18 @@ def test_resync_failure_after_successful_write_is_422_and_rolls_back(db, monkeyp
db_.flush() db_.flush()
raise PlexError("read failed") raise PlexError("read failed")
client = _admin_client(db, user, monkeypatch, stub, resync=_resync_boom) client = _admin_client(db, user, monkeypatch, override_dep, stub, resync=_resync_boom)
try: r = client.patch("/api/plex/collections/A", json={"title": "Renamed"})
r = client.patch("/api/plex/collections/A", json={"title": "Renamed"}) assert r.status_code == 422, r.text
assert r.status_code == 422, r.text assert r.json()["detail"] == {"code": "refused", "reason": "collection_pending_sync"}
assert "reconcile" in r.json()["detail"].lower() assert "rename_collection" in stub.calls # the write DID happen
assert "rename_collection" in stub.calls # the write DID happen
finally:
app.dependency_overrides.clear()
row = ( row = (
db.execute(select(PlexCollection).where(PlexCollection.rating_key == "A")).scalars().first() db.execute(select(PlexCollection).where(PlexCollection.rating_key == "A")).scalars().first()
) )
assert row.title == "My Coll" # the partial resync upsert was rolled back assert row.title == "My Coll" # the partial resync upsert was rolled back
def test_show_collection_gets_montage_art_from_shows(db, monkeypatch): def test_show_collection_gets_montage_art_from_shows(db, monkeypatch, override_dep):
# A show-library collection must get its montage from PlexShow members (else it renders blank) — the # A show-library collection must get its montage from PlexShow members (else it renders blank) — the
# A3 fix that unions movie + show art. Runtime stays 0 (shows have no single duration). # A3 fix that unions movie + show art. Runtime stays 0 (shows have no single duration).
lib = PlexLibrary(plex_key="2", title="Shows", kind="show", enabled=True) lib = PlexLibrary(plex_key="2", title="Shows", kind="show", enabled=True)
@@ -431,7 +412,9 @@ def test_show_collection_gets_montage_art_from_shows(db, monkeypatch):
db.commit() db.commit()
card = next( card = next(
c c
for c in _get(db, user, monkeypatch, "/api/plex/collections").json()["collections"] for c in _get(db, user, monkeypatch, override_dep, "/api/plex/collections").json()[
"collections"
]
if c["id"] == "S" if c["id"] == "S"
) )
assert card["art"] == ["s2", "s1"] # show members, best-rated first assert card["art"] == ["s2", "s1"] # show members, best-rated first
+14 -17
View File
@@ -191,14 +191,11 @@ def test_enrich_people_batch_persists_detail(db):
# ---- item_detail serving (DB lane) --------------------------------------------------------------- # ---- item_detail serving (DB lane) ---------------------------------------------------------------
def _get(db, user, monkeypatch, path): def _get(db, user, monkeypatch, override_dep, path):
monkeypatch.setattr(state, "_configured_cache", True) # pass the request gate monkeypatch.setattr(state, "_configured_cache", True) # pass the request gate
app.dependency_overrides[get_db] = lambda: db override_dep(get_db, db.info["request"])
app.dependency_overrides[current_user] = lambda: user override_dep(current_user, lambda: user)
try: return TestClient(app).get(path)
return TestClient(app).get(path)
finally:
app.dependency_overrides.clear()
def _user(db, email): def _user(db, email):
@@ -208,7 +205,7 @@ def _user(db, email):
return u return u
def test_item_detail_movie_serves_from_row_despite_live_outage(db, monkeypatch): def test_item_detail_movie_serves_from_row_despite_live_outage(db, monkeypatch, override_dep):
"""An enriched movie serves cast/IMDb/tagline from the DB row even when the live Plex call raises — """An enriched movie serves cast/IMDb/tagline from the DB row even when the live Plex call raises —
only markers/streams (which need the live meta) degrade.""" only markers/streams (which need the live meta) degrade."""
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True) lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
@@ -237,7 +234,7 @@ def test_item_detail_movie_serves_from_row_despite_live_outage(db, monkeypatch):
db.commit() db.commit()
monkeypatch.setattr(detail_routes, "PlexClient", lambda db: _StubClient(raise_=True)) monkeypatch.setattr(detail_routes, "PlexClient", lambda db: _StubClient(raise_=True))
resp = _get(db, user, monkeypatch, "/api/plex/item/m1") resp = _get(db, user, monkeypatch, override_dep, "/api/plex/item/m1")
assert resp.status_code == 200, resp.text assert resp.status_code == 200, resp.text
body = resp.json() body = resp.json()
assert body["imdb_rating"] == 8.7 assert body["imdb_rating"] == 8.7
@@ -249,7 +246,7 @@ def test_item_detail_movie_serves_from_row_despite_live_outage(db, monkeypatch):
assert body["markers"] == [] # live fetch failed → markers degrade, but detail survived assert body["markers"] == [] # live fetch failed → markers degrade, but detail survived
def test_item_detail_movie_lazy_fills_when_unenriched(db, monkeypatch): def test_item_detail_movie_lazy_fills_when_unenriched(db, monkeypatch, override_dep):
"""A not-yet-enriched movie is filled from the live meta on view (and persisted for next time).""" """A not-yet-enriched movie is filled from the live meta on view (and persisted for next time)."""
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True) lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
db.add(lib) db.add(lib)
@@ -261,7 +258,7 @@ def test_item_detail_movie_lazy_fills_when_unenriched(db, monkeypatch):
db.commit() db.commit()
monkeypatch.setattr(detail_routes, "PlexClient", lambda db: _StubClient(RICH_META)) monkeypatch.setattr(detail_routes, "PlexClient", lambda db: _StubClient(RICH_META))
resp = _get(db, user, monkeypatch, "/api/plex/item/m1") resp = _get(db, user, monkeypatch, override_dep, "/api/plex/item/m1")
assert resp.status_code == 200, resp.text assert resp.status_code == 200, resp.text
assert resp.json()["imdb_id"] == "tt0068646" assert resp.json()["imdb_id"] == "tt0068646"
db.refresh(mv) db.refresh(mv)
@@ -270,7 +267,7 @@ def test_item_detail_movie_lazy_fills_when_unenriched(db, monkeypatch):
assert [c["name"] for c in mv.full_cast] == ["Marlon Brando", "Al Pacino"] assert [c["name"] for c in mv.full_cast] == ["Marlon Brando", "Al Pacino"]
def test_item_detail_episode_serves_rich_from_live(db, monkeypatch): def test_item_detail_episode_serves_rich_from_live(db, monkeypatch, override_dep):
"""Episodes are never enriched (not in the grid, no HU overlay), so their detail is parsed from the """Episodes are never enriched (not in the grid, no HU overlay), so their detail is parsed from the
live meta the endpoint fetches for markers/streams.""" live meta the endpoint fetches for markers/streams."""
lib = PlexLibrary(plex_key="2", title="Shows", kind="show", enabled=True) lib = PlexLibrary(plex_key="2", title="Shows", kind="show", enabled=True)
@@ -288,7 +285,7 @@ def test_item_detail_episode_serves_rich_from_live(db, monkeypatch):
db.commit() db.commit()
monkeypatch.setattr(detail_routes, "PlexClient", lambda db: _StubClient(RICH_META)) monkeypatch.setattr(detail_routes, "PlexClient", lambda db: _StubClient(RICH_META))
resp = _get(db, user, monkeypatch, "/api/plex/item/e1") resp = _get(db, user, monkeypatch, override_dep, "/api/plex/item/e1")
assert resp.status_code == 200, resp.text assert resp.status_code == 200, resp.text
body = resp.json() body = resp.json()
assert body["imdb_id"] == "tt0068646" # from the live meta, not the (NULL) row assert body["imdb_id"] == "tt0068646" # from the live meta, not the (NULL) row
@@ -297,7 +294,7 @@ def test_item_detail_episode_serves_rich_from_live(db, monkeypatch):
assert ep.full_cast is None # episodes are not persisted/enriched assert ep.full_cast is None # episodes are not persisted/enriched
def test_show_detail_serves_from_row(db, monkeypatch): def test_show_detail_serves_from_row(db, monkeypatch, override_dep):
"""A show serves cast/IMDb from the DB row; `related` failing (Plex down) must not drop the detail.""" """A show serves cast/IMDb from the DB row; `related` failing (Plex down) must not drop the detail."""
lib = PlexLibrary(plex_key="2", title="Shows", kind="show", enabled=True) lib = PlexLibrary(plex_key="2", title="Shows", kind="show", enabled=True)
db.add(lib) db.add(lib)
@@ -324,7 +321,7 @@ def test_show_detail_serves_from_row(db, monkeypatch):
raise RuntimeError("plex unavailable") raise RuntimeError("plex unavailable")
monkeypatch.setattr(detail_routes, "_show_enrichment", _boom) monkeypatch.setattr(detail_routes, "_show_enrichment", _boom)
resp = _get(db, user, monkeypatch, "/api/plex/show/s1") resp = _get(db, user, monkeypatch, override_dep, "/api/plex/show/s1")
assert resp.status_code == 200, resp.text assert resp.status_code == 200, resp.text
body = resp.json()["show"] body = resp.json()["show"]
assert body["imdb_rating"] == 9.1 assert body["imdb_rating"] == 9.1
@@ -334,7 +331,7 @@ def test_show_detail_serves_from_row(db, monkeypatch):
assert resp.json()["related"] == [] assert resp.json()["related"] == []
def test_show_detail_lazy_fills_when_unenriched(db, monkeypatch): def test_show_detail_lazy_fills_when_unenriched(db, monkeypatch, override_dep):
"""A not-yet-enriched show is filled from the live meta on first view (imdb/full_cast persisted + """A not-yet-enriched show is filled from the live meta on first view (imdb/full_cast persisted +
stamped) so subsequent views serve from the row.""" stamped) so subsequent views serve from the row."""
lib = PlexLibrary(plex_key="2", title="Shows", kind="show", enabled=True) lib = PlexLibrary(plex_key="2", title="Shows", kind="show", enabled=True)
@@ -348,7 +345,7 @@ def test_show_detail_lazy_fills_when_unenriched(db, monkeypatch):
# _show_enrichment returns (live meta, no related) → the fill branch runs. # _show_enrichment returns (live meta, no related) → the fill branch runs.
monkeypatch.setattr(detail_routes, "_show_enrichment", lambda db_, rk: (RICH_META, [])) monkeypatch.setattr(detail_routes, "_show_enrichment", lambda db_, rk: (RICH_META, []))
resp = _get(db, user, monkeypatch, "/api/plex/show/s1") resp = _get(db, user, monkeypatch, override_dep, "/api/plex/show/s1")
assert resp.status_code == 200, resp.text assert resp.status_code == 200, resp.text
assert resp.json()["show"]["imdb_id"] == "tt0068646" assert resp.json()["show"]["imdb_id"] == "tt0068646"
db.refresh(show) db.refresh(show)
+43 -36
View File
@@ -120,24 +120,21 @@ def _seed(db):
return user return user
def _get(db, user, monkeypatch, path): def _get(db, user, monkeypatch, override_dep, path):
monkeypatch.setattr(state, "_configured_cache", True) monkeypatch.setattr(state, "_configured_cache", True)
app.dependency_overrides[get_db] = lambda: db override_dep(get_db, db.info["request"])
app.dependency_overrides[current_user] = lambda: user override_dep(current_user, lambda: user)
try: client = TestClient(app) # no context manager: skip lifespan
client = TestClient(app) # no context manager: skip lifespan return client.get(path)
return client.get(path)
finally:
app.dependency_overrides.clear()
def _ids(cards): def _ids(cards):
return [c["id"] for c in cards] return [c["id"] for c in cards]
def test_discover_shape_and_recently_added_order(db, monkeypatch): def test_discover_shape_and_recently_added_order(db, monkeypatch, override_dep):
user = _seed(db) user = _seed(db)
resp = _get(db, user, monkeypatch, "/api/plex/discover?scope=both") resp = _get(db, user, monkeypatch, override_dep, "/api/plex/discover?scope=both")
assert resp.status_code == 200, resp.text assert resp.status_code == 200, resp.text
d = resp.json() d = resp.json()
assert set(d) >= {"continue_watching", "recently_added", "genres", "because_you_watched"} assert set(d) >= {"continue_watching", "recently_added", "genres", "because_you_watched"}
@@ -148,19 +145,19 @@ def test_discover_shape_and_recently_added_order(db, monkeypatch):
assert genres.get("Drama") == 2 and genres.get("Comedy") == 1 assert genres.get("Drama") == 2 and genres.get("Comedy") == 1
def test_genre_tiles_carry_poster_art(db, monkeypatch): def test_genre_tiles_carry_poster_art(db, monkeypatch, override_dep):
# Each genre tile carries up to 4 poster rating_keys for the 2×2 collage (top_art_keys): the top # Each genre tile carries up to 4 poster rating_keys for the 2×2 collage (top_art_keys): the top
# movies in that genre, best-rated then newest. No ratings here → newest-first; shows are excluded. # movies in that genre, best-rated then newest. No ratings here → newest-first; shows are excluded.
user = _seed(db) user = _seed(db)
d = _get(db, user, monkeypatch, "/api/plex/discover?scope=both").json() d = _get(db, user, monkeypatch, override_dep, "/api/plex/discover?scope=both").json()
art = {g["value"]: g["art"] for g in d["genres"]} art = {g["value"]: g["art"] for g in d["genres"]}
assert art["Drama"] == ["m1", "m3"] # both Drama movies, m1 (newer) first assert art["Drama"] == ["m1", "m3"] # both Drama movies, m1 (newer) first
assert art["Comedy"] == ["m2"] assert art["Comedy"] == ["m2"]
def test_continue_watching_movies_and_shows(db, monkeypatch): def test_continue_watching_movies_and_shows(db, monkeypatch, override_dep):
user = _seed(db) user = _seed(db)
d = _get(db, user, monkeypatch, "/api/plex/discover?scope=both").json() d = _get(db, user, monkeypatch, override_dep, "/api/plex/discover?scope=both").json()
ids = _ids(d["continue_watching"]) ids = _ids(d["continue_watching"])
# The in-progress movie and the show with an in-progress episode are both present; the watched # The in-progress movie and the show with an in-progress episode are both present; the watched
# anchor movie (m1) is not. Order is most-recently-progressed first (the show's ep beats m3). # anchor movie (m1) is not. Order is most-recently-progressed first (the show's ep beats m3).
@@ -168,7 +165,7 @@ def test_continue_watching_movies_and_shows(db, monkeypatch):
assert "m1" not in ids assert "m1" not in ids
def test_continue_watching_null_status_in_hidden_out(db, monkeypatch): def test_continue_watching_null_status_in_hidden_out(db, monkeypatch, override_dep):
# A started movie whose PlexState.status is NULL is still "in progress": `NULL NOT IN (...)` is NULL # A started movie whose PlexState.status is NULL is still "in progress": `NULL NOT IN (...)` is NULL
# (not TRUE), so a bare notin_ would wrongly drop it — the or_(is_(None), notin_) branch keeps it. # (not TRUE), so a bare notin_ would wrongly drop it — the or_(is_(None), notin_) branch keeps it.
# A hidden started movie must be excluded. # A hidden started movie must be excluded.
@@ -207,13 +204,15 @@ def test_continue_watching_null_status_in_hidden_out(db, monkeypatch):
db.commit() db.commit()
ids = _ids( ids = _ids(
_get(db, user, monkeypatch, "/api/plex/discover?scope=movie").json()["continue_watching"] _get(db, user, monkeypatch, override_dep, "/api/plex/discover?scope=movie").json()[
"continue_watching"
]
) )
assert "mn" in ids # NULL-status started movie IS in progress assert "mn" in ids # NULL-status started movie IS in progress
assert "mh" not in ids # hidden is excluded assert "mh" not in ids # hidden is excluded
def test_continue_watching_excludes_fully_watched_show(db, monkeypatch): def test_continue_watching_excludes_fully_watched_show(db, monkeypatch, override_dep):
# A show whose every episode is watched is DONE, not in-progress: the `watched < total` guard must # A show whose every episode is watched is DONE, not in-progress: the `watched < total` guard must
# keep it off the Continue-watching rail (a bare `watched > 0` would wrongly resurface it). # keep it off the Continue-watching rail (a bare `watched > 0` would wrongly resurface it).
lib_s = PlexLibrary(plex_key="2", title="Shows", kind="show", enabled=True) lib_s = PlexLibrary(plex_key="2", title="Shows", kind="show", enabled=True)
@@ -248,14 +247,16 @@ def test_continue_watching_excludes_fully_watched_show(db, monkeypatch):
db.commit() db.commit()
ids = _ids( ids = _ids(
_get(db, user, monkeypatch, "/api/plex/discover?scope=show").json()["continue_watching"] _get(db, user, monkeypatch, override_dep, "/api/plex/discover?scope=show").json()[
"continue_watching"
]
) )
assert "shw" not in ids # fully watched → excluded from continue-watching assert "shw" not in ids # fully watched → excluded from continue-watching
def test_because_you_watched_resolves_in_library_recs(db, monkeypatch): def test_because_you_watched_resolves_in_library_recs(db, monkeypatch, override_dep):
user = _seed(db) user = _seed(db)
d = _get(db, user, monkeypatch, "/api/plex/discover?scope=both").json() d = _get(db, user, monkeypatch, override_dep, "/api/plex/discover?scope=both").json()
bw = d["because_you_watched"] bw = d["because_you_watched"]
assert bw is not None assert bw is not None
assert bw["anchor"]["id"] == "m1" and bw["anchor"]["title"] == "Anchor Movie" assert bw["anchor"]["id"] == "m1" and bw["anchor"]["title"] == "Anchor Movie"
@@ -263,34 +264,36 @@ def test_because_you_watched_resolves_in_library_recs(db, monkeypatch):
assert _ids(bw["items"]) == ["m2"] assert _ids(bw["items"]) == ["m2"]
def test_because_you_watched_null_without_history(db, monkeypatch): def test_because_you_watched_null_without_history(db, monkeypatch, override_dep):
_seed(db) _seed(db)
fresh = User(email="fresh-discover@example.com") fresh = User(email="fresh-discover@example.com")
db.add(fresh) db.add(fresh)
db.commit() db.commit()
d = _get(db, fresh, monkeypatch, "/api/plex/discover?scope=both").json() d = _get(db, fresh, monkeypatch, override_dep, "/api/plex/discover?scope=both").json()
# A user with no watched-and-enriched movie gets no recommendation rail. # A user with no watched-and-enriched movie gets no recommendation rail.
assert d["because_you_watched"] is None assert d["because_you_watched"] is None
assert d["continue_watching"] == [] assert d["continue_watching"] == []
def test_surprise_scope_and_bound(db, monkeypatch): def test_surprise_scope_and_bound(db, monkeypatch, override_dep):
user = _seed(db) user = _seed(db)
resp = _get(db, user, monkeypatch, "/api/plex/surprise?scope=movie&limit=2") resp = _get(db, user, monkeypatch, override_dep, "/api/plex/surprise?scope=movie&limit=2")
assert resp.status_code == 200, resp.text assert resp.status_code == 200, resp.text
items = resp.json()["items"] items = resp.json()["items"]
assert len(items) <= 2 assert len(items) <= 2
assert all(c["type"] == "movie" for c in items) # scope=movie excludes shows assert all(c["type"] == "movie" for c in items) # scope=movie excludes shows
def test_recently_watched_movies_and_episodes(db, monkeypatch): def test_recently_watched_movies_and_episodes(db, monkeypatch, override_dep):
# The rail lists last-fully-watched titles newest-first: a watched MOVIE as its own card, a show as # The rail lists last-fully-watched titles newest-first: a watched MOVIE as its own card, a show as
# the specific watched EPISODE (with its show title). _seed watches m1 (movie); add a watched episode. # the specific watched EPISODE (with its show title). _seed watches m1 (movie); add a watched episode.
user = _seed(db) user = _seed(db)
ep2 = db.execute(select(PlexItem).where(PlexItem.rating_key == "sh1e2")).scalars().one() ep2 = db.execute(select(PlexItem).where(PlexItem.rating_key == "sh1e2")).scalars().one()
db.add(PlexState(user_id=user.id, item_id=ep2.id, status="watched", watched_at=NOW)) db.add(PlexState(user_id=user.id, item_id=ep2.id, status="watched", watched_at=NOW))
db.commit() db.commit()
rw = _get(db, user, monkeypatch, "/api/plex/discover?scope=both").json()["recently_watched"] rw = _get(db, user, monkeypatch, override_dep, "/api/plex/discover?scope=both").json()[
"recently_watched"
]
by_id = {c["id"]: c for c in rw} by_id = {c["id"]: c for c in rw}
assert "m1" in by_id and by_id["m1"]["type"] == "movie" assert "m1" in by_id and by_id["m1"]["type"] == "movie"
assert "sh1e2" in by_id and by_id["sh1e2"]["type"] == "episode" assert "sh1e2" in by_id and by_id["sh1e2"]["type"] == "episode"
@@ -299,11 +302,13 @@ def test_recently_watched_movies_and_episodes(db, monkeypatch):
assert set(_ids(rw)) == {"m1", "sh1e2"} assert set(_ids(rw)) == {"m1", "sh1e2"}
def test_surprise_excludes_watched_and_caps_per_kind(db, monkeypatch): def test_surprise_excludes_watched_and_caps_per_kind(db, monkeypatch, override_dep):
# Surprise surfaces only UNSEEN titles: the watched movie (m1) is out; the unwatched (m2), the # Surprise surfaces only UNSEEN titles: the watched movie (m1) is out; the unwatched (m2), the
# in-progress movie (m3) and the partially-watched show (sh1) are eligible. Capped at 3 per kind. # in-progress movie (m3) and the partially-watched show (sh1) are eligible. Capped at 3 per kind.
user = _seed(db) user = _seed(db)
items = _get(db, user, monkeypatch, "/api/plex/surprise?scope=both").json()["items"] items = _get(db, user, monkeypatch, override_dep, "/api/plex/surprise?scope=both").json()[
"items"
]
ids = _ids(items) ids = _ids(items)
assert "m1" not in ids # watched → excluded assert "m1" not in ids # watched → excluded
assert set(ids) <= {"m2", "m3", "sh1"} # only unseen titles assert set(ids) <= {"m2", "m3", "sh1"} # only unseen titles
@@ -311,19 +316,21 @@ def test_surprise_excludes_watched_and_caps_per_kind(db, monkeypatch):
assert len([c for c in items if c["type"] == "show"]) <= 3 assert len([c for c in items if c["type"] == "show"]) <= 3
def test_discover_scope_movie_excludes_shows(db, monkeypatch): def test_discover_scope_movie_excludes_shows(db, monkeypatch, override_dep):
user = _seed(db) user = _seed(db)
d = _get(db, user, monkeypatch, "/api/plex/discover?scope=movie").json() d = _get(db, user, monkeypatch, override_dep, "/api/plex/discover?scope=movie").json()
assert all(c["type"] == "movie" for c in d["recently_added"]) assert all(c["type"] == "movie" for c in d["recently_added"])
# The show drops out of continue watching under the movie scope. # The show drops out of continue watching under the movie scope.
assert _ids(d["continue_watching"]) == ["m3"] assert _ids(d["continue_watching"]) == ["m3"]
def test_filters_narrow_every_discover_rail(db, monkeypatch): def test_filters_narrow_every_discover_rail(db, monkeypatch, override_dep):
# The sidebar filters constrain the Discover rails just like the Browse grid (ask 3). A genre=Drama # The sidebar filters constrain the Discover rails just like the Browse grid (ask 3). A genre=Drama
# filter keeps only the Drama titles across every rail; the genre-less show and the Comedy rec drop. # filter keeps only the Drama titles across every rail; the genre-less show and the Comedy rec drop.
user = _seed(db) user = _seed(db)
d = _get(db, user, monkeypatch, "/api/plex/discover?scope=both&genres=Drama").json() d = _get(
db, user, monkeypatch, override_dep, "/api/plex/discover?scope=both&genres=Drama"
).json()
# Recently-added: m1 + m3 (Drama) survive; Comedy m2 and the genre-less show sh1 are filtered out. # Recently-added: m1 + m3 (Drama) survive; Comedy m2 and the genre-less show sh1 are filtered out.
assert set(_ids(d["recently_added"])) == {"m1", "m3"} assert set(_ids(d["recently_added"])) == {"m1", "m3"}
# Continue-watching: only the in-progress Drama movie remains (the show has no genres → excluded). # Continue-watching: only the in-progress Drama movie remains (the show has no genres → excluded).
@@ -334,10 +341,10 @@ def test_filters_narrow_every_discover_rail(db, monkeypatch):
assert d["because_you_watched"] is None assert d["because_you_watched"] is None
def test_filters_narrow_surprise(db, monkeypatch): def test_filters_narrow_surprise(db, monkeypatch, override_dep):
user = _seed(db) user = _seed(db)
items = _get(db, user, monkeypatch, "/api/plex/surprise?scope=movie&genres=Comedy").json()[ items = _get(
"items" db, user, monkeypatch, override_dep, "/api/plex/surprise?scope=movie&genres=Comedy"
] ).json()["items"]
# Only the one Comedy movie qualifies under the filter. # Only the one Comedy movie qualifies under the filter.
assert _ids(items) == ["m2"] assert _ids(items) == ["m2"]
+9 -15
View File
@@ -16,7 +16,7 @@ from app.main import app
from app.models import PlexItem, PlexLibrary, PlexShow, User from app.models import PlexItem, PlexLibrary, PlexShow, User
def test_facets_returns_studio_vocabulary(db, monkeypatch): def test_facets_returns_studio_vocabulary(db, monkeypatch, override_dep):
"""E5 S7: /facets returns the studio vocabulary (distinct studio + count) for the sidebar picker.""" """E5 S7: /facets returns the studio vocabulary (distinct studio + count) for the sidebar picker."""
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True) lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
db.add(lib) db.add(lib)
@@ -33,18 +33,15 @@ def test_facets_returns_studio_vocabulary(db, monkeypatch):
db.add(user) db.add(user)
db.commit() db.commit()
monkeypatch.setattr(state, "_configured_cache", True) monkeypatch.setattr(state, "_configured_cache", True)
app.dependency_overrides[get_db] = lambda: db override_dep(get_db, db.info["request"])
app.dependency_overrides[current_user] = lambda: user override_dep(current_user, lambda: user)
try: resp = TestClient(app).get("/api/plex/facets?scope=movie")
resp = TestClient(app).get("/api/plex/facets?scope=movie")
finally:
app.dependency_overrides.clear()
assert resp.status_code == 200, resp.text assert resp.status_code == 200, resp.text
studios = {s["value"]: s["count"] for s in resp.json()["studios"]} studios = {s["value"]: s["count"] for s in resp.json()["studios"]}
assert studios == {"Pixar": 2, "A24": 1} assert studios == {"Pixar": 2, "A24": 1}
def test_facets_survives_json_null_genres(db, monkeypatch): def test_facets_survives_json_null_genres(db, monkeypatch, override_dep):
lib = PlexLibrary(plex_key="1", title="Shows", kind="show", enabled=True) lib = PlexLibrary(plex_key="1", title="Shows", kind="show", enabled=True)
db.add(lib) db.add(lib)
db.flush() db.flush()
@@ -61,13 +58,10 @@ def test_facets_survives_json_null_genres(db, monkeypatch):
db.execute(text("UPDATE plex_shows SET genres = 'null'::jsonb WHERE rating_key = 's1'")) db.execute(text("UPDATE plex_shows SET genres = 'null'::jsonb WHERE rating_key = 's1'"))
db.commit() db.commit()
app.dependency_overrides[get_db] = lambda: db override_dep(get_db, db.info["request"])
app.dependency_overrides[current_user] = lambda: user override_dep(current_user, lambda: user)
try: client = TestClient(app) # no context-manager: skip lifespan (scheduler/HLS sweep)
client = TestClient(app) # no context-manager: skip lifespan (scheduler/HLS sweep) resp = client.get("/api/plex/facets?scope=show")
resp = client.get("/api/plex/facets?scope=show")
finally:
app.dependency_overrides.clear()
assert resp.status_code == 200, resp.text assert resp.status_code == 200, resp.text
# The scalar-null show contributes no genres — and, crucially, doesn't abort the whole endpoint. # The scalar-null show contributes no genres — and, crucially, doesn't abort the whole endpoint.
+41 -24
View File
@@ -100,14 +100,11 @@ def _seed(db):
return {"user": user} return {"user": user}
def _get(db, user, monkeypatch, path): def _get(db, user, monkeypatch, override_dep, path):
monkeypatch.setattr(state, "_configured_cache", True) monkeypatch.setattr(state, "_configured_cache", True)
app.dependency_overrides[get_db] = lambda: db override_dep(get_db, db.info["request"])
app.dependency_overrides[current_user] = lambda: user override_dep(current_user, lambda: user)
try: return TestClient(app).get(path)
return TestClient(app).get(path)
finally:
app.dependency_overrides.clear()
def _ids(body): def _ids(body):
@@ -115,55 +112,75 @@ def _ids(body):
class TestFieldScopedSearch: class TestFieldScopedSearch:
def test_everywhere_matches_every_facet(self, db, monkeypatch): def test_everywhere_matches_every_facet(self, db, monkeypatch, override_dep):
s = _seed(db) s = _seed(db)
body = _get(db, s["user"], monkeypatch, "/api/plex/library?q=zephyr&field=all").json() body = _get(
db, s["user"], monkeypatch, override_dep, "/api/plex/library?q=zephyr&field=all"
).json()
# title (mt), cast (mp), summary (ms) all land in the item grid; the episode is grouped out # title (mt), cast (mp), summary (ms) all land in the item grid; the episode is grouped out
assert _ids(body) == ["mp", "ms", "mt"] assert _ids(body) == ["mp", "ms", "mt"]
assert [e["title"] for e in body["episodes"]] == ["Zephyr Episode"] assert [e["title"] for e in body["episodes"]] == ["Zephyr Episode"]
def test_field_all_is_identical_to_default(self, db, monkeypatch): def test_field_all_is_identical_to_default(self, db, monkeypatch, override_dep):
s = _seed(db) s = _seed(db)
default = _get(db, s["user"], monkeypatch, "/api/plex/library?q=zephyr").json() default = _get(
scoped = _get(db, s["user"], monkeypatch, "/api/plex/library?q=zephyr&field=all").json() db, s["user"], monkeypatch, override_dep, "/api/plex/library?q=zephyr"
).json()
scoped = _get(
db, s["user"], monkeypatch, override_dep, "/api/plex/library?q=zephyr&field=all"
).json()
assert _ids(default) == _ids(scoped) == ["mp", "ms", "mt"] assert _ids(default) == _ids(scoped) == ["mp", "ms", "mt"]
assert default["total"] == scoped["total"] assert default["total"] == scoped["total"]
assert [e["id"] for e in default["episodes"]] == [e["id"] for e in scoped["episodes"]] assert [e["id"] for e in default["episodes"]] == [e["id"] for e in scoped["episodes"]]
def test_title_scope_narrows_to_title_match(self, db, monkeypatch): def test_title_scope_narrows_to_title_match(self, db, monkeypatch, override_dep):
s = _seed(db) s = _seed(db)
body = _get(db, s["user"], monkeypatch, "/api/plex/library?q=zephyr&field=title").json() body = _get(
db, s["user"], monkeypatch, override_dep, "/api/plex/library?q=zephyr&field=title"
).json()
assert _ids(body) == ["mt"] assert _ids(body) == ["mt"]
assert body["total"] == 1 assert body["total"] == 1
assert body["episodes"] == [] # non-episode field scopes suppress the episode section assert body["episodes"] == [] # non-episode field scopes suppress the episode section
def test_people_scope_narrows_to_cast_match(self, db, monkeypatch): def test_people_scope_narrows_to_cast_match(self, db, monkeypatch, override_dep):
s = _seed(db) s = _seed(db)
body = _get(db, s["user"], monkeypatch, "/api/plex/library?q=zephyr&field=people").json() body = _get(
db, s["user"], monkeypatch, override_dep, "/api/plex/library?q=zephyr&field=people"
).json()
assert _ids(body) == ["mp"] assert _ids(body) == ["mp"]
def test_summary_scope_narrows_to_description_match(self, db, monkeypatch): def test_summary_scope_narrows_to_description_match(self, db, monkeypatch, override_dep):
s = _seed(db) s = _seed(db)
body = _get(db, s["user"], monkeypatch, "/api/plex/library?q=zephyr&field=summary").json() body = _get(
db, s["user"], monkeypatch, override_dep, "/api/plex/library?q=zephyr&field=summary"
).json()
assert _ids(body) == ["ms"] assert _ids(body) == ["ms"]
def test_episode_scope_empties_grid_and_returns_episode(self, db, monkeypatch): def test_episode_scope_empties_grid_and_returns_episode(self, db, monkeypatch, override_dep):
s = _seed(db) s = _seed(db)
body = _get(db, s["user"], monkeypatch, "/api/plex/library?q=zephyr&field=episode").json() body = _get(
db, s["user"], monkeypatch, override_dep, "/api/plex/library?q=zephyr&field=episode"
).json()
assert body["items"] == [] assert body["items"] == []
assert body["total"] == 0 assert body["total"] == 0
assert [e["title"] for e in body["episodes"]] == ["Zephyr Episode"] assert [e["title"] for e in body["episodes"]] == ["Zephyr Episode"]
def test_episode_scope_needs_shows_in_scope(self, db, monkeypatch): def test_episode_scope_needs_shows_in_scope(self, db, monkeypatch, override_dep):
s = _seed(db) s = _seed(db)
body = _get( body = _get(
db, s["user"], monkeypatch, "/api/plex/library?q=zephyr&field=episode&scope=movie" db,
s["user"],
monkeypatch,
override_dep,
"/api/plex/library?q=zephyr&field=episode&scope=movie",
).json() ).json()
# movie-only scope has no show libraries -> no episodes to surface, and the grid stays empty # movie-only scope has no show libraries -> no episodes to surface, and the grid stays empty
assert body["items"] == [] assert body["items"] == []
assert body["episodes"] == [] assert body["episodes"] == []
def test_unknown_field_falls_back_to_everywhere(self, db, monkeypatch): def test_unknown_field_falls_back_to_everywhere(self, db, monkeypatch, override_dep):
s = _seed(db) s = _seed(db)
body = _get(db, s["user"], monkeypatch, "/api/plex/library?q=zephyr&field=bogus").json() body = _get(
db, s["user"], monkeypatch, override_dep, "/api/plex/library?q=zephyr&field=bogus"
).json()
assert _ids(body) == ["mp", "ms", "mt"] assert _ids(body) == ["mp", "ms", "mt"]
+80
View File
@@ -0,0 +1,80 @@
"""The near-end checkpoint that finishes a Plex item.
- Exactly one scrobble per finish, even when two near-end checkpoints race (the 10 s tick and the
debounced `seeked` flush arrive together): the transition is decided by a conditional write, not a
read taken before it.
- The finish supersedes every resume push still in flight, so none can land at Plex after the
scrobble and revive a resume point on a watched item.
"""
import threading
from datetime import datetime, timedelta, timezone
from sqlalchemy import select
from app.db import SessionLocal
from app.models import PlexItem, PlexLibrary, PlexState
from app.routes.plex import playback
def _item(db) -> PlexItem:
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
db.add(lib)
db.flush()
item = PlexItem(rating_key="m1", library_id=lib.id, kind="movie", title="A Movie")
db.add(item)
db.commit()
return item
def test_two_racing_finishes_flip_the_row_once(db, make_user):
user = make_user()
item = _item(db)
db.add(PlexState(user_id=user.id, item_id=item.id, status="new", position_seconds=5000))
db.commit()
now = datetime.now(timezone.utc)
first, second = SessionLocal(), SessionLocal()
try:
assert playback._mark_finished(first, user.id, item.id, now) is True # holds the row lock
out: list[bool] = []
racer = threading.Thread(
target=lambda: out.append(playback._mark_finished(second, user.id, item.id, now))
)
racer.start()
racer.join(0.5)
assert racer.is_alive(), "the second writer must wait on the first one's row lock"
first.commit()
racer.join(5)
second.commit()
finally:
first.close()
second.close()
assert out == [False], "the second checkpoint saw the committed finish: no second scrobble"
def test_a_first_ever_finish_inserts_and_counts_once(db, make_user):
user = make_user()
item = _item(db)
now = datetime.now(timezone.utc)
assert playback._mark_finished(db, user.id, item.id, now) is True
db.commit()
assert playback._mark_finished(db, user.id, item.id, now + timedelta(seconds=10)) is False
db.commit()
st = db.execute(select(PlexState).where(PlexState.item_id == item.id)).scalars().one()
assert (st.status, st.position_seconds) == ("watched", 0)
def test_the_finish_supersedes_a_resume_push_still_in_flight(db, as_user, make_user, monkeypatch):
user = make_user()
item = _item(db)
monkeypatch.setattr(playback.plex_stream, "touch_session", lambda uid, key: None)
earlier = datetime.now(timezone.utc) - timedelta(seconds=5)
# A resume push was scheduled a moment ago and has not reached Plex yet.
assert playback._should_push_resume(user.id, item.id, earlier, final=True)
r = as_user(user).post(
"/api/plex/item/m1/progress", json={"position_seconds": 2995, "duration_seconds": 3000}
)
assert r.json() == {"ok": True, "status": "watched", "position_seconds": 0}
assert playback._resume_push_superseded(user.id, item.id, earlier) is True
+35 -24
View File
@@ -38,6 +38,9 @@ class _Proc:
return 0 return 0
VIEWER = 41 # the id of the account the test client is signed in as
class _Session: class _Session:
def __init__(self, session_id: str, directory, running: bool = False, audio_langs=None): def __init__(self, session_id: str, directory, running: bool = False, audio_langs=None):
self.id = session_id self.id = session_id
@@ -59,14 +62,11 @@ def sessions(monkeypatch):
@pytest.fixture @pytest.fixture
def client(monkeypatch): def client(monkeypatch, override_dep):
monkeypatch.setattr(state, "_configured_cache", True) monkeypatch.setattr(state, "_configured_cache", True)
user = User(email="hls@example.com") user = User(id=VIEWER, email="hls@example.com")
app.dependency_overrides[current_user] = lambda: user override_dep(current_user, lambda: user)
try: return TestClient(app)
yield TestClient(app)
finally:
app.dependency_overrides.pop(current_user, None)
def test_a_segment_is_served_from_the_session_the_url_names(client, tmp_path, sessions): def test_a_segment_is_served_from_the_session_the_url_names(client, tmp_path, sessions):
@@ -74,12 +74,23 @@ def test_a_segment_is_served_from_the_session_the_url_names(client, tmp_path, se
d.mkdir() d.mkdir()
(d / "seg_11.ts").write_bytes(b"") # its predecessor, so seg_12 is the next one (d / "seg_11.ts").write_bytes(b"") # its predecessor, so seg_12 is the next one
(d / "seg_12.ts").write_bytes(b"mine") (d / "seg_12.ts").write_bytes(b"mine")
sessions["m1"] = _Session("sid-a", d) sessions[(VIEWER, "m1")] = _Session("sid-a", d)
r = client.get("/api/plex/stream/m1/hls/sid-a/seg_12.ts") r = client.get("/api/plex/stream/m1/hls/sid-a/seg_12.ts")
assert r.status_code == 200, r.text assert r.status_code == 200, r.text
assert r.content == b"mine" assert r.content == b"mine"
def test_another_viewers_session_of_the_same_item_is_not_this_viewers(client, tmp_path, sessions):
"""Sessions are per viewer: a second account playing the same film has its own, and knowing its
id does not make it yours — resolution is by (viewer, item) before the id is compared."""
d = tmp_path / "m1_u7_0_0_+0.00"
d.mkdir()
(d / "index.m3u8").write_bytes(b"#EXTM3U")
sessions[(7, "m1")] = _Session("sid-a", d)
r = client.get("/api/plex/stream/m1/hls/sid-a/index.m3u8")
assert r.status_code == 404, r.text
def test_a_segment_of_a_replaced_session_is_a_404_not_the_new_sessions_bytes( def test_a_segment_of_a_replaced_session_is_a_404_not_the_new_sessions_bytes(
client, tmp_path, sessions client, tmp_path, sessions
): ):
@@ -89,7 +100,7 @@ def test_a_segment_of_a_replaced_session_is_a_404_not_the_new_sessions_bytes(
theirs.mkdir() theirs.mkdir()
(theirs / "seg_11.ts").write_bytes(b"") (theirs / "seg_11.ts").write_bytes(b"")
(theirs / "seg_12.ts").write_bytes(b"theirs") (theirs / "seg_12.ts").write_bytes(b"theirs")
sessions["m1"] = _Session("sid-b", theirs) sessions[(VIEWER, "m1")] = _Session("sid-b", theirs)
r = client.get("/api/plex/stream/m1/hls/sid-a/seg_12.ts") r = client.get("/api/plex/stream/m1/hls/sid-a/seg_12.ts")
assert r.status_code == 404, r.text assert r.status_code == 404, r.text
assert b"theirs" not in r.content assert b"theirs" not in r.content
@@ -105,7 +116,7 @@ def test_a_playlist_with_no_live_session_is_a_404_not_a_silent_restart_at_zero(
monkeypatch.setattr( monkeypatch.setattr(
plex_stream, plex_stream,
"start_session", "start_session",
lambda *a, **k: started.append(a[2] if len(a) > 2 else 0.0), lambda *a, **k: started.append(a[3] if len(a) > 3 else 0.0),
) )
r = client.get("/api/plex/stream/m1/hls/sid-gone/index.m3u8") r = client.get("/api/plex/stream/m1/hls/sid-gone/index.m3u8")
assert r.status_code == 404, r.text assert r.status_code == 404, r.text
@@ -122,7 +133,7 @@ def test_a_segment_that_will_never_arrive_fails_at_once(client, tmp_path, sessio
d = tmp_path / "m1_0_0_+0.00" d = tmp_path / "m1_0_0_+0.00"
d.mkdir() d.mkdir()
(d / "seg_0.ts").write_bytes(b"x") # the session is producing; seg_99999 is simply not next (d / "seg_0.ts").write_bytes(b"x") # the session is producing; seg_99999 is simply not next
sessions["m1"] = _Session("sid-a", d, running=True) sessions[(VIEWER, "m1")] = _Session("sid-a", d, running=True)
started = time.monotonic() started = time.monotonic()
r = client.get("/api/plex/stream/m1/hls/sid-a/seg_99999.ts") r = client.get("/api/plex/stream/m1/hls/sid-a/seg_99999.ts")
elapsed = time.monotonic() - started elapsed = time.monotonic() - started
@@ -150,7 +161,7 @@ def test_a_multi_audio_segment_beyond_the_region_fails_at_once_too(
d.mkdir() d.mkdir()
(d / "seg_0_0.ts").write_bytes(b"x") (d / "seg_0_0.ts").write_bytes(b"x")
(d / "seg_a0_0.ts").write_bytes(b"x") (d / "seg_a0_0.ts").write_bytes(b"x")
sessions["m1"] = _Session("sid-a", d, running=True, audio_langs=["hu", "en"]) sessions[(VIEWER, "m1")] = _Session("sid-a", d, running=True, audio_langs=["hu", "en"])
started = time.monotonic() started = time.monotonic()
r = client.get(f"/api/plex/stream/m1/hls/sid-a/{name}") r = client.get(f"/api/plex/stream/m1/hls/sid-a/{name}")
elapsed = time.monotonic() - started elapsed = time.monotonic() - started
@@ -166,7 +177,7 @@ def test_a_multi_audio_segment_whose_predecessor_exists_is_served(client, tmp_pa
d.mkdir() d.mkdir()
(d / "seg_a0_0.ts").write_bytes(b"x") (d / "seg_a0_0.ts").write_bytes(b"x")
(d / "seg_a0_1.ts").write_bytes(b"audio") (d / "seg_a0_1.ts").write_bytes(b"audio")
sessions["m1"] = _Session("sid-a", d, running=True, audio_langs=["hu", "en"]) sessions[(VIEWER, "m1")] = _Session("sid-a", d, running=True, audio_langs=["hu", "en"])
r = client.get("/api/plex/stream/m1/hls/sid-a/seg_a0_1.ts") r = client.get("/api/plex/stream/m1/hls/sid-a/seg_a0_1.ts")
assert r.status_code == 200, r.text assert r.status_code == 200, r.text
assert r.content == b"audio" assert r.content == b"audio"
@@ -179,7 +190,7 @@ def test_a_name_the_session_cannot_produce_fails_at_once(client, tmp_path, sessi
its playlists and its segments and nothing else, so any other name is answerable now.""" its playlists and its segments and nothing else, so any other name is answerable now."""
d = tmp_path / "m1_0_0_+0.00" d = tmp_path / "m1_0_0_+0.00"
d.mkdir() d.mkdir()
sessions["m1"] = _Session("sid-a", d, running=True) sessions[(VIEWER, "m1")] = _Session("sid-a", d, running=True)
started = time.monotonic() started = time.monotonic()
r = client.get("/api/plex/stream/m1/hls/sid-a/nope.m3u8") r = client.get("/api/plex/stream/m1/hls/sid-a/nope.m3u8")
elapsed = time.monotonic() - started elapsed = time.monotonic() - started
@@ -194,7 +205,7 @@ def test_an_absurd_segment_index_is_a_404_not_a_500(client, tmp_path, sessions,
subtitle routes were bounded for; the answer is the same 404 any other unproducible name gets.""" subtitle routes were bounded for; the answer is the same 404 any other unproducible name gets."""
d = tmp_path / "m1_0_0_+0.00" d = tmp_path / "m1_0_0_+0.00"
d.mkdir() d.mkdir()
sessions["m1"] = _Session("sid-a", d, running=True) sessions[(VIEWER, "m1")] = _Session("sid-a", d, running=True)
r = client.get(f"/api/plex/stream/m1/hls/sid-a/seg_{'9' * digits}.ts") r = client.get(f"/api/plex/stream/m1/hls/sid-a/seg_{'9' * digits}.ts")
assert r.status_code == 404, r.text assert r.status_code == 404, r.text
@@ -209,7 +220,7 @@ def test_a_session_torn_down_during_the_read_is_a_404_not_a_500(
d = tmp_path / "m1_0_0_+0.00" d = tmp_path / "m1_0_0_+0.00"
d.mkdir() d.mkdir()
(d / "seg_11.ts").write_bytes(b"") (d / "seg_11.ts").write_bytes(b"")
sessions["m1"] = _Session("sid-a", d) sessions[(VIEWER, "m1")] = _Session("sid-a", d)
# The file existed when the wait checked, and is gone by the time the route reads it. # The file existed when the wait checked, and is gone by the time the route reads it.
monkeypatch.setattr(plex_stream, "wait_for", lambda *a, **k: True) monkeypatch.setattr(plex_stream, "wait_for", lambda *a, **k: True)
r = client.get("/api/plex/stream/m1/hls/sid-a/seg_12.ts") r = client.get("/api/plex/stream/m1/hls/sid-a/seg_12.ts")
@@ -224,7 +235,7 @@ def test_a_dead_producer_is_not_waited_out(client, tmp_path, sessions):
d = tmp_path / "m1_0_0_+0.00" d = tmp_path / "m1_0_0_+0.00"
d.mkdir() d.mkdir()
(d / "seg_5.ts").write_bytes(b"x") (d / "seg_5.ts").write_bytes(b"x")
sessions["m1"] = _Session("sid-a", d, running=False) sessions[(VIEWER, "m1")] = _Session("sid-a", d, running=False)
started = time.monotonic() started = time.monotonic()
r = client.get("/api/plex/stream/m1/hls/sid-a/seg_6.ts") r = client.get("/api/plex/stream/m1/hls/sid-a/seg_6.ts")
elapsed = time.monotonic() - started elapsed = time.monotonic() - started
@@ -294,10 +305,10 @@ def test_a_name_only_the_other_argv_branch_writes_is_refused_at_once(tmp_path, c
30.0 s and `seg_a0_0.ts` 20.1 s to answer 404 (deep-review round 7).""" 30.0 s and `seg_a0_0.ts` 20.1 s to answer 404 (deep-review round 7)."""
single = tmp_path / "m1_0_0_+0.00" single = tmp_path / "m1_0_0_+0.00"
single.mkdir() single.mkdir()
sessions["m1"] = _Session("sid-a", single, running=True) sessions[(VIEWER, "m1")] = _Session("sid-a", single, running=True)
multi = tmp_path / "m2_0_multi_+0.00" multi = tmp_path / "m2_0_multi_+0.00"
multi.mkdir() multi.mkdir()
sessions["m2"] = _Session("sid-b", multi, running=True, audio_langs=["hu", "en"]) sessions[(VIEWER, "m2")] = _Session("sid-b", multi, running=True, audio_langs=["hu", "en"])
# Each of these belongs to the other shape (or to no shape at all) and must be refused without # Each of these belongs to the other shape (or to no shape at all) and must be refused without
# a wait — the assertion is the ELAPSED time as much as the status. # a wait — the assertion is the ELAPSED time as much as the status.
@@ -315,8 +326,8 @@ def test_a_name_only_the_other_argv_branch_writes_is_refused_at_once(tmp_path, c
) )
# And the control: the names each session CAN write are still waited for, not refused. # And the control: the names each session CAN write are still waited for, not refused.
assert plex_stream.artifact_may_arrive(sessions["m1"], "index.m3u8") assert plex_stream.artifact_may_arrive(sessions[(VIEWER, "m1")], "index.m3u8")
assert plex_stream.artifact_may_arrive(sessions["m1"], "seg_0.ts") assert plex_stream.artifact_may_arrive(sessions[(VIEWER, "m1")], "seg_0.ts")
assert plex_stream.artifact_may_arrive(sessions["m2"], "master.m3u8") assert plex_stream.artifact_may_arrive(sessions[(VIEWER, "m2")], "master.m3u8")
assert plex_stream.artifact_may_arrive(sessions["m2"], "stream_a1.m3u8") assert plex_stream.artifact_may_arrive(sessions[(VIEWER, "m2")], "stream_a1.m3u8")
assert plex_stream.artifact_may_arrive(sessions["m2"], "seg_a1_0.ts") assert plex_stream.artifact_may_arrive(sessions[(VIEWER, "m2")], "seg_a1_0.ts")
+12 -13
View File
@@ -41,34 +41,33 @@ def _seed(db):
return user return user
def _get(db, user, monkeypatch, path): def _get(db, user, monkeypatch, override_dep, path):
monkeypatch.setattr(state, "_configured_cache", True) monkeypatch.setattr(state, "_configured_cache", True)
app.dependency_overrides[get_db] = lambda: db override_dep(get_db, db.info["request"])
app.dependency_overrides[current_user] = lambda: user override_dep(current_user, lambda: user)
try: return TestClient(app).get(path)
return TestClient(app).get(path)
finally:
app.dependency_overrides.clear()
def _titles(resp): def _titles(resp):
return {i["title"] for i in resp.json()["items"]} return {i["title"] for i in resp.json()["items"]}
def test_keyword_filter_narrows_to_matching_rows(db, monkeypatch): def test_keyword_filter_narrows_to_matching_rows(db, monkeypatch, override_dep):
user = _seed(db) user = _seed(db)
resp = _get(db, user, monkeypatch, "/api/plex/library?scope=movie&keywords=heist") resp = _get(db, user, monkeypatch, override_dep, "/api/plex/library?scope=movie&keywords=heist")
assert resp.status_code == 200, resp.text assert resp.status_code == 200, resp.text
assert _titles(resp) == {"Heist Movie"} assert _titles(resp) == {"Heist Movie"}
def test_no_keyword_filter_returns_all(db, monkeypatch): def test_no_keyword_filter_returns_all(db, monkeypatch, override_dep):
user = _seed(db) user = _seed(db)
resp = _get(db, user, monkeypatch, "/api/plex/library?scope=movie") resp = _get(db, user, monkeypatch, override_dep, "/api/plex/library?scope=movie")
assert _titles(resp) == {"Heist Movie", "Space Movie", "No Keywords Movie"} assert _titles(resp) == {"Heist Movie", "Space Movie", "No Keywords Movie"}
def test_unknown_keyword_matches_nothing(db, monkeypatch): def test_unknown_keyword_matches_nothing(db, monkeypatch, override_dep):
user = _seed(db) user = _seed(db)
resp = _get(db, user, monkeypatch, "/api/plex/library?scope=movie&keywords=nonesuch") resp = _get(
db, user, monkeypatch, override_dep, "/api/plex/library?scope=movie&keywords=nonesuch"
)
assert resp.json()["items"] == [] assert resp.json()["items"] == []
+25 -24
View File
@@ -56,54 +56,55 @@ def _seed(db):
return user return user
def _get(db, user, monkeypatch, path): def _get(db, user, monkeypatch, override_dep, path):
monkeypatch.setattr( monkeypatch.setattr(
state, "_configured_cache", True state, "_configured_cache", True
) # the request gate 503s an unconfigured app ) # the request gate 503s an unconfigured app
app.dependency_overrides[get_db] = lambda: db override_dep(get_db, db.info["request"])
app.dependency_overrides[current_user] = lambda: user override_dep(current_user, lambda: user)
try: client = TestClient(app) # no context manager: skip lifespan
client = TestClient(app) # no context manager: skip lifespan return client.get(path)
return client.get(path)
finally:
app.dependency_overrides.clear()
def _titles(resp): def _titles(resp):
return {i["title"] for i in resp.json()["items"]} return {i["title"] for i in resp.json()["items"]}
def test_library_serves_active_language_titles(db, monkeypatch): def test_library_serves_active_language_titles(db, monkeypatch, override_dep):
user = _seed(db) user = _seed(db)
hu = _get(db, user, monkeypatch, "/api/plex/library?scope=movie&lang=hu") hu = _get(db, user, monkeypatch, override_dep, "/api/plex/library?scope=movie&lang=hu")
en = _get(db, user, monkeypatch, "/api/plex/library?scope=movie&lang=en") en = _get(db, user, monkeypatch, override_dep, "/api/plex/library?scope=movie&lang=en")
# HU serves the twin's Hungarian title; the EN-only row falls back to its English title. # HU serves the twin's Hungarian title; the EN-only row falls back to its English title.
assert _titles(hu) == {"Magyar Cím", "EN Only"} assert _titles(hu) == {"Magyar Cím", "EN Only"}
assert _titles(en) == {"English Title", "EN Only"} assert _titles(en) == {"English Title", "EN Only"}
def test_facets_vocabulary_is_language_specific(db, monkeypatch): def test_facets_vocabulary_is_language_specific(db, monkeypatch, override_dep):
user = _seed(db) user = _seed(db)
hu = _get(db, user, monkeypatch, "/api/plex/facets?scope=movie&lang=hu") hu = _get(db, user, monkeypatch, override_dep, "/api/plex/facets?scope=movie&lang=hu")
en = _get(db, user, monkeypatch, "/api/plex/facets?scope=movie&lang=en") en = _get(db, user, monkeypatch, override_dep, "/api/plex/facets?scope=movie&lang=en")
hu_genres = {g["value"] for g in hu.json()["genres"]} hu_genres = {g["value"] for g in hu.json()["genres"]}
en_genres = {g["value"] for g in en.json()["genres"]} en_genres = {g["value"] for g in en.json()["genres"]}
assert "Dráma" in hu_genres and "Drama" not in hu_genres assert "Dráma" in hu_genres and "Drama" not in hu_genres
assert "Drama" in en_genres and "Dráma" not in en_genres assert "Drama" in en_genres and "Dráma" not in en_genres
def test_genre_filter_matches_active_language_vocabulary(db, monkeypatch): def test_genre_filter_matches_active_language_vocabulary(db, monkeypatch, override_dep):
user = _seed(db) user = _seed(db)
# The HU genre chip filters the HU column; the same string against the EN column matches nothing. # The HU genre chip filters the HU column; the same string against the EN column matches nothing.
hit = _get(db, user, monkeypatch, "/api/plex/library?scope=movie&lang=hu&genres=Dráma") hit = _get(
miss = _get(db, user, monkeypatch, "/api/plex/library?scope=movie&lang=en&genres=Dráma") db, user, monkeypatch, override_dep, "/api/plex/library?scope=movie&lang=hu&genres=Dráma"
)
miss = _get(
db, user, monkeypatch, override_dep, "/api/plex/library?scope=movie&lang=en&genres=Dráma"
)
assert _titles(hit) == {"Magyar Cím"} assert _titles(hit) == {"Magyar Cím"}
assert miss.json()["items"] == [] assert miss.json()["items"] == []
def test_item_detail_language_and_rating_split(db, monkeypatch): def test_item_detail_language_and_rating_split(db, monkeypatch, override_dep):
user = _seed(db) user = _seed(db)
resp = _get(db, user, monkeypatch, "/api/plex/item/m1?lang=hu") resp = _get(db, user, monkeypatch, override_dep, "/api/plex/item/m1?lang=hu")
body = resp.json() body = resp.json()
assert body["title"] == "Magyar Cím" assert body["title"] == "Magyar Cím"
assert body["summary"] == "Magyar összefoglaló." assert body["summary"] == "Magyar összefoglaló."
@@ -112,15 +113,15 @@ def test_item_detail_language_and_rating_split(db, monkeypatch):
assert body["rating"] == 6.5 assert body["rating"] == 6.5
def test_hu_search_still_finds_en_only_titles(db, monkeypatch): def test_hu_search_still_finds_en_only_titles(db, monkeypatch, override_dep):
user = _seed(db) user = _seed(db)
# m2 ("EN Only") has no HU twin, so search_vector_hu carries no title tokens for it. HU-mode search # m2 ("EN Only") has no HU twin, so search_vector_hu carries no title tokens for it. HU-mode search
# must still match it via the EN vector (vector_match's union) — findable by its English title. # must still match it via the EN vector (vector_match's union) — findable by its English title.
resp = _get(db, user, monkeypatch, "/api/plex/library?scope=movie&lang=hu&q=Only") resp = _get(db, user, monkeypatch, override_dep, "/api/plex/library?scope=movie&lang=hu&q=Only")
assert "EN Only" in _titles(resp) assert "EN Only" in _titles(resp)
def test_rating_filter_prefers_imdb_over_audience(db, monkeypatch): def test_rating_filter_prefers_imdb_over_audience(db, monkeypatch, override_dep):
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True) lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
db.add(lib) db.add(lib)
db.flush() db.flush()
@@ -142,7 +143,7 @@ def test_rating_filter_prefers_imdb_over_audience(db, monkeypatch):
user = User(email="rating@example.com") user = User(email="rating@example.com")
db.add(user) db.add(user)
db.commit() db.commit()
hit = _get(db, user, monkeypatch, "/api/plex/library?scope=movie&rating_min=7") hit = _get(db, user, monkeypatch, override_dep, "/api/plex/library?scope=movie&rating_min=7")
assert "High IMDb" in _titles(hit) assert "High IMDb" in _titles(hit)
miss = _get(db, user, monkeypatch, "/api/plex/library?scope=movie&rating_min=9") miss = _get(db, user, monkeypatch, override_dep, "/api/plex/library?scope=movie&rating_min=9")
assert "High IMDb" not in _titles(miss) assert "High IMDb" not in _titles(miss)
+22 -13
View File
@@ -111,44 +111,53 @@ def _seed(db):
return user return user
def _get(db, user, monkeypatch, path): def _get(db, user, monkeypatch, override_dep, path):
monkeypatch.setattr(state, "_configured_cache", True) monkeypatch.setattr(state, "_configured_cache", True)
app.dependency_overrides[get_db] = lambda: db override_dep(get_db, db.info["request"])
app.dependency_overrides[current_user] = lambda: user override_dep(current_user, lambda: user)
try: return TestClient(app).get(path)
return TestClient(app).get(path)
finally:
app.dependency_overrides.clear()
def _ids(resp): def _ids(resp):
return [i["id"] for i in resp.json()["items"]] return [i["id"] for i in resp.json()["items"]]
def test_movies_ordered_by_last_watched_desc(db, monkeypatch): def test_movies_ordered_by_last_watched_desc(db, monkeypatch, override_dep):
user = _seed(db) user = _seed(db)
resp = _get( resp = _get(
db, user, monkeypatch, "/api/plex/library?scope=movie&sort=lastwatched&sort_dir=desc" db,
user,
monkeypatch,
override_dep,
"/api/plex/library?scope=movie&sort=lastwatched&sort_dir=desc",
) )
assert resp.status_code == 200, resp.text assert resp.status_code == 200, resp.text
# Newest resume, then the watched-only (coalesce → watched_at), then the older resume, then never. # Newest resume, then the watched-only (coalesce → watched_at), then the older resume, then never.
assert _ids(resp) == ["mo_new", "mo_watched", "mo_old", "mo_never"] assert _ids(resp) == ["mo_new", "mo_watched", "mo_old", "mo_never"]
def test_shows_ordered_by_last_watched_desc(db, monkeypatch): def test_shows_ordered_by_last_watched_desc(db, monkeypatch, override_dep):
user = _seed(db) user = _seed(db)
resp = _get( resp = _get(
db, user, monkeypatch, "/api/plex/library?scope=show&sort=lastwatched&sort_dir=desc" db,
user,
monkeypatch,
override_dep,
"/api/plex/library?scope=show&sort=lastwatched&sort_dir=desc",
) )
assert resp.status_code == 200, resp.text assert resp.status_code == 200, resp.text
# Recent resume first, then the watched-only show, then the never-watched one (nullslast). # Recent resume first, then the watched-only show, then the never-watched one (nullslast).
assert _ids(resp) == ["sh_recent", "sh_watched", "sh_never"] assert _ids(resp) == ["sh_recent", "sh_watched", "sh_never"]
def test_last_watched_mixed_scope_nullslast(db, monkeypatch): def test_last_watched_mixed_scope_nullslast(db, monkeypatch, override_dep):
user = _seed(db) user = _seed(db)
resp = _get( resp = _get(
db, user, monkeypatch, "/api/plex/library?scope=both&sort=lastwatched&sort_dir=desc" db,
user,
monkeypatch,
override_dep,
"/api/plex/library?scope=both&sort=lastwatched&sort_dir=desc",
) )
assert resp.status_code == 200, resp.text assert resp.status_code == 200, resp.text
ids = _ids(resp) ids = _ids(resp)
+8 -11
View File
@@ -25,17 +25,14 @@ from app.plex.query import content_libs
NAME = "Robert De Niro" NAME = "Robert De Niro"
def _get(db, user, monkeypatch, path): def _get(db, user, monkeypatch, override_dep, path):
monkeypatch.setattr(state, "_configured_cache", True) # pass the request gate monkeypatch.setattr(state, "_configured_cache", True) # pass the request gate
app.dependency_overrides[get_db] = lambda: db override_dep(get_db, db.info["request"])
app.dependency_overrides[current_user] = lambda: user override_dep(current_user, lambda: user)
try: return TestClient(app).get(path) # no lifespan (skip scheduler/HLS sweep)
return TestClient(app).get(path) # no lifespan (skip scheduler/HLS sweep)
finally:
app.dependency_overrides.clear()
def test_person_detail_returns_cast_and_director_credits(db, monkeypatch): def test_person_detail_returns_cast_and_director_credits(db, monkeypatch, override_dep):
movies_lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True) movies_lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
shows_lib = PlexLibrary(plex_key="2", title="Shows", kind="show", enabled=True) shows_lib = PlexLibrary(plex_key="2", title="Shows", kind="show", enabled=True)
off_lib = PlexLibrary(plex_key="3", title="Off", kind="movie", enabled=False) off_lib = PlexLibrary(plex_key="3", title="Off", kind="movie", enabled=False)
@@ -89,7 +86,7 @@ def test_person_detail_returns_cast_and_director_credits(db, monkeypatch):
db.add(PlexState(user_id=user.id, item_id=heat.id, status="watched")) db.add(PlexState(user_id=user.id, item_id=heat.id, status="watched"))
db.commit() db.commit()
resp = _get(db, user, monkeypatch, f"/api/plex/person/{NAME.replace(' ', '%20')}") resp = _get(db, user, monkeypatch, override_dep, f"/api/plex/person/{NAME.replace(' ', '%20')}")
assert resp.status_code == 200, resp.text assert resp.status_code == 200, resp.text
body = resp.json() body = resp.json()
@@ -106,7 +103,7 @@ def test_person_detail_returns_cast_and_director_credits(db, monkeypatch):
assert body["shows"][0]["type"] == "show" assert body["shows"][0]["type"] == "show"
def test_person_detail_unknown_name_is_empty(db, monkeypatch): def test_person_detail_unknown_name_is_empty(db, monkeypatch, override_dep):
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True) lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
db.add(lib) db.add(lib)
db.flush() db.flush()
@@ -123,7 +120,7 @@ def test_person_detail_unknown_name_is_empty(db, monkeypatch):
db.add(user) db.add(user)
db.commit() db.commit()
resp = _get(db, user, monkeypatch, "/api/plex/person/Ghost%20Actor") resp = _get(db, user, monkeypatch, override_dep, "/api/plex/person/Ghost%20Actor")
assert resp.status_code == 200, resp.text assert resp.status_code == 200, resp.text
body = resp.json() body = resp.json()
# An unknown name has no plex_people row → no bio, no titles (E5 S6 added the bio fields, all null). # An unknown name has no plex_people row → no bio, no titles (E5 S6 added the bio fields, all null).
+10 -13
View File
@@ -168,17 +168,14 @@ def test_enrich_person_noop_without_tmdb_id(db, monkeypatch):
assert plex_tmdb.enrich_person(db, person) is False assert plex_tmdb.enrich_person(db, person) is False
def _get(db, user, monkeypatch, path): def _get(db, user, monkeypatch, override_dep, path):
monkeypatch.setattr(state, "_configured_cache", True) monkeypatch.setattr(state, "_configured_cache", True)
app.dependency_overrides[get_db] = lambda: db override_dep(get_db, db.info["request"])
app.dependency_overrides[current_user] = lambda: user override_dep(current_user, lambda: user)
try: return TestClient(app).get(path)
return TestClient(app).get(path)
finally:
app.dependency_overrides.clear()
def test_person_detail_schedules_enrich_off_request_path(db, monkeypatch): def test_person_detail_schedules_enrich_off_request_path(db, monkeypatch, override_dep):
# The bio fetch runs OFF the request path (a background task after the response), so the serve never # The bio fetch runs OFF the request path (a background task after the response), so the serve never
# makes a live TMDB call. An un-enriched person (tmdb_person_id set + tmdb_enriched_at None) schedules # makes a live TMDB call. An un-enriched person (tmdb_person_id set + tmdb_enriched_at None) schedules
# the enrich and the page still renders immediately with whatever's in the row (bio still null). # the enrich and the page still renders immediately with whatever's in the row (bio still null).
@@ -196,7 +193,7 @@ def test_person_detail_schedules_enrich_off_request_path(db, monkeypatch):
monkeypatch.setattr(people, "_enrich_person_bg", lambda pid: scheduled.append(pid)) monkeypatch.setattr(people, "_enrich_person_bg", lambda pid: scheduled.append(pid))
people._enrich_inflight.clear() # ensure the per-person cooldown guard doesn't suppress this open people._enrich_inflight.clear() # ensure the per-person cooldown guard doesn't suppress this open
resp = _get(db, user, monkeypatch, "/api/plex/person/Lazy P") resp = _get(db, user, monkeypatch, override_dep, "/api/plex/person/Lazy P")
assert resp.status_code == 200, resp.text assert resp.status_code == 200, resp.text
assert resp.json()["biography"] is None # not enriched yet — the bio lands by the next open assert resp.json()["biography"] is None # not enriched yet — the bio lands by the next open
assert len(scheduled) == 1 # the enrich was scheduled off the request path assert len(scheduled) == 1 # the enrich was scheduled off the request path
@@ -260,7 +257,7 @@ def test_should_schedule_enrich_dedups_within_cooldown():
people._enrich_inflight.clear() people._enrich_inflight.clear()
def test_person_detail_no_enrich_when_already_enriched(db, monkeypatch): def test_person_detail_no_enrich_when_already_enriched(db, monkeypatch, override_dep):
# An already-enriched person (tmdb_enriched_at set) must NOT schedule another background fetch. # An already-enriched person (tmdb_enriched_at set) must NOT schedule another background fetch.
from datetime import datetime, timezone from datetime import datetime, timezone
@@ -284,13 +281,13 @@ def test_person_detail_no_enrich_when_already_enriched(db, monkeypatch):
scheduled: list[int] = [] scheduled: list[int] = []
monkeypatch.setattr(people, "_enrich_person_bg", lambda pid: scheduled.append(pid)) monkeypatch.setattr(people, "_enrich_person_bg", lambda pid: scheduled.append(pid))
resp = _get(db, user, monkeypatch, "/api/plex/person/Done P") resp = _get(db, user, monkeypatch, override_dep, "/api/plex/person/Done P")
assert resp.status_code == 200, resp.text assert resp.status_code == 200, resp.text
assert resp.json()["biography"] == "Bio." assert resp.json()["biography"] == "Bio."
assert scheduled == [] # already enriched → no background work assert scheduled == [] # already enriched → no background work
def test_person_detail_serves_bio_and_resolves_known_for(db, monkeypatch): def test_person_detail_serves_bio_and_resolves_known_for(db, monkeypatch, override_dep):
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True) lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
db.add(lib) db.add(lib)
db.flush() db.flush()
@@ -327,7 +324,7 @@ def test_person_detail_serves_bio_and_resolves_known_for(db, monkeypatch):
db.add(user) db.add(user)
db.commit() db.commit()
resp = _get(db, user, monkeypatch, "/api/plex/person/Actor X") resp = _get(db, user, monkeypatch, override_dep, "/api/plex/person/Actor X")
assert resp.status_code == 200, resp.text assert resp.status_code == 200, resp.text
j = resp.json() j = resp.json()
assert j["biography"] == "Bio here." assert j["biography"] == "Bio here."
+37 -52
View File
@@ -347,47 +347,41 @@ def _seed_classes(db):
return lib return lib
def _client(db, user, monkeypatch): def _client(db, user, monkeypatch, override_dep):
monkeypatch.setattr(state, "_configured_cache", True) monkeypatch.setattr(state, "_configured_cache", True)
app.dependency_overrides[get_db] = lambda: db override_dep(get_db, db.info["request"])
app.dependency_overrides[current_user] = lambda: user override_dep(current_user, lambda: user)
return TestClient(app) return TestClient(app)
def test_library_filters_by_playback_class(db, monkeypatch): def test_library_filters_by_playback_class(db, monkeypatch, override_dep):
_seed_classes(db) _seed_classes(db)
user = User(email="pclass@example.com") user = User(email="pclass@example.com")
db.add(user) db.add(user)
db.commit() db.commit()
client = _client(db, user, monkeypatch) client = _client(db, user, monkeypatch, override_dep)
try: # Software-only transcodes.
# Software-only transcodes. sw = client.get("/api/plex/library?scope=movie&playable=transcode_sw")
sw = client.get("/api/plex/library?scope=movie&playable=transcode_sw") assert sw.status_code == 200, sw.text
assert sw.status_code == 200, sw.text assert {i["id"] for i in sw.json()["items"]} == {"tsw"}
assert {i["id"] for i in sw.json()["items"]} == {"tsw"} # HW transcode + Direct Play, OR-combined.
# HW transcode + Direct Play, OR-combined. both = client.get("/api/plex/library?scope=movie&playable=transcode_hw,direct")
both = client.get("/api/plex/library?scope=movie&playable=transcode_hw,direct") assert {i["id"] for i in both.json()["items"]} == {"thw", "d1"}
assert {i["id"] for i in both.json()["items"]} == {"thw", "d1"} # The HW card carries the transcode_hw flag for the 4-way badge.
# The HW card carries the transcode_hw flag for the 4-way badge. card = next(i for i in both.json()["items"] if i["id"] == "thw")
card = next(i for i in both.json()["items"] if i["id"] == "thw") assert card["transcode_hw"] is True
assert card["transcode_hw"] is True # No filter → everything.
# No filter → everything. allm = client.get("/api/plex/library?scope=movie")
allm = client.get("/api/plex/library?scope=movie") assert {i["id"] for i in allm.json()["items"]} == {"d1", "r1", "thw", "tsw"}
assert {i["id"] for i in allm.json()["items"]} == {"d1", "r1", "thw", "tsw"}
finally:
app.dependency_overrides.clear()
def test_facets_count_playback_classes_in_severity_order(db, monkeypatch): def test_facets_count_playback_classes_in_severity_order(db, monkeypatch, override_dep):
_seed_classes(db) _seed_classes(db)
user = User(email="pclassfacet@example.com") user = User(email="pclassfacet@example.com")
db.add(user) db.add(user)
db.commit() db.commit()
client = _client(db, user, monkeypatch) client = _client(db, user, monkeypatch, override_dep)
try: resp = client.get("/api/plex/facets?scope=movie")
resp = client.get("/api/plex/facets?scope=movie")
finally:
app.dependency_overrides.clear()
assert resp.status_code == 200, resp.text assert resp.status_code == 200, resp.text
playable = resp.json()["playable"] playable = resp.json()["playable"]
# Fixed good→heavy order (not by count), one per seeded class. # Fixed good→heavy order (not by count), one per seeded class.
@@ -400,7 +394,7 @@ def test_facets_count_playback_classes_in_severity_order(db, monkeypatch):
} }
def test_unprobed_transcode_folds_into_sw(db, monkeypatch): def test_unprobed_transcode_folds_into_sw(db, monkeypatch, override_dep):
# transcode_hw NULL is the COMMON initial state (before the bit-depth backfill runs). The query's # transcode_hw NULL is the COMMON initial state (before the bit-depth backfill runs). The query's
# CASE has no explicit NULL arm — it relies on the `playable=='transcode'` else folding NULL in with # CASE has no explicit NULL arm — it relies on the `playable=='transcode'` else folding NULL in with
# software. Pin that fold for BOTH the filter and the facet, so a reorder can't silently drop the # software. Pin that fold for BOTH the filter and the facet, so a reorder can't silently drop the
@@ -422,20 +416,17 @@ def test_unprobed_transcode_folds_into_sw(db, monkeypatch):
user = User(email="pnull@example.com") user = User(email="pnull@example.com")
db.add(user) db.add(user)
db.commit() db.commit()
client = _client(db, user, monkeypatch) client = _client(db, user, monkeypatch, override_dep)
try: sw = client.get("/api/plex/library?scope=movie&playable=transcode_sw")
sw = client.get("/api/plex/library?scope=movie&playable=transcode_sw") assert {i["id"] for i in sw.json()["items"]} == {"tnull"} # NULL → SW bucket
assert {i["id"] for i in sw.json()["items"]} == {"tnull"} # NULL → SW bucket hw = client.get("/api/plex/library?scope=movie&playable=transcode_hw")
hw = client.get("/api/plex/library?scope=movie&playable=transcode_hw") assert hw.json()["items"] == [] # never HW
assert hw.json()["items"] == [] # never HW facets = client.get("/api/plex/facets?scope=movie")
facets = client.get("/api/plex/facets?scope=movie") counts = {p["value"]: p["count"] for p in facets.json()["playable"]}
counts = {p["value"]: p["count"] for p in facets.json()["playable"]} assert counts.get("transcode_sw") == 1 and counts.get("transcode_hw", 0) == 0
assert counts.get("transcode_sw") == 1 and counts.get("transcode_hw", 0) == 0
finally:
app.dependency_overrides.clear()
def test_facet_playable_self_excludes_the_active_selection(db, monkeypatch): def test_facet_playable_self_excludes_the_active_selection(db, monkeypatch, override_dep):
# A selected playable class must NOT narrow the playable facet's OWN counts (facet self-exclusion), # A selected playable class must NOT narrow the playable facet's OWN counts (facet self-exclusion),
# otherwise picking one class would collapse the list and you couldn't OR-add a second. Selecting # otherwise picking one class would collapse the list and you couldn't OR-add a second. Selecting
# 'direct' must still return all four classes at their full, unnarrowed counts. # 'direct' must still return all four classes at their full, unnarrowed counts.
@@ -443,17 +434,14 @@ def test_facet_playable_self_excludes_the_active_selection(db, monkeypatch):
user = User(email="pself@example.com") user = User(email="pself@example.com")
db.add(user) db.add(user)
db.commit() db.commit()
client = _client(db, user, monkeypatch) client = _client(db, user, monkeypatch, override_dep)
try: resp = client.get("/api/plex/facets?scope=movie&playable=direct")
resp = client.get("/api/plex/facets?scope=movie&playable=direct")
finally:
app.dependency_overrides.clear()
assert resp.status_code == 200, resp.text assert resp.status_code == 200, resp.text
counts = {p["value"]: p["count"] for p in resp.json()["playable"]} counts = {p["value"]: p["count"] for p in resp.json()["playable"]}
assert counts == {"direct": 1, "remux": 1, "transcode_hw": 1, "transcode_sw": 1} assert counts == {"direct": 1, "remux": 1, "transcode_hw": 1, "transcode_sw": 1}
def test_active_playable_narrows_the_other_facets(db, monkeypatch): def test_active_playable_narrows_the_other_facets(db, monkeypatch, override_dep):
# The flip side of self-exclusion: an active playable class DOES narrow the OTHER facet groups (so # The flip side of self-exclusion: an active playable class DOES narrow the OTHER facet groups (so
# e.g. the year range reflects only the selected class). Seed a Direct movie (2000) and a # e.g. the year range reflects only the selected class). Seed a Direct movie (2000) and a
# software-transcode movie (2020); selecting playable=direct must collapse the year facet to 2000. # software-transcode movie (2020); selecting playable=direct must collapse the year facet to 2000.
@@ -485,11 +473,8 @@ def test_active_playable_narrows_the_other_facets(db, monkeypatch):
user = User(email="pnarrow@example.com") user = User(email="pnarrow@example.com")
db.add(user) db.add(user)
db.commit() db.commit()
client = _client(db, user, monkeypatch) client = _client(db, user, monkeypatch, override_dep)
try: wide = client.get("/api/plex/facets?scope=movie").json()
wide = client.get("/api/plex/facets?scope=movie").json() narrow = client.get("/api/plex/facets?scope=movie&playable=direct").json()
narrow = client.get("/api/plex/facets?scope=movie&playable=direct").json()
finally:
app.dependency_overrides.clear()
assert (wide["year_min"], wide["year_max"]) == (2000, 2020) # both classes span the range assert (wide["year_min"], wide["year_max"]) == (2000, 2020) # both classes span the range
assert (narrow["year_min"], narrow["year_max"]) == (2000, 2000) # narrowed to the Direct subset assert (narrow["year_min"], narrow["year_max"]) == (2000, 2000) # narrowed to the Direct subset
+11 -14
View File
@@ -48,14 +48,14 @@ def _user(db, email="play@example.com"):
return u return u
def _post(db, user, monkeypatch, rating_key, *, captured): def _post(db, user, monkeypatch, override_dep, rating_key, *, captured):
"""Drive the endpoint with ffmpeg stubbed out. `start_session` records the `transcode` flag it """Drive the endpoint with ffmpeg stubbed out. `start_session` records the `transcode` flag it
was called with into `captured` and returns a stand-in session echoing it back.""" was called with into `captured` and returns a stand-in session echoing it back."""
monkeypatch.setattr(state, "_configured_cache", True) # pass the request gate monkeypatch.setattr(state, "_configured_cache", True) # pass the request gate
monkeypatch.setattr(plex_stream, "reap_idle", lambda: None) monkeypatch.setattr(plex_stream, "reap_idle", lambda: None)
monkeypatch.setattr(plex_paths, "local_media_path", lambda db_, fp: "/local/a.mkv") monkeypatch.setattr(plex_paths, "local_media_path", lambda db_, fp: "/local/a.mkv")
def fake_start(db_, it, start, audio, aoff, multi, transcode=False): def fake_start(db_, it, user_id, start, audio, aoff, multi, transcode=False):
captured["transcode"] = transcode captured["transcode"] = transcode
return SimpleNamespace( return SimpleNamespace(
id="sid", id="sid",
@@ -67,20 +67,17 @@ def _post(db, user, monkeypatch, rating_key, *, captured):
) )
monkeypatch.setattr(plex_stream, "start_session", fake_start) monkeypatch.setattr(plex_stream, "start_session", fake_start)
app.dependency_overrides[get_db] = lambda: db override_dep(get_db, db.info["request"])
app.dependency_overrides[current_user] = lambda: user override_dep(current_user, lambda: user)
try: return TestClient(app).post(f"/api/plex/stream/{rating_key}/session")
return TestClient(app).post(f"/api/plex/stream/{rating_key}/session")
finally:
app.dependency_overrides.clear()
def test_transcode_item_routes_to_an_hls_reencode_session(db, monkeypatch): def test_transcode_item_routes_to_an_hls_reencode_session(db, monkeypatch, override_dep):
# A non-h264 item (formerly HTTP 501) now plays: mode=hls, transcode=True threaded to start_session. # A non-h264 item (formerly HTTP 501) now plays: mode=hls, transcode=True threaded to start_session.
it = _item(db, playable="transcode") it = _item(db, playable="transcode")
user = _user(db) user = _user(db)
captured: dict = {} captured: dict = {}
resp = _post(db, user, monkeypatch, it.rating_key, captured=captured) resp = _post(db, user, monkeypatch, override_dep, it.rating_key, captured=captured)
assert resp.status_code == 200, resp.text assert resp.status_code == 200, resp.text
body = resp.json() body = resp.json()
assert body["mode"] == "hls" assert body["mode"] == "hls"
@@ -88,12 +85,12 @@ def test_transcode_item_routes_to_an_hls_reencode_session(db, monkeypatch):
assert captured["transcode"] is True # start_session was told to re-encode assert captured["transcode"] is True # start_session was told to re-encode
def test_remux_item_stays_a_stream_copy_session(db, monkeypatch): def test_remux_item_stays_a_stream_copy_session(db, monkeypatch, override_dep):
# A remux (h264) item takes the SAME HLS path but must NOT re-encode. # A remux (h264) item takes the SAME HLS path but must NOT re-encode.
it = _item(db, playable="remux", rating_key="m2") it = _item(db, playable="remux", rating_key="m2")
user = _user(db, "remux@example.com") user = _user(db, "remux@example.com")
captured: dict = {} captured: dict = {}
resp = _post(db, user, monkeypatch, it.rating_key, captured=captured) resp = _post(db, user, monkeypatch, override_dep, it.rating_key, captured=captured)
assert resp.status_code == 200, resp.text assert resp.status_code == 200, resp.text
body = resp.json() body = resp.json()
assert body["mode"] == "hls" assert body["mode"] == "hls"
@@ -101,12 +98,12 @@ def test_remux_item_stays_a_stream_copy_session(db, monkeypatch):
assert captured["transcode"] is False assert captured["transcode"] is False
def test_direct_item_never_opens_a_session(db, monkeypatch): def test_direct_item_never_opens_a_session(db, monkeypatch, override_dep):
# A browser-native file is served raw — start_session is never reached, so no transcode decision. # A browser-native file is served raw — start_session is never reached, so no transcode decision.
it = _item(db, playable="direct", rating_key="m3") it = _item(db, playable="direct", rating_key="m3")
user = _user(db, "direct@example.com") user = _user(db, "direct@example.com")
captured: dict = {} captured: dict = {}
resp = _post(db, user, monkeypatch, it.rating_key, captured=captured) resp = _post(db, user, monkeypatch, override_dep, it.rating_key, captured=captured)
assert resp.status_code == 200, resp.text assert resp.status_code == 200, resp.text
assert resp.json()["mode"] == "direct" assert resp.json()["mode"] == "direct"
assert "transcode" not in captured # start_session not called assert "transcode" not in captured # start_session not called
+81 -44
View File
@@ -96,27 +96,26 @@ def _seed(db):
return {"user": user, "movie_lib": movie_lib, "show_lib": show_lib, "show": show} return {"user": user, "movie_lib": movie_lib, "show_lib": show_lib, "show": show}
def _request(db, user, monkeypatch, method, path, json=None): def _request(db, user, monkeypatch, override_dep, method, path, json=None):
monkeypatch.setattr( monkeypatch.setattr(
state, "_configured_cache", True state, "_configured_cache", True
) # the request gate 503s an unconfigured app ) # the request gate 503s an unconfigured app
app.dependency_overrides[get_db] = lambda: db override_dep(get_db, db.info["request"])
app.dependency_overrides[current_user] = lambda: user override_dep(current_user, lambda: user)
try: client = TestClient(app) # no context manager: skip lifespan (scheduler/HLS sweep)
client = TestClient(app) # no context manager: skip lifespan (scheduler/HLS sweep) return client.request(method, path, json=json)
return client.request(method, path, json=json)
finally:
app.dependency_overrides.clear()
def _get(db, user, monkeypatch, path): def _get(db, user, monkeypatch, override_dep, path):
return _request(db, user, monkeypatch, "GET", path) return _request(db, user, monkeypatch, override_dep, "GET", path)
class TestUnifiedLibrary: class TestUnifiedLibrary:
def test_both_scope_unions_movies_and_shows_ordered_by_added(self, db, monkeypatch): def test_both_scope_unions_movies_and_shows_ordered_by_added(
self, db, monkeypatch, override_dep
):
s = _seed(db) s = _seed(db)
r = _get(db, s["user"], monkeypatch, "/api/plex/library?scope=both") r = _get(db, s["user"], monkeypatch, override_dep, "/api/plex/library?scope=both")
assert r.status_code == 200, r.text assert r.status_code == 200, r.text
body = r.json() body = r.json()
assert body["total"] == 3 # 2 movies + 1 show, unioned assert body["total"] == 3 # 2 movies + 1 show, unioned
@@ -127,43 +126,53 @@ class TestUnifiedLibrary:
assert show_item["season_count"] == 1 assert show_item["season_count"] == 1
assert next(i for i in body["items"] if i["id"] == "m1")["status"] == "watched" assert next(i for i in body["items"] if i["id"] == "m1")["status"] == "watched"
def test_scope_movie_only(self, db, monkeypatch): def test_scope_movie_only(self, db, monkeypatch, override_dep):
s = _seed(db) s = _seed(db)
body = _get(db, s["user"], monkeypatch, "/api/plex/library?scope=movie").json() body = _get(
db, s["user"], monkeypatch, override_dep, "/api/plex/library?scope=movie"
).json()
assert body["total"] == 2 assert body["total"] == 2
assert all(i["type"] == "movie" for i in body["items"]) assert all(i["type"] == "movie" for i in body["items"])
def test_scope_show_only(self, db, monkeypatch): def test_scope_show_only(self, db, monkeypatch, override_dep):
s = _seed(db) s = _seed(db)
body = _get(db, s["user"], monkeypatch, "/api/plex/library?scope=show").json() body = _get(db, s["user"], monkeypatch, override_dep, "/api/plex/library?scope=show").json()
assert [i["id"] for i in body["items"]] == ["s1"] assert [i["id"] for i in body["items"]] == ["s1"]
assert body["items"][0]["season_count"] == 1 assert body["items"][0]["season_count"] == 1
def test_watch_state_branches(self, db, monkeypatch): def test_watch_state_branches(self, db, monkeypatch, override_dep):
s = _seed(db) s = _seed(db)
# watched: only movie m1 (the show is in_progress, not fully watched) # watched: only movie m1 (the show is in_progress, not fully watched)
watched = _get(db, s["user"], monkeypatch, "/api/plex/library?show=watched").json() watched = _get(
db, s["user"], monkeypatch, override_dep, "/api/plex/library?show=watched"
).json()
assert [i["id"] for i in watched["items"]] == ["m1"] assert [i["id"] for i in watched["items"]] == ["m1"]
# in_progress: only the show (no movie is in progress) # in_progress: only the show (no movie is in progress)
inprog = _get(db, s["user"], monkeypatch, "/api/plex/library?show=in_progress").json() inprog = _get(
db, s["user"], monkeypatch, override_dep, "/api/plex/library?show=in_progress"
).json()
assert [i["id"] for i in inprog["items"]] == ["s1"] assert [i["id"] for i in inprog["items"]] == ["s1"]
# unwatched: only movie m2 (no state). The show is EXCLUDED — "unwatched" for a show means # unwatched: only movie m2 (no state). The show is EXCLUDED — "unwatched" for a show means
# 0 watched AND 0 in-progress episodes, and e1 is in-progress. (Locks the actual branch.) # 0 watched AND 0 in-progress episodes, and e1 is in-progress. (Locks the actual branch.)
unwatched = _get(db, s["user"], monkeypatch, "/api/plex/library?show=unwatched").json() unwatched = _get(
db, s["user"], monkeypatch, override_dep, "/api/plex/library?show=unwatched"
).json()
assert [i["id"] for i in unwatched["items"]] == ["m2"] assert [i["id"] for i in unwatched["items"]] == ["m2"]
def test_search_matches_by_rank_and_groups_episodes(self, db, monkeypatch): def test_search_matches_by_rank_and_groups_episodes(self, db, monkeypatch, override_dep):
s = _seed(db) s = _seed(db)
body = _get(db, s["user"], monkeypatch, "/api/plex/library?q=charlie").json() body = _get(db, s["user"], monkeypatch, override_dep, "/api/plex/library?q=charlie").json()
# only the show title matches in the item grid; the movies don't contain "charlie" # only the show title matches in the item grid; the movies don't contain "charlie"
assert [i["id"] for i in body["items"]] == ["s1"] assert [i["id"] for i in body["items"]] == ["s1"]
assert body["total"] == 1 assert body["total"] == 1
# matching episodes come back grouped in the separate episodes list # matching episodes come back grouped in the separate episodes list
assert sorted(e["title"] for e in body["episodes"]) == ["Charlie Finale", "Charlie Pilot"] assert sorted(e["title"] for e in body["episodes"]) == ["Charlie Finale", "Charlie Pilot"]
def test_pagination_caps_items_not_total(self, db, monkeypatch): def test_pagination_caps_items_not_total(self, db, monkeypatch, override_dep):
s = _seed(db) s = _seed(db)
body = _get(db, s["user"], monkeypatch, "/api/plex/library?limit=2&offset=0").json() body = _get(
db, s["user"], monkeypatch, override_dep, "/api/plex/library?limit=2&offset=0"
).json()
assert body["total"] == 3 assert body["total"] == 3
assert [i["id"] for i in body["items"]] == [ assert [i["id"] for i in body["items"]] == [
"s1", "s1",
@@ -267,7 +276,7 @@ class TestFacetsWithData:
JSONB unnest + year/rating/duration bounds + the watch-state join). The existing smoke test only JSONB unnest + year/rating/duration bounds + the watch-state join). The existing smoke test only
covers the null-genre guard; this locks the aggregated numbers with real data.""" covers the null-genre guard; this locks the aggregated numbers with real data."""
def test_facet_aggregation(self, db, monkeypatch): def test_facet_aggregation(self, db, monkeypatch, override_dep):
movie_lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True) movie_lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
show_lib = PlexLibrary(plex_key="2", title="Shows", kind="show", enabled=True) show_lib = PlexLibrary(plex_key="2", title="Shows", kind="show", enabled=True)
db.add_all([movie_lib, show_lib]) db.add_all([movie_lib, show_lib])
@@ -311,7 +320,7 @@ class TestFacetsWithData:
db.add(user) db.add(user)
db.commit() db.commit()
body = _get(db, user, monkeypatch, "/api/plex/facets?scope=both").json() body = _get(db, user, monkeypatch, override_dep, "/api/plex/facets?scope=both").json()
assert {g["value"]: g["count"] for g in body["genres"]} == {"Comedy": 2, "Drama": 2} assert {g["value"]: g["count"] for g in body["genres"]} == {"Comedy": 2, "Drama": 2}
assert {c["value"]: c["count"] for c in body["content_ratings"]} == { assert {c["value"]: c["count"] for c in body["content_ratings"]} == {
"PG": 1, "PG": 1,
@@ -332,7 +341,7 @@ class TestPlaylistAndCollectionEndpoints:
def _movie(self, db, lib_id, rk, title): def _movie(self, db, lib_id, rk, title):
return PlexItem(rating_key=rk, library_id=lib_id, kind="movie", title=title) return PlexItem(rating_key=rk, library_id=lib_id, kind="movie", title=title)
def test_playlist_crud_roundtrip(self, db, monkeypatch): def test_playlist_crud_roundtrip(self, db, monkeypatch, override_dep):
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True) lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
db.add(lib) db.add(lib)
db.flush() db.flush()
@@ -348,6 +357,7 @@ class TestPlaylistAndCollectionEndpoints:
db, db,
user, user,
monkeypatch, monkeypatch,
override_dep,
"POST", "POST",
"/api/plex/playlists", "/api/plex/playlists",
{"title": "Faves", "item_rating_key": "m1"}, {"title": "Faves", "item_rating_key": "m1"},
@@ -357,7 +367,9 @@ class TestPlaylistAndCollectionEndpoints:
assert created.json()["item_count"] == 1 assert created.json()["item_count"] == 1
# list -> the playlist with its count # list -> the playlist with its count
listed = _get(db, user, monkeypatch, "/api/plex/playlists").json()["playlists"] listed = _get(db, user, monkeypatch, override_dep, "/api/plex/playlists").json()[
"playlists"
]
assert [(p["id"], p["item_count"]) for p in listed] == [(pid, 1)] assert [(p["id"], p["item_count"]) for p in listed] == [(pid, 1)]
# add m2, then reorder to [m2, m1] # add m2, then reorder to [m2, m1]
@@ -365,6 +377,7 @@ class TestPlaylistAndCollectionEndpoints:
db, db,
user, user,
monkeypatch, monkeypatch,
override_dep,
"POST", "POST",
f"/api/plex/playlists/{pid}/items", f"/api/plex/playlists/{pid}/items",
{"item_rating_key": "m2"}, {"item_rating_key": "m2"},
@@ -374,24 +387,30 @@ class TestPlaylistAndCollectionEndpoints:
db, db,
user, user,
monkeypatch, monkeypatch,
override_dep,
"PUT", "PUT",
f"/api/plex/playlists/{pid}/order", f"/api/plex/playlists/{pid}/order",
{"item_rating_keys": ["m2", "m1"]}, {"item_rating_keys": ["m2", "m1"]},
) )
detail = _get(db, user, monkeypatch, f"/api/plex/playlists/{pid}").json() detail = _get(db, user, monkeypatch, override_dep, f"/api/plex/playlists/{pid}").json()
assert [it["id"] for it in detail["items"]] == ["m2", "m1"] # reorder applied, in order assert [it["id"] for it in detail["items"]] == ["m2", "m1"] # reorder applied, in order
# remove m1 -> one item left # remove m1 -> one item left
rm = _request(db, user, monkeypatch, "DELETE", f"/api/plex/playlists/{pid}/items/m1") rm = _request(
db, user, monkeypatch, override_dep, "DELETE", f"/api/plex/playlists/{pid}/items/m1"
)
assert rm.status_code == 200, rm.text assert rm.status_code == 200, rm.text
after = _get(db, user, monkeypatch, f"/api/plex/playlists/{pid}").json() after = _get(db, user, monkeypatch, override_dep, f"/api/plex/playlists/{pid}").json()
assert [it["id"] for it in after["items"]] == ["m2"] assert [it["id"] for it in after["items"]] == ["m2"]
# delete the playlist -> gone from the list # delete the playlist -> gone from the list
_request(db, user, monkeypatch, "DELETE", f"/api/plex/playlists/{pid}") _request(db, user, monkeypatch, override_dep, "DELETE", f"/api/plex/playlists/{pid}")
assert _get(db, user, monkeypatch, "/api/plex/playlists").json()["playlists"] == [] assert (
_get(db, user, monkeypatch, override_dep, "/api/plex/playlists").json()["playlists"]
== []
)
def test_playlist_bulk_add_and_remove(self, db, monkeypatch): def test_playlist_bulk_add_and_remove(self, db, monkeypatch, override_dep):
# Covers the bulk endpoints (add-bulk / remove-bulk) and pins the remove-bulk delete's # Covers the bulk endpoints (add-bulk / remove-bulk) and pins the remove-bulk delete's
# synchronize_session=False path, which the single add/remove roundtrip never exercises. # synchronize_session=False path, which the single add/remove roundtrip never exercises.
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True) lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
@@ -402,38 +421,42 @@ class TestPlaylistAndCollectionEndpoints:
db.add(user) db.add(user)
db.commit() db.commit()
created = _request(db, user, monkeypatch, "POST", "/api/plex/playlists", {"title": "Bulk"}) created = _request(
db, user, monkeypatch, override_dep, "POST", "/api/plex/playlists", {"title": "Bulk"}
)
pid = created.json()["id"] pid = created.json()["id"]
added = _request( added = _request(
db, db,
user, user,
monkeypatch, monkeypatch,
override_dep,
"POST", "POST",
f"/api/plex/playlists/{pid}/items/bulk", f"/api/plex/playlists/{pid}/items/bulk",
{"item_rating_keys": ["m1", "m2", "m3"]}, {"item_rating_keys": ["m1", "m2", "m3"]},
) )
assert added.status_code == 200, added.text assert added.status_code == 200, added.text
assert added.json() == {"added": 3, "item_count": 3} assert added.json() == {"ok": True, "added": 3, "item_count": 3}
removed = _request( removed = _request(
db, db,
user, user,
monkeypatch, monkeypatch,
override_dep,
"POST", "POST",
f"/api/plex/playlists/{pid}/items/remove-bulk", f"/api/plex/playlists/{pid}/items/remove-bulk",
{"item_rating_keys": ["m2"]}, {"item_rating_keys": ["m2"]},
) )
assert removed.status_code == 200, removed.text assert removed.status_code == 200, removed.text
assert removed.json() == {"removed": 1, "item_count": 2} assert removed.json() == {"ok": True, "removed": 1, "item_count": 2}
detail = _get(db, user, monkeypatch, f"/api/plex/playlists/{pid}").json() detail = _get(db, user, monkeypatch, override_dep, f"/api/plex/playlists/{pid}").json()
assert [it["id"] for it in detail["items"]] == [ assert [it["id"] for it in detail["items"]] == [
"m1", "m1",
"m3", "m3",
] # input order preserved, m2 gone ] # input order preserved, m2 gone
def test_collections_list(self, db, monkeypatch): def test_collections_list(self, db, monkeypatch, override_dep):
from app.models import PlexCollection from app.models import PlexCollection
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True) lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
@@ -450,13 +473,13 @@ class TestPlaylistAndCollectionEndpoints:
db.commit() db.commit()
# unified scope (no library param) -> most-populated first # unified scope (no library param) -> most-populated first
body = _get(db, user, monkeypatch, "/api/plex/collections").json() body = _get(db, user, monkeypatch, override_dep, "/api/plex/collections").json()
assert [c["title"] for c in body["collections"]] == ["Marvel", "Pixar"] assert [c["title"] for c in body["collections"]] == ["Marvel", "Pixar"]
# name filter # name filter
filtered = _get(db, user, monkeypatch, "/api/plex/collections?q=pix").json() filtered = _get(db, user, monkeypatch, override_dep, "/api/plex/collections?q=pix").json()
assert [c["title"] for c in filtered["collections"]] == ["Pixar"] assert [c["title"] for c in filtered["collections"]] == ["Pixar"]
def test_item_state_watched_then_new(self, db, monkeypatch): def test_item_state_watched_then_new(self, db, monkeypatch, override_dep):
# No PlexLink -> the background Plex push is a no-op; only the local PlexState path runs, which # No PlexLink -> the background Plex push is a no-op; only the local PlexState path runs, which
# is what the conversion touched (the st lookup + the delete on ->new). # is what the conversion touched (the st lookup + the delete on ->new).
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True) lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
@@ -468,7 +491,13 @@ class TestPlaylistAndCollectionEndpoints:
db.commit() db.commit()
watched = _request( watched = _request(
db, user, monkeypatch, "POST", "/api/plex/item/m1/state", {"status": "watched"} db,
user,
monkeypatch,
override_dep,
"POST",
"/api/plex/item/m1/state",
{"status": "watched"},
) )
assert watched.status_code == 200, watched.text assert watched.status_code == 200, watched.text
assert watched.json()["status"] == "watched" assert watched.json()["status"] == "watched"
@@ -476,7 +505,15 @@ class TestPlaylistAndCollectionEndpoints:
assert row.status == "watched" assert row.status == "watched"
# -> new deletes the state row # -> new deletes the state row
back = _request(db, user, monkeypatch, "POST", "/api/plex/item/m1/state", {"status": "new"}) back = _request(
db,
user,
monkeypatch,
override_dep,
"POST",
"/api/plex/item/m1/state",
{"status": "new"},
)
assert back.status_code == 200, back.text assert back.status_code == 200, back.text
assert ( assert (
db.execute(select(PlexState).where(PlexState.user_id == user.id)).scalars().first() db.execute(select(PlexState).where(PlexState.user_id == user.id)).scalars().first()
+78 -17
View File
@@ -108,7 +108,7 @@ class TestSweepOrphans:
def test_a_live_sessions_directory_is_never_deleted(self, hls_root): def test_a_live_sessions_directory_is_never_deleted(self, hls_root):
# Registered session, but with an OLD directory — only the live check can save it. # Registered session, but with an OLD directory — only the live check can save it.
d = _session_dir(hls_root, "12345_0_multi_+0.00", age_s=3600) d = _session_dir(hls_root, "12345_0_multi_+0.00", age_s=3600)
stream._sessions["k"] = _FakeSession(d) stream._sessions[(1, "k")] = _FakeSession(d)
assert stream.sweep_orphans(older_than=_cutoff(hls_root)) == 0 assert stream.sweep_orphans(older_than=_cutoff(hls_root)) == 0
assert d.exists() assert d.exists()
@@ -179,10 +179,20 @@ class _CapturingProc:
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
_CapturingProc.calls.append((args, kwargs)) _CapturingProc.calls.append((args, kwargs))
self.killed = False
def poll(self): def poll(self):
return None return None
def terminate(self):
self.killed = True
def wait(self, timeout=None):
return 0
def kill(self):
self.killed = True
def _venc(cmd): def _venc(cmd):
"""The video codec ffmpeg was told to use (the value after the first `-c:v`).""" """The video codec ffmpeg was told to use (the value after the first `-c:v`)."""
@@ -495,7 +505,7 @@ class TestColdStartWarmup:
return True return True
monkeypatch.setattr(stream, "wait_for", spy) monkeypatch.setattr(stream, "wait_for", spy)
s = stream.start_session(db=None, item=_FakeItem(), start_s=0.0) s = stream.start_session(db=None, user_id=7, item=_FakeItem(), start_s=0.0)
assert s is not None assert s is not None
# seg_0 (unconditional now) + the warm-up run up to _WARMUP_SEGMENTS. # seg_0 (unconditional now) + the warm-up run up to _WARMUP_SEGMENTS.
assert waited == [f"seg_{i}.ts" for i in range(stream._WARMUP_SEGMENTS)] assert waited == [f"seg_{i}.ts" for i in range(stream._WARMUP_SEGMENTS)]
@@ -508,7 +518,7 @@ class TestColdStartWarmup:
return path.name != "seg_1.ts" # seg_1 never appears → warm-up gives up there return path.name != "seg_1.ts" # seg_1 never appears → warm-up gives up there
monkeypatch.setattr(stream, "wait_for", spy) monkeypatch.setattr(stream, "wait_for", spy)
s = stream.start_session(db=None, item=_FakeItem(), start_s=0.0) s = stream.start_session(db=None, user_id=7, item=_FakeItem(), start_s=0.0)
assert s is not None assert s is not None
assert waited == ["seg_0.ts", "seg_1.ts"] # never asked for seg_2 assert waited == ["seg_0.ts", "seg_1.ts"] # never asked for seg_2
@@ -522,7 +532,7 @@ class TestColdStartWarmup:
return False return False
monkeypatch.setattr(stream, "wait_for", spy) monkeypatch.setattr(stream, "wait_for", spy)
s = stream.start_session(db=None, item=_FakeItem(), start_s=0.0) s = stream.start_session(db=None, user_id=7, item=_FakeItem(), start_s=0.0)
assert s is not None assert s is not None
assert waited == ["seg_0.ts"] assert waited == ["seg_0.ts"]
@@ -548,6 +558,7 @@ class TestTranscodeStartSession:
"plex_transcode_crf": 18, "plex_transcode_crf": 18,
"plex_transcode_max_height": 480, "plex_transcode_max_height": 480,
"plex_max_transcodes": 4, "plex_max_transcodes": 4,
"plex_max_sessions": 8,
}[k], }[k],
) )
monkeypatch.setattr( monkeypatch.setattr(
@@ -573,7 +584,7 @@ class TestTranscodeStartSession:
stream, "_select_encode", lambda db, item, transcode, src: (False, False) stream, "_select_encode", lambda db, item, transcode, src: (False, False)
) )
s = stream.start_session( s = stream.start_session(
db=None, item=_FakeItem(codec_video="mpeg4"), start_s=0.0, transcode=True db=None, user_id=7, item=_FakeItem(codec_video="mpeg4"), start_s=0.0, transcode=True
) )
assert s is not None and s.transcode is True and s.hw is False assert s is not None and s.transcode is True and s.hw is False
(cmd, *_), kwargs = _CapturingProc.calls[0] (cmd, *_), kwargs = _CapturingProc.calls[0]
@@ -590,7 +601,7 @@ class TestTranscodeStartSession:
stream, "_select_encode", lambda db, item, transcode, src: (True, False) stream, "_select_encode", lambda db, item, transcode, src: (True, False)
) )
s = stream.start_session( s = stream.start_session(
db=None, item=_FakeItem(codec_video="hevc"), start_s=0.0, transcode=True db=None, user_id=7, item=_FakeItem(codec_video="hevc"), start_s=0.0, transcode=True
) )
assert s is not None and s.transcode is True and s.hw is True and s.tonemap is False assert s is not None and s.transcode is True and s.hw is True and s.tonemap is False
assert len(_CapturingProc.calls) == 1 # first segment arrived — no HW→SW fallback respawn assert len(_CapturingProc.calls) == 1 # first segment arrived — no HW→SW fallback respawn
@@ -609,7 +620,7 @@ class TestTranscodeStartSession:
# tonemap=True on the session, still on the GPU (hw=True) and never niced. # tonemap=True on the session, still on the GPU (hw=True) and never niced.
monkeypatch.setattr(stream, "_select_encode", lambda db, item, transcode, src: (True, True)) monkeypatch.setattr(stream, "_select_encode", lambda db, item, transcode, src: (True, True))
s = stream.start_session( s = stream.start_session(
db=None, item=_FakeItem(codec_video="hevc"), start_s=0.0, transcode=True db=None, user_id=7, item=_FakeItem(codec_video="hevc"), start_s=0.0, transcode=True
) )
assert s is not None and s.transcode is True and s.hw is True and s.tonemap is True assert s is not None and s.transcode is True and s.hw is True and s.tonemap is True
(cmd, *_), kwargs = _CapturingProc.calls[0] (cmd, *_), kwargs = _CapturingProc.calls[0]
@@ -635,7 +646,7 @@ class TestTranscodeStartSession:
monkeypatch.setattr(stream, "wait_for", flaky_wait) monkeypatch.setattr(stream, "wait_for", flaky_wait)
s = stream.start_session( s = stream.start_session(
db=None, item=_FakeItem(codec_video="hevc"), start_s=0.0, transcode=True db=None, user_id=7, item=_FakeItem(codec_video="hevc"), start_s=0.0, transcode=True
) )
assert ( assert (
s is not None and s.transcode is True and s.hw is False s is not None and s.transcode is True and s.hw is False
@@ -666,7 +677,7 @@ class TestTranscodeStartSession:
monkeypatch.setattr(stream, "wait_for", flaky_wait) monkeypatch.setattr(stream, "wait_for", flaky_wait)
s = stream.start_session( s = stream.start_session(
db=None, item=_FakeItem(codec_video="hevc"), start_s=0.0, transcode=True db=None, user_id=7, item=_FakeItem(codec_video="hevc"), start_s=0.0, transcode=True
) )
assert s is not None and s.transcode is True and s.hw is False and s.tonemap is False assert s is not None and s.transcode is True and s.hw is False and s.tonemap is False
assert len(_CapturingProc.calls) == 2 # the HW tonemap attempt, then the SW respawn assert len(_CapturingProc.calls) == 2 # the HW tonemap attempt, then the SW respawn
@@ -699,17 +710,17 @@ class TestTranscodeStartSession:
def wait_then_supersede(path, timeout=20.0, proc=None): def wait_then_supersede(path, timeout=20.0, proc=None):
# Simulate the concurrent replacement happening during our out-of-lock wait, then report the # Simulate the concurrent replacement happening during our out-of-lock wait, then report the
# (now-killed) HW proc produced nothing. # (now-killed) HW proc produced nothing.
stream._sessions["12345"] = newer stream._sessions[(7, "12345")] = newer
return False return False
monkeypatch.setattr(stream, "wait_for", wait_then_supersede) monkeypatch.setattr(stream, "wait_for", wait_then_supersede)
s = stream.start_session( s = stream.start_session(
db=None, item=_FakeItem(codec_video="hevc"), start_s=0.0, transcode=True db=None, user_id=7, item=_FakeItem(codec_video="hevc"), start_s=0.0, transcode=True
) )
# No SW respawn — only the initial HW _spawn ran; the newer session was left intact. # No SW respawn — only the initial HW _spawn ran; the newer session was left intact.
assert len(_CapturingProc.calls) == 1 assert len(_CapturingProc.calls) == 1
assert s is newer # yielded the currently-registered session, did not clobber it assert s is newer # yielded the currently-registered session, did not clobber it
assert stream._sessions["12345"] is newer assert stream._sessions[(7, "12345")] is newer
def test_a_cap_culled_hw_session_does_not_respawn_and_is_not_reported_missing( def test_a_cap_culled_hw_session_does_not_respawn_and_is_not_reported_missing(
self, capturing_env, monkeypatch self, capturing_env, monkeypatch
@@ -727,16 +738,16 @@ class TestTranscodeStartSession:
def wait_then_cull(path, timeout=20.0, proc=None): def wait_then_cull(path, timeout=20.0, proc=None):
captured["s"] = stream._sessions[ captured["s"] = stream._sessions[
"12345" (7, "12345")
] # the HW session the initial _spawn just registered ] # the HW session the initial _spawn just registered
del stream._sessions[ del stream._sessions[
"12345" (7, "12345")
] # simulate _enforce_cap dropping the LRU under cap pressure ] # simulate _enforce_cap dropping the LRU under cap pressure
return False return False
monkeypatch.setattr(stream, "wait_for", wait_then_cull) monkeypatch.setattr(stream, "wait_for", wait_then_cull)
s = stream.start_session( s = stream.start_session(
db=None, item=_FakeItem(codec_video="hevc"), start_s=0.0, transcode=True db=None, user_id=7, item=_FakeItem(codec_video="hevc"), start_s=0.0, transcode=True
) )
assert ( assert (
len(_CapturingProc.calls) == 1 len(_CapturingProc.calls) == 1
@@ -752,7 +763,9 @@ class TestTranscodeStartSession:
monkeypatch.setattr( monkeypatch.setattr(
stream, "_select_encode", lambda db, item, transcode, src: (False, False) stream, "_select_encode", lambda db, item, transcode, src: (False, False)
) )
s = stream.start_session(db=None, item=_FakeItem(), start_s=0.0) # transcode defaults False s = stream.start_session(
db=None, user_id=7, item=_FakeItem(), start_s=0.0
) # transcode defaults False
assert s is not None and s.transcode is False assert s is not None and s.transcode is False
(cmd, *_), kwargs = _CapturingProc.calls[0] (cmd, *_), kwargs = _CapturingProc.calls[0]
assert _venc(cmd) == "copy" assert _venc(cmd) == "copy"
@@ -896,7 +909,7 @@ class TestAudioLoudnorm:
) )
if multi: if multi:
monkeypatch.setattr(stream, "_probe_audio_langs", lambda src: ["hun", "eng"]) monkeypatch.setattr(stream, "_probe_audio_langs", lambda src: ["hun", "eng"])
s = stream.start_session(db=None, item=_FakeItem(), start_s=0.0, multi=multi) s = stream.start_session(db=None, user_id=7, item=_FakeItem(), start_s=0.0, multi=multi)
assert s is not None assert s is not None
(cmd, *_), _ = _CapturingProc.calls[0] (cmd, *_), _ = _CapturingProc.calls[0]
return s, cmd return s, cmd
@@ -945,3 +958,51 @@ class TestAudioLoudnorm:
# sweep no longer recognises is one that never gets cleaned up. # sweep no longer recognises is one that never gets cleaned up.
s, _ = self._spawn(monkeypatch, on=on) s, _ = self._spawn(monkeypatch, on=on)
assert _SESSION_DIR_RE.match(s.dir.name) assert _SESSION_DIR_RE.match(s.dir.name)
class TestSessionsPerViewer:
"""HLS sessions were keyed by the ITEM, so a second account opening the same film killed the
first one's session; each then reclaimed and evicted the other. And only transcodes counted
toward a cap, so remuxes — each a whole segmented copy of the film on disk — were unbounded."""
capturing_env = TestTranscodeStartSession.capturing_env
@staticmethod
def _start(monkeypatch, user_id, rating_key="12345"):
monkeypatch.setattr(
stream, "_select_encode", lambda db, item, transcode, src: (False, False)
)
return stream.start_session(
db=None, user_id=user_id, item=_FakeItem(rating_key=rating_key), start_s=0.0
)
def test_two_viewers_of_one_film_each_keep_their_own_session(self, capturing_env, monkeypatch):
monkeypatch.setattr(stream, "_sessions", {})
a = self._start(monkeypatch, user_id=1)
b = self._start(monkeypatch, user_id=2)
assert stream._sessions == {(1, "12345"): a, (2, "12345"): b}
assert not a.proc.killed
assert a.dir != b.dir and _SESSION_DIR_RE.match(a.dir.name)
def test_one_account_holds_at_most_two_sessions(self, capturing_env, monkeypatch):
monkeypatch.setattr(stream, "_sessions", {})
first = self._start(monkeypatch, user_id=1, rating_key="1")
first.last_access -= 60 # the least recently used of the three
self._start(monkeypatch, user_id=1, rating_key="2")
self._start(monkeypatch, user_id=1, rating_key="3")
assert sorted(stream._sessions) == [(1, "2"), (1, "3")]
assert first.proc.killed
def test_remux_sessions_count_toward_the_overall_ceiling(self, capturing_env, monkeypatch):
monkeypatch.setattr(stream, "_sessions", {})
monkeypatch.setattr(
stream.sysconfig,
"get_int",
lambda db, k: {"plex_max_transcodes": 4, "plex_max_sessions": 3}[k],
)
oldest = self._start(monkeypatch, user_id=1)
oldest.last_access -= 60
for uid in (2, 3, 4):
self._start(monkeypatch, user_id=uid)
assert len(stream._sessions) == 3 and (1, "12345") not in stream._sessions
assert oldest.proc.killed and not oldest.transcode
+1 -1
View File
@@ -28,7 +28,7 @@ def test_a_suspended_account_cannot_drive_the_subtitle_extractor(
db, make_user, override_dep, monkeypatch, tmp_path db, make_user, override_dep, monkeypatch, tmp_path
): ):
user = make_user(is_suspended=True) user = make_user(is_suspended=True)
override_dep(get_db, lambda: db) override_dep(get_db, db.info["request"])
monkeypatch.setattr(app_state, "_configured_cache", True) monkeypatch.setattr(app_state, "_configured_cache", True)
# A validly SIGNED cookie for that account — which is all the old gate looked at. # A validly SIGNED cookie for that account — which is all the old gate looked at.
for mod in (auth, playback): for mod in (auth, playback):
+90 -2
View File
@@ -412,6 +412,54 @@ def test_a_full_pass_that_died_is_not_cached_as_the_whole_track(monkeypatch):
assert (after["state"], after["code"]) == ("unavailable", "extract_failed") assert (after["state"], after["code"]) == ("unavailable", "extract_failed")
def test_a_retried_full_pass_resumes_where_every_track_already_reaches(monkeypatch):
"""A pass that ran out of time keeps what it reached, but the retry an hour later started again
at 0 — so a file too big to read inside `EXTRACT_TIMEOUT_S` never completed. It now starts at
the end of the span every track has from the start (less `WINDOW_PRE_S`, so a cue across the
join is read whole), and finishing from there completes the track."""
calls = _stub_item(monkeypatch, clean=False, cues=("39:58.000", "40:00.000"))
_manual_pool(monkeypatch)
subtitles._extract_and_publish("98614", 3, None)
assert calls[-1] == ([0, 2], None, None), "the first pass reads from the head"
calls = _stub_item(monkeypatch, clean=True, cues=("00:05.000", "00:07.000"))
subtitles._extract_and_publish("98614", 3, None)
assert calls == [([0, 2], 2370.0, None)]
for sub_ord in (1, 3):
assert subtitles.read_meta(subtitles.cache_key("98614", sub_ord))[1] is True
assert "00:39:35.000 --> 00:39:37.000" in (
subtitles.cache_read(subtitles.cache_key("98614", 3)) or ""
), "the resumed read's cues sit at absolute time (2370 + 5 s)"
def test_a_full_pass_starts_at_the_head_while_any_track_lacks_it(monkeypatch):
"""One read feeds every embedded track, so the least-covered one decides: a window elsewhere in
the film, or one track with the head and another without, still means reading from 0."""
calls = _stub_item(monkeypatch)
_manual_pool(monkeypatch)
subtitles._extract_and_publish("98614", 3, 3570.0) # a window mid-film, both tracks
subtitles.publish(subtitles.cache_key("98614", 1), _vtt(), (0.0, 600.0), False)
subtitles._extract_and_publish("98614", 3, None)
assert calls[-1] == ([0, 2], None, None)
def test_the_status_says_whether_the_track_is_still_growing(monkeypatch):
"""`partial` alone kept the player polling every 2 s for the rest of the film once the full
pass had failed and backed off — a state that never reaches `ready` on its own."""
_stub_item(monkeypatch, clean=True, cues=("00:05.000", "00:07.000"))
pool = _manual_pool(monkeypatch)
subtitles._extract_and_publish("98614", 3, 0.0)
assert subtitles.ensure("98614", 3, at=10.0)["growing"] is True
pool.jobs.clear()
_stub_item(monkeypatch, clean=False, cues=("39:58.000", "40:00.000"))
subtitles._extract_and_publish("98614", 3, None)
assert subtitles.ensure("98614", 3, at=100.0) == {
"state": "partial",
"code": None,
"gen": subtitles.generation(subtitles.cache_key("98614", 3)),
"growing": False,
}
def test_polls_that_follow_the_playhead_join_one_window(monkeypatch): def test_polls_that_follow_the_playhead_join_one_window(monkeypatch):
"""The poll sends the LIVE position, so it moves a second per poll. A window keyed on that """The poll sends the LIVE position, so it moves a second per poll. A window keyed on that
position was a new job every second — another read of the file per poll until the first landed.""" position was a new job every second — another read of the file per poll until the first landed."""
@@ -642,8 +690,8 @@ def test_a_scrubbing_viewer_cannot_queue_a_window_per_stop(monkeypatch):
def test_nothing_more_is_queued_while_the_extractor_is_saturated(monkeypatch): def test_nothing_more_is_queued_while_the_extractor_is_saturated(monkeypatch):
pool = _manual_pool(monkeypatch) pool = _manual_pool(monkeypatch)
for rk in range(20): for rk in range(20): # twenty viewers, so no one account's share is what stops the passes
subtitles.ensure(str(rk), 0, at=0.0) subtitles.ensure(str(rk), 0, at=0.0, viewer=rk)
windows = [a for _, a in pool.jobs if a[3] is not None] windows = [a for _, a in pool.jobs if a[3] is not None]
assert len(windows) == subtitles._MAX_WINDOWS_INFLIGHT assert len(windows) == subtitles._MAX_WINDOWS_INFLIGHT
assert len(pool.jobs) - len(windows) == subtitles._MAX_FULL_INFLIGHT assert len(pool.jobs) - len(windows) == subtitles._MAX_FULL_INFLIGHT
@@ -797,6 +845,25 @@ def test_a_window_lines_up_with_the_full_pass_on_a_real_file(tmp_path, monkeypat
assert merged.count("-->") == 30, "a window cue survived beside its full-pass twin" assert merged.count("-->") == 30, "a window cue survived beside its full-pass twin"
@needs_ffmpeg
def test_a_full_pass_resumed_mid_file_lines_up_on_a_real_file(tmp_path, monkeypatch):
"""A retried full pass seeks like a window does but reads to the end (no `-t`): the cue already
running at its start must come out on time, not shifted to 0 with everything after it."""
src = _make_mkv(tmp_path)
monkeypatch.setattr(subtitles, "CACHE_DIR", tmp_path / "cache")
head, clean = subtitles._ffmpeg_extract(str(src), [0], None, 30.0)
assert clean
rest, clean = subtitles._ffmpeg_extract(str(src), [0], 21.3, None)
assert clean
key = subtitles.cache_key("1", 0)
subtitles.publish(key, head[0], (0.0, 30.0), False)
subtitles.publish(key, subtitles.shift_vtt(rest[0], -21.3), (21.3, subtitles._FULL_END_S), True)
merged = subtitles.cache_read(key) or ""
assert "00:00:22.000 --> 00:00:23.500\nline 11" in merged, merged
assert "00:00:58.000 --> 00:00:59.500\nline 29" in merged, merged
assert merged.count("-->") == 30, "a cue across the join was kept twice or lost"
# --- the third deep-review round ---------------------------------------------------------------- # --- the third deep-review round ----------------------------------------------------------------
@@ -904,6 +971,27 @@ def test_a_window_is_not_refused_while_full_passes_hold_their_slots(monkeypatch)
assert full.jobs == [], "past its bound no full pass is queued" assert full.jobs == [], "past its bound no full pass is queued"
def test_one_account_cannot_hold_every_full_pass_slot(monkeypatch):
"""The full-pass slots are global and minutes long each: one account opening subtitles on film
after film held them all, and nobody else's track ever completed. Past its share an account
still gets windows — what makes a track watchable now — and the next account still gets a pass."""
win, full = _ManualPool(), _ManualPool()
monkeypatch.setattr(subtitles, "_WIN_POOL", win)
monkeypatch.setattr(subtitles, "_FULL_POOL", full)
monkeypatch.setattr(subtitles, "_extract_and_publish", lambda *a: None)
for rk in range(subtitles._MAX_FULL_PER_USER + 1):
subtitles.ensure(str(1000 + rk), 1, at=600.0, viewer=7)
assert len(full.jobs) == subtitles._MAX_FULL_PER_USER
assert len(win.jobs) == subtitles._MAX_FULL_PER_USER + 1, (
"the account over its share still gets windows"
)
subtitles.ensure("2000", 1, at=600.0, viewer=8)
assert len(full.jobs) == subtitles._MAX_FULL_PER_USER + 1, "another account is not shut out"
full.run_all() # the account's passes end: its share is free again
subtitles.ensure("3000", 1, at=600.0, viewer=7)
assert any(a[1] == "3000" and a[3] is None for _, a in full.jobs)
def test_a_poll_after_shutdown_queues_nothing(monkeypatch): def test_a_poll_after_shutdown_queues_nothing(monkeypatch):
pool = _manual_pool(monkeypatch) pool = _manual_pool(monkeypatch)
subtitles.shutdown() subtitles.shutdown()

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