Compare commits

...
90 Commits
Author SHA1 Message Date
peter e23128b4a1 release: 0.80.0 (improvement/e9.5-onboarding-access)
gate / frontend (push) Failing after 2m26s
gate / backend (push) Successful in 3m42s
2026-09-04 23:28:30 +02:00
peter 0d5f8f0708 chore(release): 0.80.0 — E9.5 Onboarding & Access flow
Release notes for the epic: bilingual mail, one-mail-one-click approval, order-independent gates
with named states, canonical Gmail identity, the admin lifeline for sign-ups stuck outside, and the
four-round closing review that converged 12 -> 4 -> 3 -> 2.
2026-09-04 23:28:23 +02:00
peter a03bf8d371 fix(auth): stop erasing a reset link the signed-in tree never reads (E9.5 review round 4)
Round 4 came back converged — nothing above low, and both findings in one family. This is the half
worth fixing, because the branch introduced it.

Only the signed-out auth card consumes a `#reset=` token, and S2 moved the address-bar cleanup up to
a hook App mounts unconditionally. So opening a password-reset link in a browser that already holds a
session erased the token from the URL while nothing had read it: the page loads, nothing is said, and
clicking the link again does exactly the same, because the token is untouched and the session is
still there. That is the dead end this epic exists to remove, and `main` did not have it — the strip
used to live inside the card, which a signed-in visitor never renders.

An unspent reset fragment now stays, which restores the recovery that shape always had: signing out
reloads with the fragment intact and the form is waiting. Once the card has spent the token a later
reload meets S4's named dead-link screen, which is true. The affirmative version — a banner telling a
signed-in visitor that reset links open in a signed-out window — is logged with DX-15, along with the
`hashchange` half that still only recognises `#verify=`.
2026-09-04 23:14:46 +02:00
peter 3dc14402b0 test(auth): pin what rounds 1 and 2 left unpinned (E9.5 review round 3)
Three low findings, all in what the review rounds themselves wrote.

The comment on `test_approving_an_unverified_account_keeps_its_password` still asserted the argument
rounds 1 and 2 refuted — that dropping the approval-time revoke was safe because `password_login`
reads `email_verified` first — while the docstrings on the two functions it describes now say the
opposite. Two contradictory "why" records for one rule is how the round-1 takeover primitive got
reasoned into existence in the first place, so the test comment now carries the corrected account
and names SEC-16.

`test_a_later_registration_leaves_a_link_already_in_flight_alive` asserted a subset relation that is
vacuously true on an empty set, and the guard that made it non-empty had gone out with the revert.
It now pins both halves: the old link survives, and exactly one new one appears.

Round 2's own fix — activation moved to after a successful send — was unpinned; putting it back
above the send left the suite green. The failed-invitation test now also asserts the account was not
activated by a request the UI reported as a failure.
2026-09-04 23:04:09 +02:00
peter 540ca244c8 revert(auth): the newest registration does not own an unverified account (E9.5 review round 2)
Round 1 fixed the address pre-emption chain by letting a later registration replace the password on
an unverified account and delete its outstanding verify tokens. Round 2 read that code and showed it
was worse than what it fixed, so it goes back out.

It closes nothing. The rule is symmetric and an attacker can simply register last — on a timer,
inside the five-per-five-minutes budget — while the owner registers once. What it does add is two
primitives an unauthenticated caller did not have: overwrite a real registrant's chosen password,
and delete the confirmation link already sitting in their inbox (the approval mail's 7-day token is
an ordinary verify token, so the blanket delete took that too). Worst of all it fired on the rows
SEC-10 left stripped — password_hash NULL, password_revoked_at set, active — installing an
attacker's password where the entire state means "there is none", and clearing the stamp that is the
only reason password-reset still accepts them. That is a straight takeover which did not exist
before, in the code written to prevent one.

The owner's recourse needed no new primitive and already worked: password-reset accepts a
pre-empted account, proves the mailbox, rewrites the hash and bumps the session epoch. What remains
is the bounded window between a confirmation click and that reset, which is SEC-16 — logged with
this attempt written down, so the next person does not reach for it again.

Two smaller round-2 findings go with it. `upsert_pending_invite`'s new exact-spelling tie-break ran
before any status preference, so it could deterministically pick a DENIED sibling over a pending one
and reopen it — the very outcome its comment claimed to prevent; the status preference now comes
first. And `send_invitation` activated the account before the send it might have to report as
failed, so a request the UI called a failure had quietly let someone in; activation now follows a
mail that actually left. The verify token minted to render that mail still commits either way — it
is single-use, expires on its own and was mailed nowhere, and the note says so rather than leaving
the next reader to wonder whether it was missed.
2026-09-04 22:54:52 +02:00
peter 563fa0513d fix(auth): close what the epic's own review found (E9.5 review round 1)
Eleven findings survived adversarial verification; these are the fixes.

**Address pre-emption.** S1 dropped the approval-time password revoke, arguing `password_login`
refuses an unverified account before it reads `is_active`. Two verifiers showed that holds only
while the address stays unverified — and S6's approval mail exists to get the real owner to end that
state, at which point the squatter's password works. While an address is unverified the NEWEST
registration now owns it: the password is replaced and every outstanding verify token deleted, so
the owner takes the row back simply by registering, and a link already in flight cannot confirm it
for anyone else. The residual (an owner who confirms and neither registers nor resets) is logged as
SEC-16 beside SEC-15, where the re-attestation path it needs already lives. The comments that
asserted the old, false invariant now say what actually guards this.

**The entry URL.** S2 moved `clearEntryUrl()` out of the signed-out auth card into a hook App mounts
unconditionally, and it blanket-stripped the whole query string, the fragment and `history.state` on
the FIRST commit. That broke first-run setup outright — the wizard mounts only after its query
resolves and then finds no `?token=` — and silently ate `?link=`/`?oauth=` (no toast, no refetch
after connecting Google), `?v=`/`?chan=` deep links, and scroll restoration. It now clears only the
fragment and the two one-time flags it owns, and preserves `history.state`.

**The env allow-list.** S3 canonicalised the Invite branch of `is_allowed` and left the
ALLOWED_EMAILS/ADMIN_EMAILS fallback matching literally — the 2026-09-03 lockout with a different
source of truth, plus a shadowing trick where an unauthenticated request under a sibling spelling
permanently masked an env grant. Canonical on both sides now, and the same for the delete dialog's
warning and the ADMIN_EMAILS role bootstrap.

**Honest states.** `send_invitation` stamped "I already wrote to them" before a fail-soft background
send; it sends inline and records only what left. The admin-side resend answered `{ok: true}` for a
dropped mail; it now says so. Both also activate/serialize consistently with the other decide paths.

**Correctness sweep.** The 7-day approval link said "expires in 168 hours" — durations render in
days once there is more than one. `_register_account`, `upsert_pending_invite` and `add_invite`
picked among canonical siblings with no ORDER BY and no exact-spelling tie-break, so which account
got the re-send, and whether a denied row was reopened over a pending one, was up to Postgres.
`purge_user` deleted every canonically-equal invite, revoking a surviving sibling account's access.
`ConfirmProvider` wrapped its ReactNode message in a `<p>`. Approving no longer leaves the
stuck-sign-ups list stale. The refusal panels announce and take focus.

**Tests.** Three mailers rewritten this epic never rendered in any test; every `send_*` now renders
in both languages. The orphan-key guard could not fail (it scanned the catalog that defines the
keys) and had no reverse direction. Newly pinned: canonical activation, the surviving sibling's
grant, a failed invitation and resend, an expired reset link, a throttled login's silence, the
per-language admin fan-out, and that the entry URL keeps what it does not own.
2026-09-04 22:42:15 +02:00
peter 10c0ca4cf7 feat(email): write to people in their own language (E9.5 S7)
Twelve mails carried hardcoded English while the app itself has enforced HU/EN parity since E5.
A mail is the one user-facing surface nobody can re-render by switching the UI language: it arrives
once, in whatever language we picked, and stays that way. That made the epic's own headline mail —
"one mail, one click" — English for a Hungarian reader.

The text moved to a catalog (`mailtext.py`) with a `text(key, lang, **vars)` renderer, and the parity
rule is now a test rather than a habit: every key must carry both languages AND the same
`{placeholders}`. A translation that drops `{link}` sends a mail with no link in it; one that invents
a placeholder raises inside a background task nobody is watching. Both are silent in production.

Every `send_*` takes the language as its second argument, resolved by the REQUEST handler and never
inside the mail — these all run as background tasks, after the request's session is gone, which is
the same reason the approval mail computes its link in the handler.

Where the language comes from, in order: the account's own preference; failing that, the language the
person asked us in; failing that, English. The middle one is new — `invites.lang`, migration `0082` —
because the mails an access request produces go to an address that usually has no account yet, and
the signal does exist: the page their form was submitted from, or the locale Google reported for a
sign-in we refused. Captured rather than derived, for the third time in this epic (`google_email` in
S3, `preferences.language` in S6 taught the same lesson): a signal that exists only in one moment
cannot be backfilled later. So the admin is asked in exactly one place — the invitation to an address
they typed in themselves, where nobody has ever told us anything.

That send is now a dialog rather than a bare click, which is also where the "you already wrote to
them" warning belongs — it used to live only in a tooltip the admin may never hover, guarding the one
action whose failure mode is mailing a real person twice.

S6's registration `lang` field turned out to be dead on arrival: the backend accepted it, the
frontend never sent it, so every account has been storing nothing. The request builders now stamp the
UI language themselves — a signal that has to be passed in by every caller is one every caller can
forget, which is exactly how it shipped unset.

Admins are notified one mail per language group instead of one blast to everyone. They are people
with their own UI language; a single message would have to pick one of them and be wrong for the rest.

Also removes a duplicated `invited_at` column declaration on `Invite` (two identical definitions
landed in S6).
2026-09-04 20:29:33 +02:00
peter a76bab9b34 feat(auth): one mail, one click — the approval mail carries the confirmation link (E9.5 S6)
The funnel's common ordering made a person open TWO messages: one saying their access was approved,
another asking them to confirm their address. The approval mail now branches on what they actually
still have to do — carry the confirmation link, point at sign-in, or say a confirmation mail follows
once they register — so the usual case is one mail and one click. A mail that guesses wrong is worse
than a plain one: telling someone to sign in while their address is unconfirmed contradicts the
screen that will refuse them.

That embedded link lives 7 days rather than the usual 24 hours. Nobody sits waiting for an approval —
it can easily be read on Monday after a weekend, and an expired link spends the one-mail promise and
puts them back on two. The risk is small: the token only ever confirms an address; it signs nobody in
and sets no password.

The most likely root cause of the 2026-09-03 report that a confirmation mail "never arrived" is here
too, and it was never a delivery failure: re-registering an address that already had an unconfirmed
account returned in SILENCE — same cheerful "check your inbox", no mail sent at all. It now re-sends
the link. Nothing new leaks: the response and its timing are unchanged, and the public verify/resend
already does exactly this for the same input.

Whitelisting stays silent, and that is a decision, not an omission. The operator opens a door as a
possibility — usually already agreed with the person — and a row can sit approved for weeks with
nobody registering; a mail fired automatically there would be nudging someone who never asked.
Telling them is its own act on its own button, `invited_at` records it so a second click is a
decision rather than an accident, and the button only appears where nobody is behind the address yet:
"the door is open, come in" is meaningless to someone already inside.

Two things UAT caught that no test would have. Every fresh registration appeared in BOTH admin lists
at once, because the stuck-sign-ups filter keyed on "unconfirmed" — the rule is now "the address may
sign in, and this account still can't", which leaves an undecided request with the queue that owns
it. And the invitation button was offered on every approved row, half of which are existing users.

Also: the confirmation mail finally says what its link's rules are (once, 24 hours) — the reset mail
always has, while this one left the reader to discover both by meeting a dead link. The card's
"didn't get the email?" moves up under the form, and the three waiting messages name the spam folder,
which is where a first message to a fresh recipient sometimes lands.

Registration now records the language the sign-up page was in. Nothing reads it yet — the bilingual
templates are S7 — but it is the only moment that choice exists anywhere the server can see, and a
preference nobody recorded cannot be backfilled later.

UAT on localdev: re-registering re-sent the link (one token became two, one account); approval issued
a 7-day token and moved the account into the stuck list with only a re-send; whitelisting mailed
nobody; the invitation recorded itself and the row then warned that sending again would write twice.
Test data removed.
2026-09-04 04:05:28 +02:00
peter 7acdf5d577 feat(admin): surface the sign-ups that are stuck outside, and let an admin finish letting them in (E9.5 S5)
Approve an address BEFORE the person registers and their sign-up produces no queue row at all —
`_decide` only takes a still-pending invite — so the Access-requests screen sat empty, the tab badge
read zero, and the only working move was re-typing the address into the whitelist box. Nobody would
guess that, and on 2026-09-03 nobody did.

This is roadmap UX-25, where two earlier attempts were reverted after review. Both moved AUTHORITY:
auto-activating on registration turns a standing whitelist entry into a bearer grant for whoever
registers the address first, and reopening the approved invite to `pending` lets an anonymous sign-up
REVOKE a standing approval. What was actually missing was visibility, so this changes nothing about
what an unauthenticated caller can cause — the admin could already do exactly this by re-entering the
address; they just had no way to know they had to.

The section lists every account that cannot sign in yet and says WHICH gate is shut, because the two
dead ends look the same from the admin's chair: waiting for activation, or waiting on a confirmation
mail. Naming the unconfirmed ones is not decoration — on a no-SMTP instance that is the difference
between the owner and whoever registered the address first (SEC-15), and an admin about to let
someone in should see it. A person the queue above already covers is deliberately NOT listed here;
two buttons for one person is worse than one.

Activate is gated on `is_allowed` — the same function the sign-in paths consult, in core, so the
button that appears and the badge that counts cannot disagree. Without that gate it would
manufacture an inconsistent account: password sign-in only checks `is_active` and would let them in,
while their Google sign-in would be turned away and silently reopened as a fresh access request.
Letting in someone with no approved invite stays the whitelist box's job, which says so out loud.

The badge counts activations SEPARATELY from pending invites rather than widening an existing
field's meaning, and only counts what genuinely waits on the admin: an unconfirmed account waits on
its owner, so it is listed with a re-send but does not nag. The admin-side resend says plainly what
happened, including refusing when the instance has no working SMTP — mail is fail-soft everywhere
else, and that silence is exactly what left the 0.78.5 sign-ups with nothing to fall back on.

`send_verification_link` is now one function with three callers (registration, the public resend,
this one), so the link's shape and TTL cannot drift by who triggered it.

UAT on localdev, both languages: the UX-25 shape reproduced end to end (approve the address, then
register), the badge counting it, Activate letting them in and dropping the badge, the row losing
its Activate but keeping the re-send, a fresh token issued by the re-send, and both actions in the
audit log. Test data removed.
2026-09-04 03:27:48 +02:00
peter b1086e05dc fix(auth): stop drawing a reset form the link can no longer fill (E9.5 S4)
Reported from UAT: delete an account, then open its password-reset link. The card drew the full "set
a new password" form — because a `#reset=` fragment was present, and nothing more — and only after
the user had typed a password did the server answer 400. Measured first: no security hole. A user's
tokens cascade away with the row, so nothing can be set on a deleted or a re-created account. What
was left is a dead end of exactly the kind this epic exists to remove, and it is not rare: a reset
link lives one hour, mail is slow or lands in spam, and whoever got two mails clicks the older one.

password-reset/check answers whether a link is still usable WITHOUT spending it, so asking can never
cost the user their one real attempt — the property the tests pin hardest. It gets its own limiter,
because opening a link must not eat the budget for requesting one, and a throttled caller is told
"ok", never "dead": this check may only ever save someone a wasted form, so when it cannot answer it
degrades to the old behaviour instead of declaring a live link finished. The confirm endpoint stays
the authority, and its failure raises the same named screen, since the token can die between the two
calls or the probe itself may have failed.

A too-short password is deliberately NOT that screen. The length check runs before the token is
spent, so a typo costs the link nothing, and the card tells the two 400s apart by `reason` so only
one of them takes the form down.

Both email-link confirmations (verify, password-reset/confirm) now answer in the {code, reason}
shape S4 introduced. They were the last refusals a user meets by simply clicking an old link while
still being answered in hardcoded English under a bilingual UI. The seven remaining bare strings in
this module (register, request-access, the demo guards, set-password) sit behind client-side
validation the UI does first; they are logged as a follow-up rather than swept in silently here.

One bug found by the UAT itself, in the new code: the check went out FOUR times per opened link —
StrictMode double-invokes effects and the shell remounts the card as the session probe settles.
Harmless per request, but against a per-IP limit it would exhaust the budget in two reloads and then
silently fall back to drawing the form unchecked: nothing errors, the feature just stops working.
Deduped in a module-level map holding the in-flight promise (a ref dies with the remount that causes
it); a failed probe drops itself so a blip is never cached as "dead". The test renders the card
twice, because with one render the guard would rot undetected.

UAT on localdev: the reported scenario end to end (delete the account, open the link) shows the named
screen and no form; a live link still opens the form and completes; the CTA lands on the request-a-
link form; one probe per opened link. Throwaway accounts removed.
2026-09-04 02:38:01 +02:00
peter ed91c83dd7 fix(auth): name the state and the address behind a refused sign-in (E9.5 S4)
"Your account is awaiting admin approval." was true and useless. It never said WHICH mailbox the
account was on, and on 2026-09-03 that silence hid a Gmail alias mismatch for a day: a registration
as np.temu@gmail.com met a Google claim of nptemu@gmail.com, and every screen in the funnel agreed
to say nothing about it.

Every refusal password-login can answer with now uses the codebase's structured {code, reason}
detail, built in one helper. The three that arrive AFTER the password is proven carry the address on
the account plus both gate flags; the two that answer before it (invalid, rate_limited) carry
nothing, so a wrong password and an unknown account stay byte-identical. The frontend renders the
first three as a named screen in place of the form — there is nothing to retype once the password is
correct — with an icon, the two-gate checklist, the address in monospace, and a resend CTA where one
helps. Reporting each gate from its own flag is the payoff of S1: approved-but-unverified is a real
state now, and a screen that assumed a sequence would send that user off to wait for an admin who
has already decided.

The Google access-request notice names the address too, and says so when the request was already on
file under ANOTHER spelling of the same mailbox — the honest version of "we didn't open a second
one", stated only when it applies. It moved from the query string to the fragment: the notice now
carries an address, and a query string lands in every reverse-proxy access log. Same reasoning that
put the verify/reset tokens there, and the same owner already clears it. urlencode, not an f-string,
because URLSearchParams reads a bare + in a fragment as a space and would mangle exactly the
+tag addresses this epic exists to protect.

upsert_pending_invite returns the row it settled on beside the is-new flag, since the row's stored
address is what the notice has to name and what mail will go to.

Deviation from the PendingScreen mockup, deliberately: no "Sign out" button and no separate "Not
your address?" link. The mockup drew the panel in the app shell; the accepted decision (a) keeps the
pending user session-less, so there is no session to end, and the address is named directly above a
"Back to sign in" that answers the same question.

UAT on localdev, both languages: all three refusals with the typed spelling deliberately differing
from the stored one (the panel named the stored one), the resend CTA carrying the typed address into
the resend form, and both access-request banners with a +tag address surviving the fragment round
trip. Throwaway accounts and the language switch reverted.
2026-09-04 01:56:55 +02:00
peter a614ee2435 feat(auth): show the Google address beside the one the account registered with (E9.5 S3)
An account can now hold two true addresses: the one its owner typed, which a Google
sign-in no longer overwrites, and the one Google answers with. Only the first was
stored, so Settings could say 'Connected' without saying connected AS WHOM -- exactly
what someone who registered with a +tag needs to know.

users.google_email (migration 0080) records Google's answer at sign-in and at an
in-app link. /api/me exposes it ONLY when it differs from the account address, so the
comparison lives in one place and the majority, whose two addresses are the same
string, see no change at all. Rendered in the account menu as a muted third line under
the identity, and in the Settings sign-in row under the existing hint.

A short 'Google' label rather than a brand glyph: the app draws one icon set (lucide),
and a generic mail icon next to an email address says nothing. It also reads to a
screen reader, which an unlabelled logo would not.

No backfill is possible -- Google's answer is only known at sign-in -- so accounts
linked earlier stay NULL until their owner next signs in with Google.
2026-09-04 01:23:11 +02:00
peter c74487fbb3 fix(auth): keep the address the user registered with, and let it sign them in (E9.5 S3)
Two halves of one rule: the address someone typed is theirs to keep, and it must be
the one that gets them back in.

A plus-tagged or dotted Gmail is often a deliberate choice -- registering as
me+siftlode@gmail.com is how you find out later which site leaked your address. The
Google callback overwrote it with the account's primary, which can never contain a
'+', so the tag vanished on the first Google sign-in and, because sign-in matched the
stored address, took their chosen address with it. The rewrite line is older than
this epic, but S3 made it reachable here by matching adoption across spellings. It
now runs only when the mailbox GENUINELY changed, and lives in a named helper rather
than inline, so the rule has one home.

The three lookups that resolve a typed address -- password login, password reset,
verify resend -- go through find_user_by_email: exact spelling first, then the unique
canonical candidate, and None when several accounts share a mailbox rather than
guessing which one a password should open. Reset and resend previously answered a
different spelling with silence (they reply uniformly by design), which was a dead end
with no way out. Their mail now goes to the address on the account, never the one
typed into the form.
2026-09-04 01:07:54 +02:00
peter 876cece881 fix(auth): match identity on a canonical email, so one Gmail mailbox is one account (E9.5 S3)
Gmail delivers x.y@, xy@ and xy+tag@ to one inbox; the invite gate only lowercased.
On 2026-09-03 that turned an approved user's Google sign-in into a silent SECOND
access request -- OAuth returns the account's stored primary, which need not be the
spelling they typed -- and left prod with two user rows for one mailbox (ids 13, 14).
The +tag case fails every time: a Google account cannot contain +, so OAuth can only
ever answer with the base address.

Mail still goes to the address AS TYPED; only identity matching uses the canonical
form. utils.canonical_email is the single definition, and a model validator derives
users.email_canonical / invites.email_canonical on every write, so no call site can
forget it. Scope is narrow on purpose: dots are significant on most providers and the
Gmail rule does not hold for Workspace custom domains.

Swept every sibling of the same principle, not just the reported one: is_allowed,
upsert_pending_invite, the Google-callback adoption, _register_account (so no new
duplicate pair can be created), _activate_user_by_email, admin _accounts_for (an
invite backing a live user under another spelling used to look deletable) and
add_invite, plus purge_user, which now erases every canonically-equal invite instead
of leaving one behind that still grants access.

Migration 0079 backfills both columns and indexes them NON-uniquely: prod already
carries canonical duplicates (approved invite pairs since 2026-06-16), and merging
accounts is a judgement call about people's data, not something a migration does on
its own. A test pins the backfill SQL against the Python helper.
2026-09-04 00:14:12 +02:00
peter e5997ca567 fix(auth): consume a verification token that arrives without a page load (E9.5 S2)
Found in UAT. Navigating a tab that already runs the app to a URL differing only in
the fragment is a same-document navigation: nothing reloads, so the import-time entry
snapshot never sees the token. It sat unconsumed in the address bar and the previous
outcome stayed on screen -- the same silence this sprint set out to remove, wearing a
different hat. Pasting the emailed link into a running tab does exactly this, and our
verification mails were plain text until 0.78.8, which makes pasting the normal way to
use one.

A hashchange listener now picks up the new token, the guard tracks consumed tokens
rather than a single fired flag (still exactly one POST per token), and a fresh link
clears the standing outcome instead of leaving the old banner up.
2026-09-03 23:57:18 +02:00
peter c134b514c1 fix(auth): confirm an emailed verification link wherever it is opened (E9.5 S2)
The token was only ever consumed inside Welcome.tsx, which App renders ONLY when
there is no session. Opening the emailed link in a browser that already held a
Siftlode session posted nothing, the fragment was then stripped, and the user got
no signal at all -- the link simply did nothing. Incognito worked, which is why it
survived. Confirmation now happens in App, above that branch: signed out the outcome
still drives the auth card's banners, signed in it drives a top-of-app bar.

The entry URL is snapshotted once at import time (lib/entryUrl) instead of per
component, so no future URL writer can strip a token before whoever needs it has
read it -- that ordering race is what broke this in the first place. POST /auth/verify
now names the address it confirmed, which matters when the browser holds a session
for a different account; the same reasoning as password_reset_required already
covers the disclosure.

Also here, from the 2026-09-02 review tail: the dead GET /auth/verify goes (both
senders emit the fragment form, and the last query-token link expired 24h after the
0.78.7 deploy), a spent #verify=/#reset= fragment is cleared while signed in, and
the auth-card banners get role=status. The verifyOkReset copy no longer blames
admin approval, which E9.5 S1 made false.
2026-09-03 23:45:31 +02:00
peter 243869401a fix(auth): stop discarding a legitimate password on admin approval (E9.5 S1)
SEC-10 revoked any password set while an account's email was unverified, on both
the Google-adoption and the admin-approval path. On the approval path that was
redundant: password_login refuses an unverified account before it ever reads
is_active, so approval hands a pre-emptor's password nothing it could use. All the
revoke achieved there was destroying the password a legitimate registrant had just
chosen -- and since the admin normally decides before the user reads their mail,
'your password was cleared for security' was the DEFAULT experience, not an edge
case. The guard stays on the Google-adoption path, where the real owner does attest
ownership.

password_reset_required and the ok_reset banner stay: accounts stripped on prod
before this change have no other way back in.

Two existing tests pinned the old rule, so they are rewritten rather than deleted,
and the SEC-10 recovery pack now builds the revoked state through the surviving
producer.
2026-09-03 23:32:34 +02:00
peter 7339a0b84b release: 0.79.0 (improvement/e9-playlist)
gate / frontend (push) Failing after 1m43s
gate / backend (push) Successful in 2m48s
2026-09-03 21:59:31 +02:00
peter c960c07a6a release: 0.79.0 notes + version bump
E9 Playlist module: total playtime in both playlist headers, the header
compacted to two rows, and the sticky column header sitting flush. Plus the
six defects the epic-end deep review confirmed.
2026-09-03 21:59:09 +02:00
peter 3cfdb36f06 test(format): pin the sub-minute truncation (E9 R2)
Round 2 mutation-checked round 1's own fix and found the one line it left
uncovered: every table row was an integer, so `Math.floor(sec)` in the new
seconds tier could be deleted or swapped for round/ceil with the suite still
green — exactly the "later cleanup removes it as redundant" failure the fix's
comment claims to prevent. Two fractional rows kill all three mutants.
2026-09-03 21:51:14 +02:00
peter ada95d04a4 fix(playlists): one rendering for a playlist total, honest below a minute (E9 R1)
Epic-end deep review, round 1. Six findings survived verification; five of them
were one defect wearing different hats.

formatRuntime floored 1-59s to "0m". Harmless while it only ever formatted a
Plex movie's length, but 12D repurposed it for an aggregate, and a total is the
only input that can land under a minute: a playlist of Shorts reported "0m"
while the rail beside it read "0:45". Fixed in the function, not behind a
`>= 60` guard at each of the seven call sites — the invariant belongs where it
cannot be forgotten. Its contract comment claimed "Plex movie/episode lengths"
and was two epics stale.

The rail rendered the same playlist total as formatDuration (1:23:45) while the
new header rendered formatRuntime (1h 23m) — two shapes of one number, side by
side on one screen. The rail now uses formatRuntime too; formatTotalHours had
already recorded that H:MM:SS is wrong for an aggregate.

The rename input kept min-w-0 without flex-1 in the compacted row, so it was
the only shrinkable child beside a shrink-0 action cluster. The Plex half of
this same epic already had the fix; this restores the symmetry.

plex.browse.items had no plural forms, so a one-item Plex playlist read
"1 items". Pre-existing, but the new header is the call site where count=1 is
ordinary. Both languages get both forms, no key drift.

formatRuntime had no unit tests at all across seven call sites. Added a tier
table plus a sweep asserting no non-zero second ever renders as "0m".

Dropped after 3/3 adversarial refutations: the meta-line segment ordering
(the filtered count is labelled, and count-then-runtime is the convention) and
the null-duration lower bound (the server's own SUM has skipped NULLs since
long before this branch; changing only the client would split them).
2026-09-03 21:43:04 +02:00
peter 1b004e6ccf feat(plex): total playtime in the playlist header (E9 S3)
Parity with the YouTube playlist header (E9 12D): the Plex playlist detail
now carries a meta line under the title — "N items · Xh Ym", summed from the
items' duration_seconds. No backend change: leaf_card already serializes
duration_seconds for both movies and episodes, and no new i18n key either —
the count reuses plex.browse.items, the same key the collection header uses.

The title/rename input moved into a min-w-0 flex-1 column so the meta line
sits under both; the line is hidden on an empty playlist.
2026-09-02 18:30:06 +02:00
peter 95c33e0b0f refactor(playlists): compact the playlist header to two rows (E9 12A)
The header portals into the shell's FIXED toolbar band, so its height is
list height the user never gets back — and it was spending four stacked
blocks on it: title, meta line, an action row, then a separate view-control
row under the cover. Option B collapses that into two tight rows beside a
smaller cover: title + rename/YouTube-link on the first, with Play-all,
push/revert/delete and the unsynced badge pushed right; the meta line
(count · playtime · filtered) and the view controls (group-by, reset view,
save order, view status, undo/redo) sharing the second.

Every control and its gating is unchanged — the view-control group still
appears only for `items.length > 1 || viewActive`, so a one-item playlist
shows just the meta line.

Measured on a 32-item playlist: the band goes 170px -> 88px at rest
(122px while a view is active, where the status sentence wraps), which is
~80px more visible list.
2026-09-02 03:45:07 +02:00
peter 4e6bcbc078 fix(playlists): stop the table header shifting up on first scroll (E9 12B)
The playlist content wrapper carried `pt-3`, so its sticky column header sat
12px below the scroller top at rest and jumped up to `top:0` on the first
scroll. Drop the top padding so the header is flush from rest and never
shifts — the fixed toolbar band above supplies the breathing room, matching
the channel table's already-correct pattern.
2026-09-02 03:39:17 +02:00
peter e17fb5bbf5 feat(playlists): total playtime in header + sticky-header fade fix (E9 S1)
Surface the playlist's total playtime beside the item count in the detail
header (12D) — sums every item's duration (formatRuntime "h m", matching the
approved mockup), hidden when nothing sums. Data is already client-side; no
backend change.

Pass fadeTop:false for the playlists page (12B) so the shell's top edge-fade
no longer sits over the already-sticky column header (tableHeader.tsx:257),
matching the channels/audit pages.
2026-09-02 03:39:17 +02:00
peter a9044ece50 release: 0.78.8 (improvement/email-html-alternative)
gate / frontend (push) Failing after 1m40s
gate / backend (push) Successful in 2m40s
2026-09-02 03:24:59 +02:00
peter 473aafd1c8 release: bump VERSION to 0.78.8 + release notes 2026-09-02 03:24:56 +02:00
peter 7019e3afdf fix(email): give every mail an HTML alternative so its link is clickable
The bodies are text/plain only, so whether the URL in a verification or
password-reset mail is a link at all was the client's decision. Gmail, Outlook and
Apple Mail linkify plain text; lighter web clients render the body verbatim and leave
the link as dead text — in a message whose entire purpose is that one link.

_send now attaches an HTML part generated from the SAME plain body, rather than
per-template HTML that would have to be kept in sync with it: one converter, all
eleven templates, and text/plain stays the primary part and the source of truth.
Escaping runs before linkification — bodies interpolate untrusted values (a
requester's address, the operator contact) — so no caller can inject markup, and a
query string's `&` is already `&amp;` when it reaches the href, which is what an HTML
attribute wants. Sentence-final punctuation is trimmed out of the href while
url-safe token characters (`-`, `_`) are left alone. Inline styles only, no images,
no external CSS, no tracking.
2026-09-02 03:13:23 +02:00
peter c81b110637 release: 0.78.7 (bug/verify-link-and-approval-lockout)
gate / frontend (push) Failing after 1m44s
gate / backend (push) Successful in 2m42s
2026-09-02 02:31:19 +02:00
peter 2ecc70ca6d release: bump VERSION to 0.78.7 + release notes 2026-09-02 02:31:14 +02:00
peter f8c63e80c2 fix(auth): widen the 0078 backfill so revoke victims who verified are rescued too
Review found the backfill's fourth predicate `AND email_verified = false` strands
exactly the subset that needs it most. The revoke does not invalidate the account's
outstanding verification token, so clicking the emailed link is the natural next
thing such a user does — and it sets email_verified=True while password_hash stays
NULL. That row then has no route back at all: reset ineligible, resend refuses an
already-verified address, set-password needs a session the account cannot obtain,
and nothing re-stamps it because the revoke itself is guarded on being unverified.

The remaining three predicates already pin the victim set exactly: registration and
the setup wizard always set a hash, every Google row carries a google_sub (adoption
assigns it before revoking), and nothing in the codebase ever clears google_sub — so
a local, non-demo, passwordless row can only have come from the revoke. Widening is
therefore safe, and it is the argument the docstring was already making while the
SQL contradicted it.

Also corrects that docstring: set-password is unreachable because the account cannot
authenticate at all, not because the epoch bump killed a session — an unverified
account is refused at login before a session is ever minted, so it never had one.
2026-09-02 02:21:46 +02:00
peter 3ac3d382e7 fix(auth): stop the address-bar mirror eating emailed auth tokens, and unlock revoked accounts
Two independent bugs made an email+password registration unrecoverable in prod.

The verification link did nothing. urlWithParams/clearParams rebuilt the URL as
pathname+query and dropped the fragment — but the one-time auth tokens ride the
fragment (#verify=/#reset=, SB3), and the providers that mirror nav params
(useUrlMirror in FeedFilters/Playlists/Plex/Channels) sit ABOVE the lazily-loaded
<App> in main.tsx. Their mount effect replaceState'd the token away before the auth
card's chunk had loaded, so the card read an empty hash: the link opened a bare
sign-in page, no request, no banner. Both helpers now carry the fragment over.

The account was then locked out for good. SEC-10 nulls password_hash when an
unverified account is attested (here: admin approval landing before the user could
verify — which the bug above made impossible). That is indistinguishable from a
Google-only account, and reset eligibility keyed off password_hash alone, so no
reset link was ever issued: no password to sign in with, and no way to get one.
users.password_revoked_at records the revocation, reset eligibility honours it, and
setting a password again (reset-confirm or set-password) clears it. An account that
never had a password stays ineligible on purpose — its owner adds one from Settings.

/auth/verify now also answers password_reset_required so the card can say why the
registered password stopped working, instead of leaving the user at the uniform
"Invalid email or password". Telling the holder of a valid verify token leaks
nothing: they have already proved they own the mailbox.

The 0078 migration backfills the stamp for rows carrying the exact lockout signature
(local, non-demo, no password, unverified), so existing victims recover unaided.
2026-09-02 01:28:45 +02:00
peter 94c62ca699 release: 0.78.6 (feat/admin-invite-delete-email-health)
gate / frontend (push) Failing after 1m43s
gate / backend (push) Successful in 2m53s
2026-09-02 00:36:52 +02:00
peter 1fe24d374a docs(tests): correct the deny-vs-delete rationale in the invites test file
Round 4's only finding: the file header and one comment still said Deny stops a
refused person re-asking. It does not — upsert_pending_invite reopens a denied
row as pending — and the delete_invite docstring in the same branch already says
so. The real difference is that Deny records an auditable decision and mails the
requester; Delete forgets the address.
2026-09-02 00:15:21 +02:00
peter 2cba6b9fca docs: round-3 copy corrections, and pin the two behaviours nothing was guarding
Round 3's correctness/security pass came back clean; what was left said more than
the code does.

- emailOff promised the access-request list is where sign-ups show up. A sign-up
  on an address that is already approved or already pending adds nothing to it
  (roadmap UX-25), and "only" was falsified by this release's own inbox notice.
- deleteConfirm claimed the admin was dropping "an approval you already gave" —
  it is the confirmation for pending and denied rows too.
- sendFailed blamed the mail server for rejecting a message; the reason covers
  connect, DNS, TLS and login failures alike, so it now describes the symptom and
  leaves the diagnosis to the log.
- ADMIN_EMAILS re-promotion happens in the Google callback only, not on any
  sign-in.

Tests: the decided-list copy of the delete affordance was never rendered (both
new cases fail if that branch is dropped), and the throttle release — a
deliberate behaviour the notes advertise — was asserted by nothing; a drop with
no admin yet, or with a failing write, must not silence the following hour.
2026-09-02 00:06:01 +02:00
peter 8d7aaad9a8 fix: warn before the delete, not after — plus round-2 copy and a11y corrections
Round 2 on the feature diff (15 → 12):

- still_allowed_by_env now rides the invite LIST as well as the delete response,
  so the confirmation warns while the admin can still change their mind. It
  mattered most for a DENIED row on an env-allow-listed address: deleting it
  silently converted a refusal into a grant, and the only warning arrived after
  the irreversible click.
- Both inbox bodies claimed to name "the most recent" dropped message. The
  throttle records the FIRST of each hour and never updates it, so they now say
  "one of them", and the not-configured body finally names where to fix it.
- settings.invites.emailOff told the admin they "get no notice when someone
  signs up" — rendered directly above the queue that shows exactly that. Only
  the email is lost, and it now says so.
- The disabled delete buttons put their reason out of reach: `disabled` drops a
  control from the tab order and suppresses its title. Switched to
  aria-disabled + preventDefault, the pattern Playlists.tsx already established
  for the same reason.
- deleteInvite is quiet, so the 409 the UI handles with a translated toast no
  longer also raises the server's English detail as a modal.
- After a completed password reset, Back landed on the reset form again with a
  consumed token — the history change made that state reachable. The token is
  cleared on success so the reset view stops being the card's root.
- The hu deleteHasAccount named a tab that renders under a different label.

Tests: a dom test for the Access-requests panel covering both has_account
variants and all three delete outcomes, /api/me flag cases, an ADMIN_EMAILS
case, and the env-allowlist test now patches the raw setting so the parsing
stays under test.
2026-09-01 23:50:29 +02:00
peter 85043013f0 fix: address the feature review — accurate copy, reachable controls, real coverage
Round 1 on the feature diff found 15; the substantive ones:

- The delete control VANISHED on any invite whose address had registered, which
  is the ordinary sign-up shape (register writes the User and the pending invite
  together). The guard stays broad — that row is the account's approval gate, and
  orphaning it strands someone who can then neither sign in nor be approved — but
  the control is now a disabled button carrying the reason, on both lists, so an
  admin never sees a row silently missing a button its neighbours have.
- Deleting an entry is not always a revoke: is_allowed falls back to the env
  ALLOWED_EMAILS / ADMIN_EMAILS lists once no row exists, and an ADMIN_EMAILS
  address is force-promoted on its next Google sign-in. The route now reports
  still_allowed_by_env and the UI says so instead of claiming access was removed.
- "Deny keeps the row so they can't immediately re-ask" was simply false —
  upsert_pending_invite reopens a denied row. Corrected everywhere it was stated.
- The Configuration warning reads me.email_configured, which save() did not
  invalidate, so it kept warning about fields the admin had just filled in.
- The read-only variant was an aria-hidden span explained only by a Tooltip that
  the hints toggle can switch off; now a disabled button with title + aria-label.
- The delete error path threw away the 409's reason (the stale-list case) and
  left the stale row on screen.
- The throttle window was spent even when no notice was written, silencing the
  next hour on the path whose job is to stop silent drops.
- email_enabled() opened a second session and decrypted the SMTP password on
  every /api/me poll; it now takes the caller's session.
- welcomeMode.dom.test.ts never rendered the card, so reverting the history
  wiring to useState left it green. A render test now drives the real links and
  fails on that revert (verified).

Plus /api/me flag tests, the registration-shaped pending case, the env-allowlist
case, and a release note that matches what the code does.
2026-09-01 23:31:49 +02:00
peter 44f626f732 feat(admin): delete a whitelist entry, and stop hiding a broken mail setup
Three operator gaps, all found the same way — a person could not sign up and the
app showed nobody anything.

- DELETE /api/admin/invites/{id} with a delete control on both the pending
  requests and the previously-decided list. Deny keeps the row so the person
  cannot immediately re-ask; delete forgets the address. Refused with 409 while
  a User row exists for it, because is_allowed reads that row and removing it
  would revoke a live user's access with no other sign — the list ships
  has_account so the UI withholds the button instead of offering one that fails.
- Email is fail-soft by design, which had come to mean invisible. _send() now
  records an email_undelivered notification for every admin when a message is
  skipped or rejected, naming the message that was lost, throttled to one an
  hour. /api/me carries email_configured, driving a standing warning on
  Configuration → Email / SMTP and above the access-request list.
- The sign-in card's Create account / Forgot password / Didn't get the email
  views are history entries (useHistorySubview), so Back returns to the previous
  view instead of only moving the address bar.

Also: the per-test rate-limiter reset sweeps every loaded app.* module rather
than a hand-kept list of three — this change added a limiter outside it.
2026-09-01 23:09:25 +02:00
peter 24c93cbb83 docs: name the Configuration tab as it is rendered, drop an unused fixture
Round 6's two remaining nits. The operator note said "Configuration → Email"
but the group renders as "Email / SMTP" in both locales, and
test_an_admin_can_activate_a_pre_approved_registrant requested make_user without
using it — a leftover from the reverted rounds that made the test look like it
seeded an admin when it did not.
2026-09-01 22:15:56 +02:00
peter cb1f4f00b0 docs(auth): correct the last round of over-claiming comments and notes
Round 5's correctness/security pass came back clean; what was left was text that
said more than the code does.

- The register-path notify comment (and its test) claimed the admin "is still
  told". The call fires, but on a no-SMTP instance _send drops it — which is the
  open half of the problem, not something this branch fixed. Both now say so.
- The release notes tell admins what to actually do: an account whose address was
  whitelisted in advance has no pending request to approve, so the whitelist box
  is what activates it. That instruction only existed in the roadmap and in
  chores, which the dialog does not render.
- "That one line is the whole production change" now accounts for the test-only
  RateLimiter.reset() that also ships.
- The rate-limiter test claimed to pin that blocked attempts are not counted, on
  an assertion that held either way. Replaced with one that drives the clock
  across the window boundary and fails if hammering extends it.
2026-09-01 22:02:43 +02:00
peter f6e1d1e69f fix(auth): keep the admin notice unconditional on the register path
Round 4 showed the `inv is not None` gate added in b5a1002 removed a signal that
matters: for an address the admin had already approved, that email is their only
notice that someone has now created an account on it — the invite is not pending
so nothing lands in the Access-requests queue either. Suppressing it left the
registrant at "awaiting admin approval" with the admin unaware they existed. The
duplicate it avoided (Request access, then sign up) is mild noise by comparison.

The production change is now one db.commit(), which is all the reported bug ever
needed. Release notes corrected again: a previously-denied address was also
unaffected, the pending-request case never lost its queue entry (only the
account behind it), and the test inventory was missing a guard.
2026-09-01 21:48:40 +02:00
peter b64c3f514d fix(auth): drop the invite reopen — keep the fix to the lost account alone
Round 3 of the review killed the reopen from b5a1002: flipping an approved
invite back to pending let an unauthenticated POST /auth/register REVOKE a
standing approval for any whitelisted-but-unclaimed address, which then fails
is_allowed() and locks the real owner out of Google sign-in — the primary way
people get in here. It also re-armed the pre-emption window it was meant to
close, because with no SMTP email_verified is set True and the SEC-10 guard the
design leaned on never fires.

Two attempts at fixing the stranded-account UX in the same change both came back
worse than the wart. What remains is the narrow, real bug: the account was
silently rolled back and is now committed. An admin still binds the approval to
the account, exactly as before this branch. UX-25 in the roadmap records the
follow-up and both dead ends so they are not retried.

Also from round 3: the upsert docstring no longer claims the admin UI cannot
reach these accounts (re-entering the address in the same panel's whitelist box
does, and a test now pins it), the release notes are scoped to what the diff
actually changes with the operator note moved where the dialog renders it, and
RateLimiter has unit tests so reset() is not production code only a fixture
calls.
2026-09-01 21:34:38 +02:00
peter b5a1002412 fix(auth): reopen a pre-dated approval instead of auto-granting it
Replaces the activation added in ea931dc, which round 2 of the review showed was
a real weakening: on a no-SMTP instance it made a standing whitelist entry a
bearer grant — the first anonymous party to register an approved-but-unclaimed
address got an immediately usable account under that identity, with no
attestation, no admin action and no signal anywhere. The verify-time activation
had the same shape one step removed, and neither site ran the SEC-10 preset-
password guard or left an audit trail.

Keep a human in the loop instead. An approval granted before any account existed
was a decision about the ADDRESS, so registration reopens the invite
(upsert_pending_invite gains revive_approved, used only by the register worker).
The request lands in the Access-requests queue, approving it runs
_activate_user_by_email — and with it revoke_preset_password_if_unverified — and
the account activates. One mechanism, already audited, no new grant path, and
the "awaiting admin approval" copy is honest again.

The explicit commit from the previous commit still matters: with an invite
already pending, upsert returns without committing and _issue_token is gated on
SMTP, so a no-SMTP registration was still being rolled back.

Also from round 2: gate the admin notice on a fresh decision (matching the two
other upsert callers) so a retry no longer re-pings every admin, and reset the
module-level rate limiters between tests — the suite pooled every login into one
300s window and was two logins short of failing an unrelated test with a 429.
2026-09-01 21:18:14 +02:00
peter ea931dca30 fix(auth): honour an approval granted before sign-up instead of parking the account
Follow-up to the commit fix, from the deep-review. Persisting the account was
only half the story: when the admin had approved the address BEFORE the person
registered, the account landed is_active=False against an invite that is no
longer pending — absent from the Access-requests queue, and approve_invite 409s
on an already-decided invite. The sign-up worked and still could not sign in.

Derive activation from is_allowed() at the moment ownership is attested:
- verify_email_confirm activates a pre-approved account when the emailed link is
  confirmed (only the mailbox owner can follow it), so SEC-10 stays intact;
- _register_account activates only when there is no SMTP to verify with, where
  the admin whitelist is by design the sole gate — the same one add_invite
  already activates on.

Tests: the pre-approved no-SMTP case and the verify-activates case are real
regression tests (both fail without this change); guards cover the unknown-email
and denied->pending branches, verification of an unapproved account, and the
first end-to-end coverage of the /auth/register route and its
allow_registration gate. Release notes rewritten — the previous entry miscounted
its regression tests and advertised an email fix this release has no code for.
2026-09-01 20:56:43 +02:00
peter d954e71a77 fix(auth): commit the account on registration so a pre-approved sign-up isn't lost
_register_account() flushed the new User but relied on someone else to commit:
upsert_pending_invite() commits only when it actually writes an invite row, and
_issue_token() runs only when SMTP is enabled. For an email an admin had ALREADY
approved, on an instance where email is unavailable, neither fired — the session
closed and the account was rolled back. The would-be user got the uniform "ok"
and no account, could not sign in, and re-registering was a silent no-op.

Commit explicitly after the invite upsert (a no-op when upsert already committed
the same transaction). Three DB-lane regression tests cover the pre-approved
no-SMTP path (fails without the fix), the unknown-email path that always worked,
and the pre-approved path with SMTP on.
2026-09-01 16:25:05 +02:00
peter 602f62967a release: 0.78.4 (bug/plex-transcode-hls-startposition)
gate / frontend (push) Failing after 1m55s
gate / backend (push) Successful in 2m57s
2026-08-30 20:33:15 +02:00
peter ba00b50dea release: 0.78.4 — fix Plex transcode playback stall/rebuffer
hls.js startPosition:0 so the transcode/remux HLS session starts VOD-style
at the requested position instead of chasing the live edge of the growing
EVENT playlist.
2026-08-30 20:32:56 +02:00
peter 92adb593c8 fix(plex): start hls.js at position 0 for the transcode/remux HLS session
Our HLS is a growing EVENT playlist with no ENDLIST, which hls.js reads as
LIVE. Its default startPosition (-1) then starts at the live edge and hugs it
(no forward buffer → rebuffer spinners), and on a resume/seek session the
segments' absolute PTS (-copyts, offset by the server's -ss) don't line up
with the live-edge timeline → 0 fragments load → permanent stall. The
seek-restart model makes fragment 0 the requested content position, so start
VOD-style at 0 and buffer forward. Fixes the transcode playback stall/rebuffer
found in 0.78.2 UAT.
2026-08-30 20:09:00 +02:00
peter 66132b6dda release: 0.78.3 (feature/plex-playback-class-filter)
gate / frontend (push) Failing after 1m36s
gate / backend (push) Successful in 2m35s
2026-08-30 19:09:22 +02:00
peter d09f021f0c release: 0.78.3 — filter the Plex feed by playback class 2026-08-30 19:09:08 +02:00
peter d7e9240e54 merge main (0.78.2) into playback-class before release
# Conflicts:
#	backend/app/plex/stream.py
2026-08-30 19:08:08 +02:00
peter f2fa49b778 release: 0.78.2 (feature/plex-hdr-hw-tonemap)
gate / frontend (push) Failing after 1m37s
gate / backend (push) Successful in 2m24s
2026-08-30 18:56:32 +02:00
peter d4ed45b2eb release: 0.78.2 — HW HDR→SDR tonemap for 10-bit/HDR Plex transcode 2026-08-30 18:56:18 +02:00
peter 375f6e58be merge main (0.78.1) into tonemap before release 2026-08-30 18:55:14 +02:00
peter 1a102ce81a release: 0.78.1 (bug/download-mediakind-probe)
gate / frontend (push) Failing after 1m36s
gate / backend (push) Successful in 2m22s
2026-08-30 18:47:05 +02:00
peter bfd45a155e release: 0.78.1 — download media_kind from a file probe (BUG 2) 2026-08-30 18:46:33 +02:00
peter 1102ad6a28 fix(downloads): make the probe verdict authoritative over yt-dlp info by probe-ran, not per-field
Deep-review round 1 on bug/download-mediakind-probe found a real HIGH the prior
review missed:

- resolve_media_facts used a per-field `probe or info` fallback, so a probe that
  RAN and deliberately found no video track (cover-art-only audio: attached_pic
  skipped; a still image: format-guarded) had its None video overridden by
  yt-dlp's stale vcodec. An audio-mode download from a progressive source (info
  carries the source's video codec, embed-thumbnail on) was then classified VIDEO
  -> a Play button + a broken in-app <video> with no video track. Only the
  download path regressed (upload calls probe_media directly).

  Fix: gate precedence on whether ffprobe RAN, not per-field. If the probe read
  the file, its codec verdict wins outright (a None means 'no such track'); only a
  full ffprobe failure falls back to info. Dimensions still gap-fill from info.
  Refactored the parsing into _facts_from_probe so resolve_media_facts can see the
  raw probe-ran signal; probe_media keeps its 5-tuple (upload + TestProbeClassifier
  unchanged).

- tests: the cover-art-audio and still-image resurrection cases, a differing
  populated-info precedence case (the prior-flagged gap), and the ffprobe-failed
  info fallback — now stubbing run_ffprobe_json so the real parsing + precedence
  run together.

Backend gate green (ruff + full pytest).
2026-08-30 18:09:26 +02:00
peter c1a3b7b935 fix(plex): gate the part-key re-probe to single-version items; drop dead i18n key; cover cross-facet narrowing
Deep-review round 2 on the round-1 fixes (all low):

- part_key churn (state): round 1's part-key reset re-probed EVERY sync for a
  multi-version item (an original + a Plex 'Optimized' copy), because
  _media_facts reads media[0]/part[0] whose order Plex doesn't guarantee stable,
  oscillating transcode_hw between the two versions. Gate the part-key signal on
  a single-version item (len(Media)<=1); codec-change reset still applies to all.
  A multi-version same-codec re-encode not self-healing is an acceptable rare
  edge; a per-sync re-probe loop is not.

- dead i18n key: this diff's playableClass() collapses to transcode_hw/sw, so the
  bare plex.playable.transcode key it orphaned is now unused (knip can't see
  dynamic keys). Dropped from both locales, parity kept.

- test: cross-facet narrowing by an active playable class (a co-facet's year
  range collapses to the selected subset) — the flip side of self-exclusion,
  previously unpinned.

The aria-label+title double-announcement on the card dot is a deliberate accepted
trade-off (reliable SR name + mouse tooltip). Backend + FE gates green.
2026-08-30 17:58:38 +02:00
peter bf98b48517 fix(plex): re-probe on file replace, HU parity, public class const, playback-class test gaps
Deep-review round 1 on feature/plex-playback-class-filter:

- Sticky watermark (medium): _apply_item reset the bit-depth probe watermark
  only on a codec-string change, so a same-codec re-encode (8-bit->10-bit HEVC)
  kept a stale video_bit_depth/transcode_hw forever (the backfill only re-probes
  media_probed_at IS NULL rows). Also reset when the Plex Part key changes (the
  file was replaced), not just the codec.

- i18n parity (medium): the HU playback-class labels were mixed-language (only
  'direct' translated). Translated remux/transcode_hw/transcode_sw and the
  tooltips consistently to Hungarian (Kozvetlen streameles / Atkodolas).

- Layering (low): _PLAYBACK_CLASSES was module-private yet imported across into
  routes.plex.browse; renamed to public PLAYBACK_CLASSES (it is the shared
  source of truth for facet order + zero-fill).

- a11y (low): the browse-card playback-class dot conveyed meaning only via a
  mouse-hover title; added role=img + aria-label so SR/touch users get the class.

- tests: the NULL/unprobed transcode_hw -> SW fold (filter + facet), facet
  self-exclusion under an active playable filter, the migration codec-set ==
  HW_DECODABLE_CODECS drift guard, the probe-batch bound + oldest-first order,
  the transient probe-failure retry path, and the same-codec re-encode re-probe.

Backend gate green (ruff + full pytest); FE gate green (tsc/lint/knip/prettier/
vitest). NOTE: the facet-vs-runtime disagreement for 10-bit HEVC (C1) is the
known merge-order constraint — this branch's runtime is codec-only; the facet
becomes truthful once BUG1/tonemap's bit-depth-aware routing lands with it. Left
as a sequencing constraint, not code-changed here.
2026-08-30 17:51:33 +02:00
peter b563155a37 docs(plex): correct the cull-yield fallback comment to name the real route 404s
Deep-review round 3: the only surviving finding — a comment-drift nit. The
fallback's cull-yield comment named the route's 'Not available yet' path, but a
culled-key segment request actually returns 'No active playback session' (the
substantive invariant — never a None that 404s as file-not-found — is unchanged).
Reworded to match the route. Concurrency + correctness came back clean.
2026-08-30 15:18:26 +02:00
peter 915319fa35 fix(plex): make the HW->SW fallback supersede-check atomic; honest cull yield
Deep-review round 2 on the round-1 fix:

- Race (medium): the round-1 identity guard did the supersede check and the
  respawn in TWO separate _lock sections. A newer same-key session installed in
  the gap was still torn down by _spawn's unconditional pop+kill (the round-1
  comment even had the direction backwards). Fold the check into _spawn via a
  new replace_only= param: the identity test and the pop+replace now share ONE
  lock, so the respawn is a true no-op when superseded/culled.

- Honest states (low): the superseded branch returned current_session(key),
  which is None when the key was cap-culled during our out-of-lock wait; the
  route turns None into a dishonest 'Media file not found on disk' 404 for a
  file that exists. Yield 'current_session(key) or s' so a culled session
  degrades to the route's retryable 'not available yet' path instead.

- Hygiene: _want_hw docstring corrected (it is a test-only shim now; production
  decides via _select_encode) and the two stale _want_hw references refreshed
  (stream.py header, config.py plex_transcode_hw).

- tests: the cap-cull no-respawn/no-None sub-case; the _opencl_present except
  OSError branch (glob raising, not just empty); tightened a tautological -vf
  assertion. Backend gate green (ruff + full pytest).
2026-08-30 15:11:37 +02:00
peter a2a8f5681d fix(plex): don't tonemap 10-bit SDR; identity-guard the HW→SW fallback
Deep-review round 1 on feature/plex-hdr-hw-tonemap:

- F1 (correctness): _select_encode gated tonemap on bit-depth alone, so a
  10-bit *SDR* (bt709) source got the HDR hable tone curve applied, visibly
  altering an SDR picture where only a 10->8-bit downconvert was needed. Split
  the ffprobe gate into a 3-way classifier (_probe_video_kind: hwsafe / hdr /
  sdr10); only true-HDR (PQ/HLG) takes the OpenCL tonemap graph, 10-bit SDR
  drops to software libx264 (an exact downconvert). Applies to both auto and on.

- F2 (race): the runtime HW->SW fallback popped+killed _sessions[key] with no
  identity check. Because wait_for blocks OUTSIDE _lock, a concurrent same-key
  _spawn (rapid seek-restart) or _enforce_cap cull could kill this session; the
  fallback then respawned software over the NEWER session and breached the
  transcode cap. Guard the respawn: only fall back if _sessions[key] is still
  this session, else yield the currently-registered one.

- tests: 3-way classifier (incl. HLG + empty-probe), real _opencl_present glob,
  real wait_for proc fast-fail branch, tonemap-variant fallback, the F1
  regressions (auto + on), the supersede/no-respawn race, and the on/opencl
  matrix cell. Backend gate green (ruff + full pytest).
2026-08-30 15:02:41 +02:00
peter 4827aebb7f feat(plex): HW HDR→SDR tonemap for 10-bit/HDR transcode (OpenCL on the iGPU)
Upgrade mode-3's 10-bit/HDR branch from "route to software libx264" (BUG 1's
behaviour) to a full-GPU pipeline: VAAPI decode → OpenCL tonemap (hable,
HDR10/HLG → bt709 SDR nv12) → h264_vaapi. Gen 9.5 (UHD 630) can't tonemap in
the VAAPI VPP (tonemap_vaapi fails), so OpenCL is the only HW route — proven on
the prod iGPU at ~4.2x realtime, ~1 CPU core.

- stream.py: `_select_encode` returns (hw, tonemap); a 10-bit/HDR HW-decodable
  source picks the tonemap graph when the OpenCL runtime is present, else drops
  to software. `_opencl_present` gates on a registered ICD. `_venc_opts` /
  `_hwaccel_in_opts` gain the tonemap variant (named vaapi+opencl devices,
  `-filter_hw_device ocl`, hwmap→tonemap_opencl→hwmap reverse). `_want_hw` kept
  as the hw-half wrapper. The runtime HW→SW fallback still backstops a session
  that emits no segment.
- Dockerfile: add the Intel NEO OpenCL runtime (intel-opencl-icd). It was removed
  from Debian trixie, so it's pulled from bookworm (22.43 still supports Gen 9.5;
  the .deb runs on the trixie base — verified live). Harmless without /dev/dri.
- route + FE: thread `tonemap` through the session response; the stats overlay
  mode row reads "transcode · GPU · HDR→SDR" for the tonemap path.
- tests: tonemap command branch, the (hw, tonemap) selection matrix (auto/on ×
  8-bit/10-bit × opencl), start_session tonemap wiring, and the fmtMode tag.
2026-08-30 06:02:45 +02:00
peter 840526b1b8 feat(plex): filter the feed by playback class (Direct Play / Direct Stream / Transcode HW/SW)
Adds a movie-scoped "Playback" filter to the Plex feed so a user can isolate items by how
they play — the groundwork for collecting HW-transcodable (or no-transcode) versions.

Four classes derived from the stored `playable` + a new `transcode_hw` facet:
- Direct Play (direct), Direct Stream (remux)
- Transcode · HW  — playable=transcode AND the iGPU can hardware-transcode it
- Transcode · SW  — playable=transcode, software libx264 only

The HW/SW split is precise, not codec-only: a 10-bit/HDR HEVC is HW-decodable yet breaks the
VAAPI nv12 pipeline (the same case plex/stream special-cases), so it must count as SW.

Backend:
- New PlexItem columns (migration 0077): transcode_hw, video_bit_depth, media_probed_at.
  Migration backfills transcode_hw codec-only so the filter works immediately.
- `plex.sync.transcode_hw_capable` is the single classifier; the HW-decodable codec set moves
  to sync as `HW_DECODABLE_CODECS` and plex.stream imports it, so the catalog facet and the
  runtime encoder choice can't disagree by codec.
- Bit-depth backfill (`_probe_media_batch`, wired into sync): probes only HW-decodable transcode
  items' video Stream for bit-depth + HDR and refines a 10-bit/HDR HEVC to software. Bounded
  per run, watermarked by media_probed_at. A codec change resets the watermark (stale probe).
- Filter + self-excluded facet on a shared `playback_class_expr` CASE (movie-leg only — a show
  is an aggregate with no single class). transcode_hw threaded onto the feed/detail/card JSON.

Frontend:
- Multi-select "Playback" Combobox in the sidebar (movie scope, facet-empty drop-out), active
  chips, 4-way corner-dot badge (green/blue/amber/red) + tooltip, and an info-page playback chip
  with a codec · bit-depth subtitle. New `bg-info` (blue) token, i18n HU/EN parity.
- Fix a regression where the card's playback dot lost its hover tooltip: the full-card reveal
  overlay swallowed the cursor; the dot now sits above it (z-20).

Gate: backend full lane + frontend typecheck/lint/knip/prettier/vitest all green. Browser-UAT'd
on localdev (filter narrows to the SW set, facet counts, badge colour + tooltip, info chip).
2026-08-30 04:45:11 +02:00
peter 10d69cb316 fix(plex): route 10-bit/HDR HEVC to software + runtime VAAPI→libx264 fallback
A 10-bit/HDR HEVC source (e.g. an HDR10 x265 file) died at scale_vaapi=format=nv12
on the UHD 630 (Gen 9.5 VPP: "VAProfile not supported"); ffmpeg produced 0 segments
and the item never played, with no fallback — a retry just re-picked the failing HW
path. An 8-bit HEVC and the same file on libx264 both work.

Two defenses:
- `_want_hw` auto mode now also gates on an 8-bit-SDR source via a new
  `_probe_video_hwsafe` ffprobe (bit depth from pix_fmt/bits_per_raw_sample, HDR from
  color_transfer) — a 10-bit/HDR source takes the software libx264 path, which
  downconverts to yuv420p cleanly. The Plex mirror stores no bit-depth signal, so this
  is probed at session start. "on" still trusts the admin and skips the gate.
- `start_session` adds a runtime HW→SW fallback: a HW session that emits no first
  segment respawns on libx264 and reports hw=False (the route/stats overlay honestly
  show CPU). `wait_for` gained an optional `proc` so a dead ffmpeg fails fast instead
  of burning the 25s budget before the fallback fires.

Tests: TestProbeVideoHwsafe (parsing + probe-failure-assumes-safe), the 10-bit auto
gate + "on" skip in TestWantHw, and the no-segment HW→SW respawn in
TestTranscodeStartSession. Backend lane green.
2026-08-30 03:53:31 +02:00
peter a86131e062 fix(downloads): classify media_kind from a file probe, not yt-dlp info
A downloaded video was silently demoted to a download-only "file" (no
in-app play/edit) whenever yt-dlp's info dict omitted vcodec/acodec — which
it does for Facebook and other sources. Finalize derived media_kind from
those nulls (`derive(None, None)` -> file), so a genuine H.264/AAC MP4
became non-playable in the library.

Fix: probe the finished file on disk (the ground truth) at finalize and
derive from that, falling back to the info dict only for a field the probe
couldn't read. This is the same ffprobe the upload ingest already used, now
extracted to a shared downloads/probe.py so the download worker and the
upload path share one classifier (and can't drift). Also backfills the
stored vcodec/acodec/width/height from the real file.

Tests: TestResolveMediaFacts pins probe-wins-over-info (incl. the Facebook
no-codec case and yt-dlp's literal "none"); upload's probe tests repoint to
the shared module.
2026-08-30 03:32:14 +02:00
peter a84f2db310 release: 0.78.0 (feature/e6-plex-player-polish)
gate / frontend (push) Failing after 1m35s
gate / backend (push) Successful in 2m20s
2026-08-30 02:26:25 +02:00
peter 872d91f540 release: 0.78.0 — Plex transcode playback + player polish (E6) 2026-08-30 02:25:20 +02:00
peter 4eaa68ceeb feat(plex): S1a — add intel-media-va-driver for VAAPI transcode (E6)
The runtime image ships ffmpeg with h264_vaapi/libva2 but no VA driver, so
h264_vaapi can't initialise and a Plex transcode silently falls back to
libx264. Add the iHD driver (intel-media-va-driver) so the UHD 630 iGPU can
hardware-encode once /dev/dri is passed into LXC 118 (host + compose side,
tracked in siftlode-ops). Harmless where no render node is present.
2026-08-30 02:14:06 +02:00
peter 00539a4d97 fix(plex): deep-review follow-ups on E6 transcode + player polish
Eight verified findings from the sprint deep-review:

- Transcode cap: _enforce_cap now counts only transcode sessions, so a
  live remux (stream-copy) stream can no longer be LRU-evicted mid-play —
  matching the config comment + the HU/EN Config hint that promise remux
  is uncapped.
- Player Back: the browser/mouse Back interceptor (BackClose) now also
  mounts for the stats overlay and up-next card, so Back closes them first
  instead of ejecting the whole player (keyboard/Esc already did).
- Honest cold-start: loadSession clears sessionIsTranscode up front, so a
  transcode->direct/remux switch no longer flashes a stale "Preparing
  stream..." during the session request.
- Stats overlay a11y: the close (X) buttons in the stats, help and info
  overlays now read as "Close"/"Bezaras" (new plex.player.close key), not
  "Back"; dropped aria-live from the per-second stat grid so a screen
  reader is not flooded with telemetry.

Tests: route-level transcode routing (transcode item -> HLS re-encode
session, remux stays copy, direct opens none); start_session threading the
preset/crf/max_height knobs + hw/preexec into the ffmpeg argv; UpNextCard
countdown auto-advance, Play-now, Cancel and the S?-E? heading fallback.

Also brings UpNextCard.tsx / plexStats.ts / plexStats.test.ts to prettier.
2026-08-30 02:04:17 +02:00
peter dc24d353e0 feat(plex): honest transcode cold-start + encoder in stats (E6 S1d)
Surface the transcode session's runtime facts in the player. The session
response's transcode/hw flags now thread through: the stats overlay's mode row
reads "transcode · GPU" (VAAPI) or "transcode · CPU" (libx264) — the at-a-glance
confirmation of which S1 path engaged — and the initial-load overlay says
"Preparing stream…" instead of "Loading…" for a transcode session, since its
first segment is genuinely slower than a remux and shouldn't read as a hang.

fmtMode (pure, unit-tested) appends the encoder only for a transcode with a known
encoder. i18n HU/EN for the new preparing label. Browser-UAT'd on the XviD
fixture: mode row reads "transcode · CPU" live (software, no render node here).
2026-08-30 01:30:53 +02:00
peter ce96032edb feat(plex): transcode resource discipline + admin knobs (E6 S1c)
The software-fallback re-encode now runs deprioritized and admin-configurable.
The SW transcode ffmpeg child is niced (preexec_fn → os.nice, applied per-child
because the Popen lives in the long-lived API process, which must NOT itself be
niced — unlike the download worker's whole-process _deprioritize) so a CPU-bound
re-encode can't starve request handling or playback; the VAAPI path needs none of
it.

New sysconfig knobs (plex group, rendered on the admin Configuration page, i18n
HU/EN): plex_transcode_hw (auto/on/off, layered onto _want_hw), plex_transcode_
preset, plex_transcode_crf, plex_transcode_max_height (software-only downscale-to
cap; the GPU path encodes at source resolution). plex_max_transcodes stays the
concurrency cap (comment corrected — the fallback is shipped, not "later").

pytest extends the command-builder + _want_hw coverage (preset/crf/max_height
plumbing, the on/off/auto policy). Browser-UAT'd: max_height=240 drops the live
resolution to 330x240; the SW ffmpeg child runs at nice 10 (ppid=api process).
2026-08-30 01:22:30 +02:00
peter 0658f9550b feat(plex): transcode playback mode — VAAPI/libx264 re-encode (E6 S1b)
Non-h264 items (playable="transcode": HEVC/VP9/AV1/MPEG-2/XviD…) now play by
re-encoding the video to H.264 inside the existing HLS session, instead of the
old HTTP 501. VAAPI hardware encode (h264_vaapi, full GPU decode→scale→encode
pipeline) where the render node is present and the source is HW-decodable;
libx264 software fallback otherwise (yuv420p 8-bit, -threads reserve-one-core).

The re-encode mints a keyframe at every segment boundary (-force_key_frames) so
the HLS muxer still cuts independent segments and seeking works; everything else
— -copyts, the HLS tail, multi-audio renditions, the AAC audio path — is the
same machinery as remux, so hls.js/subtitles/seeking are unchanged. The session
route returns transcode/hw so the player can surface the active mode.

pytest pins both command branches (software + VAAPI shape) and the HW-selection
gate (render node present AND source HW-decodable). Browser-UAT'd on the XviD
fixture (Winning London): 501→plays, real 528×384 H.264 frames, 0 dropped, seek
lands clean; stats overlay Mode row reads "transcode".
2026-08-30 01:06:08 +02:00
peter f162788135 feat(plex): up-next end-card at episode end (E6 S4)
At episode end, when a next item exists and the pref is on, show an up-next
card (poster + title + ~10s countdown + Cancel) instead of the old instant jump,
so binge stays in the viewer's control (D6-A). Countdown or Play now advances;
Cancel/✕ dismisses and stays on the finished frame. Lives only on the
play-to-the-end path — credits-auto-skip advances earlier and never reaches the
card, so the two reconcile by construction (no double-advance). New pref
'upNext' (default on) with a Playback-tab toggle; the card closes on item change
and joins the Back/Escape cascade. i18n HU/EN.
2026-08-30 00:28:50 +02:00
peter 968674bc6a feat(plex): stats-for-nerds debug overlay (E6 S2)
Add a toggleable stats overlay to the Plex player: active mode
(direct/remux/transcode), transport, resolution, codec, bitrate, HLS level,
dropped frames, buffer-ahead, session offset and position — sampled once a
second off the live videoRef + hlsRef (no data layer). Non-modal corner panel,
aria-live polite, dismissable; toggled with 'd', a gauge control-bar button, or
its X. Number-crunching lives in a pure, unit-tested plexStats module. Doubles
as the S1 transcode-verification surface. i18n HU/EN.
2026-08-30 00:17:34 +02:00
peter 90ae9f6e88 feat(plex): on-screen ±10s skip buttons flanking play/pause (E6 S3)
Add discoverable rewind-10 / forward-10 controls to the Plex player control
bar, flanking play/pause (D5-A). New Skip10 glyph: an outline circular arrow
with a centred '10', deliberately distinct from the episode-level
SkipBack/SkipForward and from Restart's plain RotateCcw. Reuses seekTo; mirrors
the keyboard ←/→ (seekStep=10). i18n HU/EN aria-labels.
2026-08-30 00:09:36 +02:00
peter 3682e6a01f release: 0.76.0 (feature/e7-downloads-media-library)
gate / frontend (push) Failing after 1m36s
gate / backend (push) Successful in 2m26s
2026-08-29 21:54:19 +02:00
peter aca355f43b chore(release): 0.76.0 — E7 Downloads & Media Library
Bundles the whole E7 epic (library metadata, crop fix, share-by-default, in-app player,
browser-playable guarantee, local upload) plus the deep-review hardening. Bump VERSION and
add the 0.76.0 release-notes entry (RELEASE_NOTES[0] must match VERSION).
2026-08-29 21:52:21 +02:00
peter f41c81eb56 fix(downloads): media-library UI review fixes (E7)
Deep-review remediation on the frontend, all findings adversarially verified:

- LibraryPlayer picks audio vs video from the canonical media_kind (single source), not a
  hardcoded container whitelist, so an uploaded audio file with an uncommon/absent container
  renders in <audio> instead of a broken video box (legacy null media_kind keeps the heuristic).
- UploadDialog aborts the in-flight upload when the dialog is closed by X/ESC/backdrop/Back
  (not only the footer Cancel); drag highlight uses an enter/leave depth counter to stop the
  flicker; the progress bar gets a stable role=progressbar aria-label with the value on aria-valuenow.
- DownloadCenter's Shared tab renders honest loading/error states instead of a blank panel, and
  the library/shared row-action gating moves to a single tested helper (lib/downloadActions).

Tests: downloadActions gating matrix, the uploadReq XHR path (abort/401/422-localize/onerror),
and the media_kind-driven audio/video branch.

i18n: downloads.upload.uploadProgress (HU + EN).
2026-08-29 21:52:13 +02:00
peter 614c5528ad fix(downloads): harden upload serving + ingest against stored-XSS and leaks (E7 review)
Deep-review remediation for the E7 upload path, all findings adversarially verified:

- Serve every library file with a Content-Type chosen from the ffprobe-verified media_kind
  + a strict container allow-list (storage.served_media_type), never guessed from the
  user-controlled file extension. Closes a stored-XSS vector where a media/HTML polyglot
  uploaded as "evil.html" was served inline as text/html on the login-free /watch surface.
  Applied to both the authenticated /file and the public watch_file endpoints.
- Classify uploaded still images (jpg/png/gif/webp/…) as media_kind='file' so they are
  force-downloaded and don't get a broken in-app video player.
- Roll back the on-disk side of an upload when the ingest transaction fails after place_file
  (unlink the placed file + its deterministic poster path), so a failed commit can't orphan
  bytes in the GC-skipped .uploads tree.
- Floor download_upload_max_bytes at min=1 so a stored 0 can't silently disable the per-file cap.
- Dedupe the ffprobe-JSON invocation into edit.run_ffprobe_json (shared by probe_codecs and
  upload._probe) and the upload extension into storage._clean_upload_ext; drop the now-unused
  parse_probe_streams.

Tests: public + authenticated force-download and the polyglot-not-served-as-HTML guard, the
real _probe classifier (image/cover-art/dims/duration), the ingest rollback/cleanup path, the
max_jobs branch, the cap-floor invariant, and run_ffprobe_json error handling.
2026-08-29 21:52:02 +02:00
peter 104bd1e3a6 feat(downloads): public generic-file watch surface (E7 S5d)
The /watch/{token} SPA gets a third branch: media_kind==='file' renders an
info card (name / TYPE · size) + a Download button, no player. The <video>/
<audio> branches now guard on media_kind!=='file' so legacy links
(media_kind null) still play. Download is gated on the link's allow_download,
mirroring the media branch; the backend serves a file force-download
(attachment + octet-stream) regardless, so the gate only reflects owner ACL.

FE-only (public_meta already emits media_kind/size_bytes/container since S5a).
i18n HU/EN/DE 'file' fallback label. Two DOM tests: file -> info card +
Download, no player; view-only file -> no Download.
2026-08-29 19:51:58 +02:00
peter 584e9ced0c feat(downloads): local-upload UI — drag-drop dialog + progress (E7 S5c)
UploadDialog (drag-drop + native picker + optional display name) opened
from a new Upload button in the queue add-row. Uploads stream through a
new XHR helper (uploadReq in api/core) so the dialog shows real 0-100%
progress with a cancel — fetch/req can't report upload progress. The
upload lands as an already-done job; the S5b file-kind row gates it to
download+share. i18n HU/EN; upload cap/empty errors localize via the
shared structured-detail path.
2026-08-29 18:53:53 +02:00
peter b4ad59274b feat(downloads): gate library row actions by media_kind (E7 S5b)
Surface the S5a media_kind discriminator in the library UI. A generic
uploaded file (media_kind='file') is download+share only — no player and
no timeline to trim, so Play/Edit/Edit-details are media-only; it shows a
neutral file glyph instead of an (always-empty) thumbnail and no duration
chip (container/size still read). The "Shared with me" tab's file row is
gated the same way. Re-download is now hidden for edit clips and uploads
too (neither has a re-fetchable source — the backend already rejects both
with 409), removing a latent click-to-409 on edit rows.

FE-only: the serializer already emits media_kind (S5a). Adds media_kind to
the DownloadAsset type and "upload" to the job_kind union; DOM tests cover
the file-row presentation (glyph, no img, no duration).
2026-08-29 18:38:21 +02:00
peter fe0b34cd00 feat(downloads): local file upload ingest — media library backend (E7 S5a)
Add the non-yt-dlp ingest path so a user-uploaded file joins the same download
library and inherits its function set. Any file type is accepted: media
(video/audio) gets the full set (metadata, share, play/edit), a generic file
(pdf/zip/docx/…) is download-only.

- MediaAsset.media_kind ("video"|"audio"|"file") — the stored discriminator that
  gates library actions. Set at every finalize (download probe, editor carries the
  source's kind, upload probe); consolidates the ad-hoc audio-detect that lived only
  in links.py into one invariant (downloads.mediakind.derive). Migration 0076 adds
  the column + backfills existing rows.
- downloads/upload.py: ingest_upload probes the file (ffprobe → codecs/dims/duration),
  places it in a per-user .uploads tree, and creates an already-ready asset + done job
  (the same cache-hit shortcut enqueue uses). Uploads never expire (irreplaceable).
- POST /api/downloads/upload: streaming multipart with the per-file cap
  (download_upload_max_bytes, default 500 MiB) enforced on actual bytes read; quota
  charges the known size up front (quota.check_upload).
- Security: a generic file is always served force-download (attachment +
  application/octet-stream) on both the in-app and public endpoints, so uploaded
  HTML/SVG can't execute inline. Edit + redownload reject non-media / upload sources.
- python-multipart added (first multipart endpoint in the app).
2026-08-29 08:02:11 +02:00
peter 9eaa01617c feat(downloads): in-app player for library items (E7 S4b)
Play a completed library item in-app: a Play button on Library rows (and
Shared rows with download access) opens a modal that streams the local file
into a native <video controls> (or <audio> for audio-only assets).

Rather than the YouTube IFrame PlayerModal or the Plex HLS PlexPlayer — both
bound to their own domain APIs and unable to point at a local file — this
reuses the shared Modal a11y shell (focus-trap, ESC, layer-stack, portal) and
the range-aware /api/downloads/{id}/file endpoint. S4a's universal-codec
guarantee (mp4/H.264/AAC + m4a/mp3) makes native playback always work, so seek,
volume, fullscreen and speed come for free with zero transcode.
2026-08-29 07:18:58 +02:00
peter fdca12968b feat(downloads): one-click Share + downloadable-by-default links (E7 S3)
D2-A: the library row's Share button becomes a single action — quickShareLink()
ensures a public link (minting on demand), copies it to the clipboard, and toasts
with an "Open in new tab" action. A copy failure still toasts (warning) with that
action so the link is never a dead end. Advanced controls (password/expiry/revoke/
user-ACL) stay in the existing ShareDialog, reached via a new "Manage sharing" row
button.

D1 (forward-only): CreateLinkIn.allow_download defaults to true, so a newly minted
link is downloadable by default. The default lives in the API, not a migration —
the download_links column default stays false, so no existing link flips. The
ShareDialog create-default is flipped to match.

Tests: TestCreateLinkDefault (default true; explicit false honored); 4 vitest cases
for quickShareLink (mint-vs-reuse, copy ok/fail, create failure). HU/EN parity.
2026-08-29 06:36:07 +02:00
peter 94fcc40bde feat(downloads): guarantee every download is browser-playable (mp4/H.264/AAC)
The Download module's purpose is download -> share -> play in-browser, so the
output must play in EVERY browser (iOS/Safari/WebKit included). The only
universally-safe combo is mp4 + H.264 + AAC/MP3.

- formats.normalize() now CLAMPS every spec to that profile (container->mp4,
  vcodec->None, audio_format opus->m4a) at the single point all paths flow
  through (picker, stored profile, raw API) — not just by hiding UI options. A
  source exposing no H.264 is still caught by the worker's browser-safe
  re-encode, so playability is guaranteed end-to-end.
- build_ydl_opts drops the now-unreachable non-compat branches (+ _VCODEC_SORT).
- _SIG_VERSION 3->4 so assets cached under an old non-compat spec — or a stale
  non-H.264 mp4 that slipped the v3 net (the HEVC Facebook dls) — re-derive.
- ProfileEditor drops the container/codec pickers and Opus; audio is M4A/MP3.
- i18n: remove the now-unused container/vcodec/any keys (HU+EN parity).
2026-08-29 06:08:21 +02:00
peter fb2c843fa9 fix(downloads): anchor crop overlay to the letterboxed picture (E7 S2)
The video preview is object-fit: contain (the browser default for <video>), so a
non-16:9 source is letter/pillarboxed inside the element box. The crop marquee was
drawn in fractions of the whole box (bars included) and those fractions were then
multiplied by the source resolution, so the emitted ffmpeg crop diverged from what
the selector showed. Anchor the overlay to the computed content rect (and set
object-contain explicitly) so a marquee fraction is a fraction of the real picture;
extract the display-px<->source-px math into cropGeometry.ts with unit tests, and
clamp the even-rounded window in-bounds so a full-frame crop can't overflow.
2026-08-29 05:43:41 +02:00
peter bb6cb6e335 style(downloads): bump library-row meta text + action buttons a step (E7 S1)
Enlarge every non-title text on the card (channel, status/format/size/date meta,
source-ref line, progress readout) one step (xs→sm) plus their inline icons, and
grow the row action buttons (padding + icon) via the actions wrapper so the shared
IconBtn stays smaller in the modals. Buttons keep the title's line — the wrapper is
shrink-0/no-wrap, so the text column shrinks instead.
2026-08-29 05:29:19 +02:00
peter bdbff89507 feat(downloads): show container/format chip in library rows (E7 S1)
The completed download rows already surfaced size + duration; add the concrete
container (MP4/M4A/WebM…) as a compact chip so the library reads as a media list
with a visible format — the audio-vs-video distinction the running-only phase pill
can't show at rest. Pure display of the already-serialized asset.container; locked
by a small DOM test (present when ready, absent before the worker fills it).
2026-08-29 05:21:54 +02:00
155 changed files with 13560 additions and 910 deletions
+20 -4
View File
@@ -45,18 +45,34 @@ RUN adduser --disabled-password --gecos "" appuser \
&& mkdir -p /downloads \
&& chown appuser /app /downloads
# ffmpeg: merge separate video+audio streams + postprocessing. Deno: the JavaScript runtime
# yt-dlp uses to solve YouTube's "n" signature challenge for the web player clients (required
# alongside the PO token, else only storyboards are offered). yt-dlp auto-detects deno on PATH.
# ffmpeg: merge separate video+audio streams + postprocessing, and the Plex VAAPI transcode (E6 S1).
# intel-media-va-driver (iHD): the VA driver ffmpeg's h264_vaapi needs — libva2 ships in the base but
# no driver, so without this `vainfo`/`h264_vaapi` fail and transcode silently falls back to libx264.
# Harmless on hosts with no /dev/dri (the driver just goes unused). Deno: the JavaScript runtime yt-dlp
# uses to solve YouTube's "n" signature challenge for the web player clients (required alongside the PO
# token, else only storyboards are offered). yt-dlp auto-detects deno on PATH.
ARG DENO_VERSION=v2.9.1
RUN apt-get update \
&& apt-get install -y --no-install-recommends ffmpeg unzip curl ca-certificates \
&& apt-get install -y --no-install-recommends ffmpeg intel-media-va-driver unzip curl ca-certificates \
&& curl -fsSL "https://github.com/denoland/deno/releases/download/${DENO_VERSION}/deno-x86_64-unknown-linux-gnu.zip" -o /tmp/deno.zip \
&& unzip -o /tmp/deno.zip -d /usr/local/bin \
&& chmod +x /usr/local/bin/deno \
&& rm /tmp/deno.zip \
&& rm -rf /var/lib/apt/lists/*
# Intel NEO OpenCL runtime (intel-opencl-icd) — needed by the Plex transcode's HDR/10-bit HW path, which
# tone-maps HDR→SDR on the GPU via `tonemap_opencl` (Gen 9.5 / UHD 630 can't tonemap in the VAAPI VPP, so
# OpenCL is the only HW route; without this the 10-bit branch drops to CPU libx264). NEO was REMOVED from
# Debian trixie (this base = python:3.12-slim = trixie), so it's pulled from **bookworm** — the 22.43 there
# still supports Gen 9.5 and its .deb runs on trixie (verified live on the prod UHD 630). The bookworm apt
# source is added only for this one install and removed after, so it can't drift the rest of the image.
# Harmless on hosts with no /dev/dri or no OpenCL use (the runtime just sits unused).
RUN printf 'Types: deb\nURIs: http://deb.debian.org/debian\nSuites: bookworm\nComponents: main\nSigned-By: /usr/share/keyrings/debian-archive-keyring.pgp\n' > /etc/apt/sources.list.d/bookworm.sources \
&& apt-get update \
&& apt-get install -y --no-install-recommends -t bookworm intel-opencl-icd \
&& rm /etc/apt/sources.list.d/bookworm.sources \
&& rm -rf /var/lib/apt/lists/*
# Install from the PINNED lock (requirements.lock, generated from requirements.txt) so the image is
# byte-reproducible and the exact versions are the ones pip-audit scanned in CI — not whatever floats
# up from the floor ranges on build day. Regenerate the lock when requirements.txt changes (see its header).
+1 -1
View File
@@ -1 +1 @@
0.75.0
0.80.0
@@ -0,0 +1,41 @@
"""media_assets.media_kind (E7 S5a: the video|audio|file discriminator for the media library)
Adds a stored media-kind to each asset so the library can gate its actions on it (play/edit for
media, download-only for a generic uploaded file) instead of re-deriving from codecs in prose. The
column is the single home of the invariant `downloads.mediakind.derive` computes at finalize.
Backfill for existing rows: a stored video codec -> 'video', an audio-only codec -> 'audio', an
editor derivative (source_kind='edit', which carries no codecs but is always a video clip) ->
'video'. Rows still pending/errored (no codecs, not an edit) stay NULL — kind is set when they
finalize. Empty `media_kind` never reaches the UI: only `ready`/`done` rows render actions, and
those are all backfilled here.
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0076_media_asset_kind"
down_revision: Union[str, None] = "0075_playlist_sync_baseline"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("media_assets", sa.Column("media_kind", sa.String(length=16), nullable=True))
op.execute(
"""
UPDATE media_assets
SET media_kind = CASE
WHEN vcodec IS NOT NULL THEN 'video'
WHEN acodec IS NOT NULL THEN 'audio'
WHEN source_kind = 'edit' THEN 'video'
ELSE NULL
END
"""
)
def downgrade() -> None:
op.drop_column("media_assets", "media_kind")
@@ -0,0 +1,54 @@
"""plex_items playback-class facet (E7: filter the feed by Direct Play / Direct Stream / Transcode)
Adds the columns the playback-class filter needs:
* transcode_hw — for a `playable="transcode"` item, whether the prod iGPU can hardware-transcode it
(codec HW-decodable AND 8-bit SDR) vs software-only. NULL for non-transcode items.
* video_bit_depth — the source video bit depth (from the Plex media Stream), NULL until probed.
* media_probed_at — watermark for the bit-depth backfill (NULL = not yet probed).
Backfill for existing rows: `transcode_hw` is derived from the ALREADY-stored `codec_video` alone (the
same HW-decodable allow-list `plex.sync.HW_DECODABLE_CODECS` / `plex.stream` use), so the filter works
immediately after migration. Bit depth is unknown for existing rows (the cheap Plex listing doesn't
carry it), so a 10-bit/HDR HEVC is provisionally 'hw' here and later refined to 'sw' by the bit-depth
backfill (`plex.sync._probe_media_batch`) once its Stream is probed. `video_bit_depth`/`media_probed_at`
start NULL.
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0077_plex_playback_class"
down_revision: Union[str, None] = "0076_media_asset_kind"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("plex_items", sa.Column("transcode_hw", sa.Boolean(), nullable=True))
op.add_column("plex_items", sa.Column("video_bit_depth", sa.Integer(), nullable=True))
op.add_column(
"plex_items", sa.Column("media_probed_at", sa.DateTime(timezone=True), nullable=True)
)
op.create_index("ix_plex_items_transcode_hw", "plex_items", ["transcode_hw"])
# Codec-only derivation for existing rows — keep this codec set identical to
# plex.sync.HW_DECODABLE_CODECS. Bit depth is unknown here, so provisionally 8-bit (→ hw for a
# decodable codec); the bit-depth backfill downgrades 10-bit/HDR HEVC to sw later.
op.execute(
"""
UPDATE plex_items
SET transcode_hw = CASE
WHEN playable = 'transcode'
THEN lower(codec_video) IN ('h264','hevc','h265','vp9','vc1','mpeg2video')
ELSE NULL
END
"""
)
def downgrade() -> None:
op.drop_index("ix_plex_items_transcode_hw", table_name="plex_items")
op.drop_column("plex_items", "media_probed_at")
op.drop_column("plex_items", "video_bit_depth")
op.drop_column("plex_items", "transcode_hw")
@@ -0,0 +1,58 @@
"""users.password_revoked_at — remember that SEC-10 dropped a preset password
Revision ID: 0078_password_revoked_at
Revises: 0077_plex_playback_class
Create Date: 2026-09-02
SEC-10 (`auth.revoke_preset_password_if_unverified`) nulls `password_hash` when an account whose email
was never verified is attested (Google adoption, or admin approval). That left NO trace, and
password-reset eligibility keyed off `password_hash IS NOT NULL` — so such an account could neither
sign in (no password) nor request a reset link (not "eligible"): a permanent lockout with a silent,
uniform response at every step.
This column is that trace. A row with `password_revoked_at` set is a local account that HAD a password
and had it revoked, so the reset flow treats it as eligible again. Setting a password (reset-confirm or
/auth/set-password) clears the stamp. A Google-only account that never had a password keeps it NULL and
stays reset-ineligible by design — its owner adds a password from Settings while signed in.
Backfill: the signature is exact and narrow — a local (google_sub IS NULL), non-demo account with NO
password could ONLY have got there through the revoke. Registration always sets a hash, the setup
wizard sets one too, every Google row carries a google_sub (the adoption path assigns it BEFORE
revoking), and nothing in the codebase ever clears google_sub. Stamp those with the current time so
they become recoverable without operator intervention.
Deliberately NOT also requiring `email_verified = false`, even though the revoke only fires on an
unverified account: verifying is exactly what such a victim does NEXT. The revoke does not invalidate
the outstanding verification token, so clicking the emailed link sets email_verified=True while
password_hash stays NULL — and that row has no route back at all (reset ineligible, resend refuses an
already-verified address, and set-password needs a signed-in session the account can no longer obtain:
no password to sign in with, and no Google identity). Those are the victims who most need the stamp,
so the predicate must not exclude them.
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0078_password_revoked_at"
down_revision: Union[str, None] = "0077_plex_playback_class"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"users",
sa.Column("password_revoked_at", sa.DateTime(timezone=True), nullable=True),
)
op.execute(
"UPDATE users SET password_revoked_at = now() "
"WHERE password_hash IS NULL "
"AND google_sub IS NULL "
"AND is_demo = false"
)
def downgrade() -> None:
op.drop_column("users", "password_revoked_at")
@@ -0,0 +1,65 @@
"""email_canonical on users + invites, for matching one mailbox across Gmail spellings
Revision ID: 0079_email_canonical
Revises: 0078_password_revoked_at
Create Date: 2026-09-04
Gmail delivers `x.y@gmail.com`, `xy@gmail.com` and `xy+tag@gmail.com` to ONE mailbox; the invite gate
only lowercased. On 2026-09-03 that turned an approved user's Google sign-in into a silent second
access request (OAuth returns the account's stored primary, which need not be the spelling they typed)
and left prod with two user rows for one mailbox.
The backfill below reproduces `app.utils.canonical_email` in SQL. That duplication is deliberate — a
migration must not import application code that will keep changing — but it means the two have to
agree; `test_canonical_email_backfill_matches_the_helper` pins them together.
Deliberately NOT unique. Prod already carries canonical duplicates: two approved invites for one
mailbox dating back to 2026-06-16, and two active user rows from the 2026-09-03 walkthrough. Merging
accounts is a judgement call about people's data, not something a migration gets to do on its own, so
this migration only records the canonical form and leaves the duplicates in place for a human.
"""
import sqlalchemy as sa
from alembic import op
revision: str = "0079_email_canonical"
down_revision: str | None = "0078_password_revoked_at"
branch_labels: str | None = None
depends_on: str | None = None
# Mirrors app.utils.canonical_email: Gmail (and its googlemail alias) loses dots and any `+tag` from
# the local part and lands on gmail.com; every other domain is only lowercased, because the dot rule
# is not guaranteed elsewhere — and does not hold for Workspace custom domains unless their admin
# enabled it.
_CANON_SQL = """
CASE
WHEN split_part(lower({col}), '@', 2) IN ('gmail.com', 'googlemail.com')
-- an empty local part after stripping falls through to plain lowercase, as the helper does
AND replace(split_part(split_part(lower({col}), '@', 1), '+', 1), '.', '') <> ''
THEN replace(split_part(split_part(lower({col}), '@', 1), '+', 1), '.', '') || '@gmail.com'
ELSE lower({col})
END
"""
def _backfill(table: str) -> None:
op.execute(
f"UPDATE {table} SET email_canonical = {_CANON_SQL.format(col='email')} "
f"WHERE email IS NOT NULL"
)
def upgrade() -> None:
for table in ("users", "invites"):
op.add_column(
table,
sa.Column("email_canonical", sa.String(length=320), nullable=False, server_default=""),
)
_backfill(table)
op.create_index(f"ix_{table}_email_canonical", table, ["email_canonical"])
def downgrade() -> None:
for table in ("users", "invites"):
op.drop_index(f"ix_{table}_email_canonical", table_name=table)
op.drop_column(table, "email_canonical")
@@ -0,0 +1,32 @@
"""users.google_email — the address Google asserts, kept beside the one the user registered with
Revision ID: 0080_google_email
Revises: 0079_email_canonical
Create Date: 2026-09-04
Since 0079's follow-up a Google sign-in no longer overwrites `users.email`, so a deliberate
`me+siftlode@gmail.com` survives being linked to a Google account whose primary is `me@gmail.com`.
That leaves two true addresses for one account and only one of them stored. This column holds the
other, so the account menu and the Settings sign-in row can show which address Google actually
answers with.
No backfill is possible: Google's answer is only known at sign-in. Accounts linked before this
column existed carry NULL until their owner next signs in with Google — and for the majority the two
addresses are identical anyway, where the UI shows nothing either way.
"""
import sqlalchemy as sa
from alembic import op
revision: str = "0080_google_email"
down_revision: str | None = "0079_email_canonical"
branch_labels: str | None = None
depends_on: str | None = None
def upgrade() -> None:
op.add_column("users", sa.Column("google_email", sa.String(length=320), nullable=True))
def downgrade() -> None:
op.drop_column("users", "google_email")
@@ -0,0 +1,34 @@
"""invites.invited_at — when the admin actually told this person the door was open
Revision ID: 0081_invite_invited_at
Revises: 0080_google_email
Create Date: 2026-09-05
Whitelisting an address stays SILENT by design: the operator is opening a door as a possibility,
usually after agreeing it with the person, and a row can sit approved for weeks without anyone
registering. Telling them is therefore a separate, deliberate act — a send button on the row.
Which raises the one question the admin cannot otherwise answer: "did I already write to them?"
Without a record the only failure mode is writing to a real person twice, which is exactly the
nudging the silent-by-default design exists to avoid. The audit log holds the event, but not where
the decision is made.
NULL means "never invited from here", which is true of every existing row: no backfill is possible
or wanted, since the historic approval mails answered a REQUEST rather than inviting anyone.
"""
import sqlalchemy as sa
from alembic import op
revision: str = "0081_invite_invited_at"
down_revision: str | None = "0080_google_email"
branch_labels: str | None = None
depends_on: str | None = None
def upgrade() -> None:
op.add_column("invites", sa.Column("invited_at", sa.DateTime(timezone=True), nullable=True))
def downgrade() -> None:
op.drop_column("invites", "invited_at")
@@ -0,0 +1,34 @@
"""invites.lang — the language this person asked us in
Revision ID: 0082_invite_lang
Revises: 0081_invite_invited_at
Create Date: 2026-09-05
The mails an access request produces — approved, denied, invited — go to an address that often has
no account yet, so there is no `preferences.language` to read. The signal does exist, though, and
only at the moment of the request: the language of the page the form was submitted from, or the
locale Google reported for a sign-in we refused. Recording it there is what lets the decision mail
answer in the requester's own language instead of an admin's guess about a stranger.
Captured rather than derived, for the third time in this epic: `preferences.language` (S6) and
`google_email` (S3) both taught that a signal which exists only in one moment cannot be backfilled
later. So no backfill here either — NULL means "we were never told", which is the honest state for
every historic row, and for every address an admin whitelists by hand. Those fall back to English
unless the admin picks otherwise on the invitation send.
"""
import sqlalchemy as sa
from alembic import op
revision: str = "0082_invite_lang"
down_revision: str | None = "0081_invite_invited_at"
branch_labels: str | None = None
depends_on: str | None = None
def upgrade() -> None:
op.add_column("invites", sa.Column("lang", sa.String(length=8), nullable=True))
def downgrade() -> None:
op.drop_column("invites", "lang")
+8
View File
@@ -27,9 +27,17 @@ class AuditAction:
USER_SUSPEND = "user_suspend"
USER_UNSUSPEND = "user_unsuspend"
USER_DELETE = "user_delete"
# E9.5 S5: an admin let a stuck registrant in, or re-sent their confirmation link. Both are
# recorded because both are the admin acting ON someone's access, not merely reading a list.
USER_ACTIVATE = "user_activate"
USER_VERIFY_RESEND = "user_verify_resend"
INVITE_APPROVE = "invite_approve"
INVITE_DENY = "invite_deny"
INVITE_ADD = "invite_add"
INVITE_DELETE = "invite_delete"
# E9.5 S6: the admin chose to tell someone their address is whitelisted. Adding the address is
# silent, so this is the act that actually reaches a person — and the one worth a record.
INVITE_SEND = "invite_send"
DEMO_ADD = "demo_add"
DEMO_REMOVE = "demo_remove"
DEMO_RESET = "demo_reset"
+135 -8
View File
@@ -7,11 +7,13 @@ from sqlalchemy.orm import Session
from starlette.requests import HTTPConnection
from app import email as email_mod
from app import mailtext
from app import sysconfig
from app.config import settings
from app.db import get_db
from app.models import Invite, User
from app.security import decrypt_optional
from app.utils import canonical_email
PASSWORD_MIN_LEN = 10
@@ -63,16 +65,126 @@ def has_write_scope(user: User) -> bool:
return WRITE_SCOPE in tok.scopes.split()
def env_allowed_canonicals() -> set[str]:
"""The env bootstrap allow-list, in CANONICAL form — the only shape it may be compared in.
S3 canonicalised the Invite branch of `is_allowed` and left this one matching the literal string,
which is the 2026-09-03 lockout with a different source of truth: an operator who writes
`first.last@gmail.com` into ALLOWED_EMAILS is refused when Google answers with the undotted
primary, and a `+tag`ged entry can never match at all, because a Google account cannot contain a
`+`. The addresses themselves stay literal wherever we MAIL them (`admin_notify_groups`); only
the comparison is canonical.
"""
return env_admin_canonicals() | {canonical_email(e) for e in settings.allowed_email_set}
def env_admin_canonicals() -> set[str]:
"""The env ADMIN_EMAILS bootstrap list, canonical — for the role check only. Kept separate from
`env_allowed_canonicals` because being listed as an admin also grants access, but being allowed
does not make anyone an admin."""
return {canonical_email(e) for e in settings.admin_email_set}
def is_allowed(db: Session, email: str) -> bool:
"""May this email sign in? An approved Invite is the source of truth; the env
ALLOWED_EMAILS/ADMIN_EMAILS remain a bootstrap fallback (e.g. a fresh DB)."""
ALLOWED_EMAILS/ADMIN_EMAILS remain a bootstrap fallback (e.g. a fresh DB).
Matched on the CANONICAL form (utils.canonical_email), not the literal string. Gmail delivers
every dotted / `+tagged` spelling to one mailbox, so approving one spelling always meant approving
that mailbox — matching literally only produced the 2026-09-03 lockout, where an approved user's
Google sign-in became a silent second access request because OAuth returned the undotted primary.
One canonical form can match SEVERAL invite rows (prod carries approved pairs for one mailbox,
the oldest from 2026-06-16). An approved row among them grants access: the API cannot flip an
approved invite to denied (`_decide` only accepts a pending one), so approved-beside-denied means
the admin approved that mailbox under a different spelling, not that they revoked it. Revoking is
deleting the invite row or suspending the account — both still work, since a deleted row is no
longer here to match and suspension is enforced at sign-in.
"""
if not email:
return False
email = email.lower()
inv = db.execute(select(Invite).where(Invite.email == email)).scalar_one_or_none()
if inv is not None:
return inv.status == "approved"
return email in settings.allowed_email_set or email in settings.admin_email_set
canon = canonical_email(email)
statuses = (
db.execute(select(Invite.status).where(Invite.email_canonical == canon)).scalars().all()
)
if statuses:
return "approved" in statuses
return canon in env_allowed_canonicals()
def can_activate(db: Session, user: User) -> bool:
"""Is this account merely waiting for an admin to finish letting it in?
True when it is inactive but its address is ALREADY allowed — the shape roadmap UX-25 describes:
the admin approved the address, the person signed up afterwards, and nothing connects the two
(the Access-requests queue only offers Approve on a still-pending invite).
Gated on `is_allowed`, the same function the sign-in paths consult, because activating an address
that is NOT allowed manufactures an inconsistent account: password sign-in only checks
`is_active` and would let them in, while their Google sign-in would be turned away and silently
reopened as a fresh access request. Letting in someone with no approved invite is the whitelist
box's job — that action says so out loud.
Lives in core so the admin surface and the /api/me badge count answer from ONE definition; a
button that appears and a number that counts must never disagree about who is stuck.
"""
if user.is_demo or user.is_suspended or user.is_active:
return False
return is_allowed(db, user.email)
def count_pending_activations(db: Session) -> int:
"""How many sign-ups are waiting on an admin click (see can_activate). Drives the admin badge.
Deliberately NOT "everyone who cannot sign in": an account that has simply not confirmed its
address yet is waiting on its owner, not on the admin, and badging that would nag about something
the admin did not cause. It is still listed on the admin screen — with a re-send — because the
mail may never have arrived.
"""
# Narrowed in SQL, then decided by can_activate itself — so the number the badge shows and the
# button the admin sees cannot drift apart by one of them re-deriving the rule.
rows = (
db.execute(
select(User).where(
User.is_demo.is_(False),
User.is_suspended.is_(False),
User.is_active.is_(False),
)
)
.scalars()
.all()
)
return sum(1 for u in rows if can_activate(db, u))
def find_user_by_email(db: Session, email: str) -> User | None:
"""The account someone means when they type an address at sign-in, password-reset or
verify-resend. None when there isn't exactly one answer.
Exact spelling wins. Failing that, the address is resolved to its mailbox
(utils.canonical_email) — so a password chosen at registration still works when the person types
another spelling of their own Gmail, and so the three flows that look an account up by typed
address stop being dead ends. Before this, requesting a reset for `xy@gmail.com` on an account
registered as `x.y@gmail.com` issued nothing and said nothing: the endpoint answers uniformly by
design, so the user got silence and no way in.
SEVERAL canonical candidates return None rather than a guess. That case is real — prod carries two
accounts for one mailbox — and picking one of them for a password sign-in would hand someone the
wrong account. The callers all treat None as "no such account", which is the uniform,
non-enumerating answer they already gave.
"""
if not email:
return None
email = email.strip().lower()
exact = db.execute(select(User).where(User.email == email)).scalar_one_or_none()
if exact is not None:
return exact
candidates = (
db.execute(select(User).where(User.email_canonical == canonical_email(email)))
.scalars()
.all()
)
return candidates[0] if len(candidates) == 1 else None
def get_or_create_demo_user(db: Session) -> User:
@@ -119,6 +231,9 @@ def purge_user(db: Session, user: User, background: BackgroundTasks) -> None:
touch any browser session — each caller handles its own."""
email = user.email.lower()
user_id = user.id
# Read the language off the row while it still exists — the confirmation below is queued as a
# background task that runs after this session (and this user) are gone.
lang = mailtext.lang_for_user(user)
tok = user.token
# Both are stored encrypted — decrypt for the revoke call (the access-token fallback was passing
# ciphertext, so a token row with no refresh token silently failed to revoke). decrypt_optional so a
@@ -130,13 +245,25 @@ def purge_user(db: Session, user: User, background: BackgroundTasks) -> None:
)
# Raw delete so Postgres applies ON DELETE CASCADE / SET NULL on every dependent table.
db.execute(delete(User).where(User.id == user_id))
db.execute(delete(Invite).where(Invite.email == email))
# Every canonically-equal invite goes, not just the literal spelling: is_allowed matches on the
# canonical form, so leaving a sibling row behind would keep granting access to a mailbox whose
# account was just erased — and prod really does carry approved pairs for one mailbox.
#
# Unless ANOTHER account still lives on that mailbox — prod carries that too (two user rows for
# one Gmail mailbox). Then NO invite goes, not even the literal one: is_allowed matches
# canonically, so every row here is what keeps the survivor allowed, and removing any of them
# would refuse their next Google sign-in and reopen it as a fresh access request — this epic's
# founding bug, caused by its own cleanup. `delete_invite` refuses for exactly this reason; the
# user row is already gone by now, so this query sees only the others.
canon = canonical_email(email)
if db.execute(select(User.id).where(User.email_canonical == canon)).first() is None:
db.execute(delete(Invite).where(Invite.email_canonical == canon))
db.commit()
if google_token:
background.add_task(revoke_google_token, google_token)
# Confirm the erasure to the (now former) user — GDPR good practice, and covers both the
# self-service and admin deletion paths since both funnel through here.
background.add_task(email_mod.send_account_deleted, email, operator_contact())
background.add_task(email_mod.send_account_deleted, email, lang, operator_contact())
ACTIVE_ACCOUNT_HEADER = "x-siftlode-account"
+19 -2
View File
@@ -208,6 +208,10 @@ class Settings(BaseSettings):
download_default_max_bytes: int = 5_368_709_120 # 5 GiB
download_default_max_concurrent: int = 2
download_default_max_jobs: int = 50
# Per-file cap for local uploads (E7). Unlike a download (whose size is unknown until the worker
# finishes), an upload's size is known at ingest, so this bounds a single file directly; the
# user's overall byte footprint is still governed by download_default_max_bytes.
download_upload_max_bytes: int = 524_288_000 # 500 MiB
# Retention: an asset expires this many days after its last access; the GC job then
# deletes it (once no job references it) after an additional grace window.
download_retention_days: int = 30
@@ -254,9 +258,22 @@ class Settings(BaseSettings):
# that catches what the incremental history feed can't — notably an un-watch done on the Plex
# side — so it runs far less often (daily) than the incremental pass.
plex_watch_reconcile_interval_min: int = 1440
# Max concurrent transcodes for the P3 fallback. Low by default — CPU-only transcode is
# expensive; direct-serve (browser-compatible files) has no such limit.
# Max concurrent transcode sessions (playable="transcode"). Low by default — the software-fallback
# 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.
plex_max_transcodes: int = 4
# --- Transcode (playable="transcode") re-encode tuning (E6 S1) ---
# 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
# node exists (skip the decodability gate); "off" = always software. See plex/stream.py `_select_encode`.
plex_transcode_hw: str = "auto"
# Software-fallback libx264 tuning (IGNORED on the VAAPI path). `preset` trades CPU for size; `crf`
# trades quality for size (lower = better + bigger). `max_height` caps the SOFTWARE transcode's output
# height (0 = keep source) so a CPU re-encode of a tall source stays affordable — the GPU path always
# encodes at source resolution.
plex_transcode_preset: str = "veryfast"
plex_transcode_crf: int = 20
plex_transcode_max_height: int = 1080
# Where on-the-fly HLS remux segments are written (transient, reaped). Empty → a `.plex-hls`
# dir under download_root. On localdev the download_root is a slow Windows bind-mount, so point
# this at a fast container-local path (e.g. /var/tmp/plex-hls); prod's download_root is fast
+38 -30
View File
@@ -438,18 +438,42 @@ _BROWSER_SAFE_VCODEC = ("h264",)
_BROWSER_SAFE_ACODEC = ("aac", "mp3")
def parse_probe_streams(payload: str) -> tuple[str | None, str | None]:
"""(video_codec, audio_codec) out of ffprobe's JSON stream list, lowercased.
JSON, not the flat `default=nw=1` output: there the fields arrive in ffprobe's own struct
order, which puts `codec_name` BEFORE `codec_type`, so a line-by-line "remember the last
codec_type" parser attributes every name to the PREVIOUS stream. Measured on real downloads:
an ordinary H.264+AAC mp4 probed as `v=aac a=None` — the browser-playability check then
believed the video stream was AAC and re-encoded a perfectly fine file on every download."""
def run_ffprobe_json(
path: Path,
*,
show_streams: bool = False,
show_format: bool = False,
entries: str | None = None,
) -> dict | None:
"""One ffprobe call → its parsed JSON dict, or None on any failure (timeout, non-zero exit,
missing binary, unparseable output). The SINGLE ffprobe-JSON-invocation point in the downloads
package: the browser-playability codec probe here and the shared media probe (`probe.probe_media`,
used by both the download worker's finalize and the upload ingest) both go through it, so the
subprocess flags/timeout/error handling live in one place instead of drifting copies."""
args = ["ffprobe", "-v", "error"]
if show_streams:
args.append("-show_streams")
if show_format:
args.append("-show_format")
if entries:
args += ["-show_entries", entries]
args += ["-of", "json", str(path)]
try:
streams = json.loads(payload or "{}").get("streams") or []
except ValueError:
return None, None
out = subprocess.run(args, capture_output=True, text=True, timeout=30, check=True).stdout
return json.loads(out)
except (subprocess.TimeoutExpired, subprocess.CalledProcessError, OSError, ValueError):
return None
def _codecs_from_streams(streams: list) -> tuple[str | None, str | None]:
"""(video_codec, audio_codec) lowercased from an ffprobe JSON stream list — the first of each kind.
Parsed from the JSON `streams` list, NOT ffprobe's flat `default=nw=1` output: there the fields
arrive in ffprobe's own struct order, which puts `codec_name` BEFORE `codec_type`, so a line-by-line
"remember the last codec_type" parser attributes every name to the PREVIOUS stream. Measured on real
downloads: an ordinary H.264+AAC mp4 probed as `v=aac a=None` — the browser-playability check then
believed the video stream was AAC and re-encoded a perfectly fine file on every download. Reading each
stream dict by its own `codec_type` is immune to that ordering."""
vcodec = acodec = None
for s in streams:
name = (s.get("codec_name") or "").lower() or None
@@ -464,26 +488,10 @@ def parse_probe_streams(payload: str) -> tuple[str | None, str | None]:
def probe_codecs(path: Path) -> tuple[str | None, str | None]:
"""Return (video_codec, audio_codec) lowercased via one ffprobe call; None for a missing
stream or if ffprobe fails (a failed probe is treated as 'leave it alone' upstream)."""
try:
out = subprocess.run(
[
"ffprobe",
"-v",
"error",
"-show_entries",
"stream=codec_type,codec_name",
"-of",
"json",
str(path),
],
capture_output=True,
text=True,
timeout=30,
check=True,
).stdout
except (subprocess.TimeoutExpired, subprocess.CalledProcessError, OSError):
data = run_ffprobe_json(path, entries="stream=codec_type,codec_name")
if data is None:
return None, None
return parse_probe_streams(out)
return _codecs_from_streams(data.get("streams") or [])
def probe_duration(path: Path) -> int:
+46 -47
View File
@@ -5,13 +5,16 @@ of truth for what file gets produced. `format_sig` hashes exactly the fields tha
OUTPUT BYTES, so two users picking equivalent settings share one cached MediaAsset.
`build_ydl_opts` turns a spec into a yt-dlp options dict (format selector + postprocessors).
spec shape (all keys optional; defaults applied by `normalize`):
spec shape (all keys optional; defaults + browser-playability clamp applied by `normalize`):
mode "av" (audio+video) | "v" (video-only) | "a" (audio-only)
max_height int | null (null = best)
container "mp4" | "mkv" | "webm" | null (merge/remux target for av/v)
audio_format "m4a" | "mp3" | "opus" | null (extract target for mode "a")
vcodec "h264" | "vp9" | "av1" | null (preference, best-effort)
container always coerced to "mp4" (the only universally browser-playable container)
audio_format "m4a" | "mp3" (mode "a" extract target; anything else — e.g. opus — → "m4a")
vcodec always coerced to null → H.264 selected (see `normalize`); no opt-out
embed_subs bool embed_chapters bool embed_thumbnail bool sponsorblock bool
`normalize` clamps container/vcodec/audio_format so EVERY download plays in-browser (E7); the
`container`/`vcodec` keys survive only so an old stored profile deserializes without error.
"""
import hashlib
@@ -50,10 +53,12 @@ _DEFAULTS = {
# prefer H.264+AAC (browser/iOS-compatible) rather than yt-dlp's default VP9/Opus best.
# v3: mp4 compat now *selects* H.264 (avc1) with a progressive fallback + re-encodes the rare
# AV1/VP9-only result to H.264, so cached AV1 assets must re-derive (fixes iOS broken-player).
_SIG_VERSION = 3
# v4: EVERY download is clamped to the mp4/H.264/AAC compat profile (no more mkv/webm/VP9/AV1/Opus
# opt-out), so any asset cached under an old non-compat spec — or a stale non-H.264 mp4 that slipped
# the v3 net — must re-derive through the current browser-safe guarantee.
_SIG_VERSION = 4
_SPONSORBLOCK_CATEGORIES = ["sponsor", "selfpromo", "interaction"]
_VCODEC_SORT = {"h264": "vcodec:h264", "vp9": "vcodec:vp9", "av1": "vcodec:av01"}
def normalize(spec: dict) -> dict:
@@ -76,6 +81,18 @@ def normalize(spec: dict) -> dict:
# Audio-only files can't embed subtitles/thumbnails as video; keep the flags meaningful.
if s["mode"] == "a":
s["embed_subs"] = False
# Browser-playability guarantee (E7): the Download module exists to download → share → PLAY
# in-browser, so every output must play in EVERY browser, iOS/Safari/WebKit included. The only
# universally-safe combo is mp4 + H.264 + AAC/MP3, so we CLAMP the spec to it HERE — the single
# point every path (picker, stored profile, raw API) flows through — instead of only hiding
# options in the UI. Non-mp4 containers (mkv doesn't play at all; webm/VP9/AV1 fail iOS) and any
# explicit vcodec are coerced to the compat profile; Opus audio (Safari-flaky as a bare file)
# falls back to AAC/m4a. A source that exposes no H.264 is still caught by the worker's
# browser-safe re-encode (`_ensure_browser_playable`), so playback is guaranteed end-to-end.
s["container"] = "mp4"
s["vcodec"] = None
if s["audio_format"] not in ("m4a", "mp3"):
s["audio_format"] = "m4a"
return s
@@ -153,51 +170,33 @@ def build_ydl_opts(
)
else:
hcap = f"[height<=?{h}]" if h else ""
# Compatibility-first for mp4 (the universal container): produce H.264 video + AAC audio
# so shared/downloaded files play in EVERY browser, including iOS/Safari. WebKit (which
# every iOS browser, Brave included, is forced to use) decodes only H.264/H.265 + AAC
# inside mp4 — a VP9/AV1-in-mp4 file shows a broken player there, even though Chromium
# plays it fine. A custom profile that explicitly sets `vcodec` opts out.
compat = s["container"] == "mp4" and s["vcodec"] is None
# Every video download is the mp4/H.264 compat profile (normalize() clamps container→mp4,
# vcodec→None), so shared/downloaded files play in EVERY browser, iOS/Safari included. WebKit
# (which every iOS browser, Brave included, is forced to use) decodes only H.264/H.265 + AAC
# inside mp4 — a VP9/AV1-in-mp4 file shows a broken player there — so H.264 is selected at the
# SELECTION level, not merely preferred: a sort key can't conjure an H.264 stream a source
# doesn't expose as video-only. A source that ships only non-H.264 is caught after download by
# the worker's browser-safe re-encode (`_ensure_browser_playable`).
if s["mode"] == "v":
if compat:
# Video-only: keep it audio-free. Prefer an H.264 *video-only* stream, then any
# video-only, and only fall to a progressive (audio-bearing) `best` as a last
# resort — same audio-free-when-possible guarantee as the old `bestvideo/best`.
opts["format"] = (
f"bestvideo[vcodec^=avc1]{hcap}/bestvideo{hcap}"
f"/best[vcodec^=avc1]{hcap}/best{hcap}"
)
else:
opts["format"] = f"bestvideo{hcap}/best{hcap}"
# Video-only: keep it audio-free. Prefer an H.264 video-only stream, then any video-only,
# and only fall to a progressive (audio-bearing) `best` as a last resort.
opts["format"] = (
f"bestvideo[vcodec^=avc1]{hcap}/bestvideo{hcap}/best[vcodec^=avc1]{hcap}/best{hcap}"
)
else: # av
if compat:
# Prefer H.264 at the SELECTION level, not just via format_sort: a sort key is only
# a *preference* and can't conjure an H.264 stream a source doesn't expose as
# video-only. A non-YouTube source (e.g. Facebook) may ship AV1 as the best
# video-only stream and H.264 only as a *progressive* (a+v) format — which
# `bestvideo` skips — so `bestvideo+bestaudio` picked AV1 and iOS broke. This chain
# tries avc1 video-only, then the avc1 progressive, then anything.
opts["format"] = (
f"bestvideo[vcodec^=avc1]{hcap}+bestaudio[acodec^=mp4a]"
f"/best[vcodec^=avc1]{hcap}"
f"/bestvideo{hcap}+bestaudio/best{hcap}"
)
else:
opts["format"] = f"bestvideo{hcap}+bestaudio/best{hcap}"
if s["container"]:
opts["merge_output_format"] = s["container"]
sort = []
# Keep the codec preference as a tiebreaker (ranks H.264/AAC above resolution among the
# selected candidates); the selector above is the hard guarantee, this is belt-and-braces.
if compat:
sort += ["vcodec:h264", "acodec:aac"]
# avc1 video-only + m4a audio, then the avc1 progressive, then anything (re-encoded).
opts["format"] = (
f"bestvideo[vcodec^=avc1]{hcap}+bestaudio[acodec^=mp4a]"
f"/best[vcodec^=avc1]{hcap}"
f"/bestvideo{hcap}+bestaudio/best{hcap}"
)
opts["merge_output_format"] = "mp4"
# Codec preference tiebreaker (ranks H.264/AAC among the selected candidates); the selector
# above is the hard guarantee, this is belt-and-braces.
sort = ["vcodec:h264", "acodec:aac"]
if h:
sort.append(f"res:{h}")
if s["vcodec"] in _VCODEC_SORT:
sort.append(_VCODEC_SORT[s["vcodec"]])
if sort:
opts["format_sort"] = sort
opts["format_sort"] = sort
# SponsorBlock first (marks segments), then strip them from the media + chapters.
if s["sponsorblock"]:
+9 -6
View File
@@ -80,13 +80,13 @@ def public_meta(
Resolves the owner's per-download display overrides (custom title/channel) over the asset's
auto-extracted values, and surfaces the source + any extra reference links as clickable URLs."""
from app.downloads import service
from app.downloads import mediakind, service
source_url = service.reference_url(job, asset)
# Audio-only assets (mp3 rips, extracted audio) carry a codec but no video stream. WebKit (all
# iOS browsers) refuses to play them inside a <video> element, so the watch page must render a
# native <audio> instead — flag it here from the recorded codecs (a real video always has vcodec).
is_audio = asset.vcodec is None and asset.acodec is not None
# Audio-only assets (mp3 rips, extracted audio) can't play inside a <video> element on WebKit
# (all iOS browsers), so the watch page renders a native <audio> instead. A generic uploaded
# file (media_kind='file') has no player at all — the watch page shows file info + a download.
# Both branches read the stored media_kind (the single source), not a re-derived codec check.
return {
"title": (job and job.display_name) or asset.title,
"uploader": (job and job.display_uploader) or asset.uploader,
@@ -96,7 +96,10 @@ def public_meta(
"duration_s": asset.duration_s,
"width": asset.width,
"height": asset.height,
"audio": is_audio,
"audio": asset.media_kind == mediakind.AUDIO,
"media_kind": asset.media_kind,
"size_bytes": asset.size_bytes,
"container": asset.container,
"allow_download": link.allow_download,
"file_url": file_url,
"poster_url": poster_url,
+23
View File
@@ -0,0 +1,23 @@
"""The media-kind discriminator for a library asset — the single home of one invariant.
`MediaAsset.media_kind` is `"video" | "audio" | "file"`. It decides which library actions apply:
media (video/audio) gets play/edit/share; a generic uploaded file gets download + share only, and
is never rendered inline (see the force-download serving in the file/watch routes). Every finalize
path computes it HERE — the download worker + the upload ingest via `derive`, the editor by
carrying the source's kind over — so the rule lives in code, not in a comment that can drift (an
earlier ad-hoc `vcodec is None and acodec is not None` audio check lived only in links.py).
"""
VIDEO = "video"
AUDIO = "audio"
FILE = "file"
def derive(vcodec: str | None, acodec: str | None) -> str:
"""Media kind from the probed codecs: a video stream -> video, audio-only -> audio, neither
(a non-media upload: pdf/zip/docx/…) -> file."""
if vcodec:
return VIDEO
if acodec:
return AUDIO
return FILE
+9 -9
View File
@@ -15,7 +15,7 @@ from app.config import settings
from app.db import SessionLocal
from app.downloads import edit as editmod
from app.downloads import errors as dlerrors
from app.downloads import adapters, credentials, formats, service, storage
from app.downloads import adapters, credentials, formats, mediakind, probe, service, storage
from app.downloads.jobdb import (
_edit_staging_dir,
_job_status,
@@ -475,15 +475,15 @@ def _download(
asset.rel_path = rel
asset.size_bytes = size
asset.duration_s = meta.duration_s
asset.width = int(info["width"]) if info.get("width") else None
asset.height = int(info["height"]) if info.get("height") else None
# Codecs/dims come from a PROBE of the finished file — the ground truth — NOT yt-dlp's
# `info` dict, whose codec fields are absent for some sources (Facebook among them):
# deriving media_kind from those nulls misclassified a real H.264 video as a
# download-only 'file' with no in-app play/edit. Same probe the upload ingest uses.
asset.vcodec, asset.acodec, asset.width, asset.height = probe.resolve_media_facts(
final, info
)
asset.container = ext
asset.vcodec = (
info.get("vcodec") if info.get("vcodec") not in (None, "none") else None
)
asset.acodec = (
info.get("acodec") if info.get("acodec") not in (None, "none") else None
)
asset.media_kind = mediakind.derive(asset.vcodec, asset.acodec)
asset.title = meta.title
asset.uploader = meta.uploader
asset.uploader_url = adapter.uploader_url(info)
+107
View File
@@ -0,0 +1,107 @@
"""Ground-truth media probe for a produced library file — the SINGLE place both ingest paths ask
"what is actually on disk?".
`media_kind` (and the stored `vcodec`/`acodec`/`width`/`height`) must reflect the real file, not an
extractor's metadata: yt-dlp's `info` dict omits codec fields for some sources (e.g. Facebook), and
deriving the kind from those nulls misclassified a genuine H.264 video as a download-only `file`
(no in-app play). So the download worker (`pipeline`) and the local upload ingest (`upload`) both
probe the finished file here; `mediakind.derive` then turns the probed codecs into the kind.
"""
from pathlib import Path
from app.downloads import edit as editmod
# ffprobe container formats that mean "this is a still image / image sequence", NOT playable media.
# A standalone jpg/png/gif/webp/bmp reports a real video stream (codec mjpeg/png/…) with no
# `attached_pic` flag, so without this guard it would classify as media_kind='video' and get a Play
# button + a broken in-app <video> player (and inline serving on the public share). Keyed on the
# container format so an animated gif/webp also lands as a generic 'file' (no browser plays those in
# a <video> either). SVG and anything else ffprobe can't decode already fails the probe → 'file'.
_IMAGE_FORMATS = {
"image2",
"image2pipe",
"png_pipe",
"mjpeg",
"mjpeg_pipe",
"jpeg_pipe",
"gif",
"webp_pipe",
"bmp_pipe",
"tiff_pipe",
"ico",
"svg_pipe",
}
def _facts_from_probe(
data: dict,
) -> tuple[str | None, str | None, int | None, int | None, int | None]:
"""(vcodec, acodec, width, height, duration_s) parsed from a NON-None ffprobe JSON dict. A still
image / image-sequence container yields no vcodec (→ generic 'file'); a video stream flagged
`attached_pic` (embedded cover art in an mp3/m4a) is skipped — it's a thumbnail, not a real video
track, so an audio file with album art still classifies as audio."""
fmt = data.get("format") or {}
fmt_names = {n.strip() for n in (fmt.get("format_name") or "").split(",")}
if fmt_names & _IMAGE_FORMATS:
return None, None, None, None, None
vcodec = acodec = None
width = height = None
for stream in data.get("streams", []):
kind = stream.get("codec_type")
if kind == "video" and vcodec is None:
if (stream.get("disposition") or {}).get("attached_pic"):
continue # cover art, not a video track
vcodec = stream.get("codec_name")
width = stream.get("width")
height = stream.get("height")
elif kind == "audio" and acodec is None:
acodec = stream.get("codec_name")
duration = None
try:
raw = fmt.get("duration")
if raw is not None:
duration = int(float(raw))
except (TypeError, ValueError):
duration = None
return vcodec, acodec, width, height, duration
def probe_media(path: Path) -> tuple[str | None, str | None, int | None, int | None, int | None]:
"""(vcodec, acodec, width, height, duration_s) from one ffprobe call; all None if ffprobe fails
or the file isn't playable media (an unprobeable file — or a still image — is treated as a generic
non-media file, so it becomes a download-only 'file' rather than a broken 'video')."""
data = editmod.run_ffprobe_json(path, show_streams=True, show_format=True)
if data is None:
return None, None, None, None, None
return _facts_from_probe(data)
def resolve_media_facts(
path: Path, info: dict
) -> tuple[str | None, str | None, int | None, int | None]:
"""(vcodec, acodec, width, height) for the stored download asset: PROBE the finished file on disk
(the ground truth). yt-dlp's `info` omits codec/dimension fields for some sources (Facebook among
them), so deriving media_kind from it there labelled a real H.264 video a download-only 'file'.
Precedence is by whether the probe RAN, not per-field: if ffprobe read the file, its codec verdict
is authoritative — INCLUDING a deliberate None (cover-art-only audio, a still image). A per-field
`probe or info` fallback would let `info`'s stale vcodec resurrect a video track the file doesn't
have (yt-dlp reports a progressive `vcodec` for an audio-mode download whose video it then strips),
mislabelling an audio download as a broken in-app 'video'. Only when ffprobe could NOT read the
file at all do we fall back to `info` (the pre-probe behaviour, a safety net); the cosmetic
dimensions still gap-fill from `info` in either case."""
data = editmod.run_ffprobe_json(path, show_streams=True, show_format=True)
info_v = info.get("vcodec") if info.get("vcodec") not in (None, "none") else None
info_a = info.get("acodec") if info.get("acodec") not in (None, "none") else None
info_w = int(info["width"]) if info.get("width") else None
info_h = int(info["height"]) if info.get("height") else None
if data is None:
# ffprobe could not read the file — fall back to the extractor's info entirely.
return info_v, info_a, info_w, info_h
pv, pa, pw, ph, _dur = _facts_from_probe(data)
# Probe ran → its codecs win outright (a None means "no such track", not "unknown"); info only
# gap-fills the cosmetic width/height the probe couldn't read.
return pv, pa, pw if pw is not None else info_w, ph if ph is not None else info_h
+16
View File
@@ -122,6 +122,22 @@ def check_enqueue(db: Session, user_id: int) -> None:
raise QuotaExceeded("max_bytes", lim.max_bytes, used)
def check_upload(db: Session, user_id: int, add_bytes: int) -> None:
"""Like check_enqueue, but for an upload whose size is KNOWN at ingest: also reject when adding
this file would push the footprint over max_bytes. A download can't pre-check its size (unknown
until the worker finishes) so check_enqueue only compares the current footprint; an upload can,
so it's charged against the cap before it lands. Raises QuotaExceeded(max_jobs|max_bytes)."""
lim = resolve(db, user_id)
if lim.unlimited:
return
n = active_jobs(db, user_id)
if n >= lim.max_jobs:
raise QuotaExceeded("max_jobs", lim.max_jobs, n)
projected = footprint(db, user_id) + add_bytes
if projected > lim.max_bytes:
raise QuotaExceeded("max_bytes", lim.max_bytes, projected)
def usage(db: Session, user_id: int) -> dict:
"""Compact usage snapshot for the UI (footprint + counts vs limits)."""
lim = resolve(db, user_id)
+8 -1
View File
@@ -17,7 +17,7 @@ from app.config import settings
from app.downloads import adapters
from app.downloads import credentials
from app.downloads import edit as editmod
from app.downloads import formats, quota, storage
from app.downloads import formats, mediakind, quota, storage
from app.downloads.status import HOLDING, JobStatus
from app.models import Channel, DownloadJob, MediaAsset, Video
@@ -216,6 +216,10 @@ def enqueue_edit(
src_asset = db.get(MediaAsset, source_job.asset_id) if source_job.asset_id else None
if src_asset is None or src_asset.status != "ready" or not src_asset.rel_path:
raise EditError("source_not_ready")
# A generic uploaded file (media_kind='file') has no timeline to trim/crop — the editor path is
# media-only. Reject here so an upload's file never reaches the ffmpeg edit worker.
if src_asset.media_kind == mediakind.FILE:
raise EditError("not_media")
spec = editmod.normalize_edit_spec(edit_spec)
if not spec:
@@ -232,6 +236,9 @@ def enqueue_edit(
asset.thumbnail_url = src_asset.thumbnail_url
asset.upload_date = src_asset.upload_date
asset.duration_s = editmod.clip_duration(spec, src_asset.duration_s)
# A clip inherits its source's kind (a video edit stays video, an audio trim stays audio);
# the ffmpeg edit worker doesn't re-probe codecs, so set it here where the source is known.
asset.media_kind = src_asset.media_kind or mediakind.VIDEO
job = DownloadJob(
user_id=user_id,
+77
View File
@@ -19,6 +19,8 @@ from datetime import date
from pathlib import Path
from xml.sax.saxutils import escape
from app.downloads import mediakind
# Reserve room for the "_id.ext" suffix + parent dirs within the 255-char component limit.
_MAX_TITLE = 120
# User-facing (Content-Disposition) names can be longer than an on-disk component.
@@ -147,6 +149,81 @@ def edit_rel_path(user_id: int, asset_id: int, ext: str) -> str:
return f".edits/{user_id}/clip_{asset_id}.{ext}"
def _clean_upload_ext(filename: str) -> str:
"""The upload's extension reduced to a shell-safe, lowercased, bounded token (``""`` when the
upload has no extension). The SINGLE definition feeding both the on-disk suffix and the stored
`container` — they must agree (the served Content-Disposition suffix has to match the container),
so the sanitize/truncation rule lives in one place, not two drifting copies."""
return _UNSAFE.sub("", Path(filename).suffix.lstrip(".")).lower()[:16]
def upload_rel_path(user_id: int, asset_id: int, filename: str) -> str:
"""On-disk path (relative to DOWNLOAD_ROOT) for a locally-uploaded file.
Kept in a per-user `.uploads/` tree — device-downloadable via the file endpoint, but NOT part of
the Plex library (an upload is arbitrary user content, not a canonical episode; no .nfo/poster
episode sidecars). The name is system-decided and bound to the asset id (`_a{id}` keeps two
same-named uploads apart); the original filename is display metadata only. The extension is
preserved (shell-safe, bounded) so the served file + its Content-Disposition carry a correct
suffix; an extensionless upload stays extensionless."""
stem = sanitize(Path(filename).stem, 80)
ext = _clean_upload_ext(filename)
suffix = f".{ext}" if ext else ""
return f".uploads/{user_id}/{stem}_a{asset_id}{suffix}"
def upload_ext(filename: str) -> str | None:
"""The stored `container` for an uploaded file: its shell-safe lowercased extension, or None when
the upload has no extension (a bare name). Shares `_clean_upload_ext` with `upload_rel_path` so the
stored container and the on-disk suffix can never diverge."""
return _clean_upload_ext(filename) or None
# The Content-Type used to serve an inline-playable media asset, keyed by its stored `container`.
# DELIBERATELY a strict allow-list of known audio/video containers: the served type is chosen from
# the asset's ffprobe-verified media_kind + this table, NEVER guessed from the raw (user-controlled)
# upload extension. Without this, an uploaded media/HTML polyglot named "evil.html" — a real MP3/MP4
# that ffprobe classifies as media, so it dodges the generic-file force-download — would be served
# with a Content-Type Starlette guesses from ".html" (text/html) and execute as stored XSS on the
# serving origin. A media asset whose container isn't in this table falls back to a neutral
# force-download attachment (see `served_media_type`), so nothing script-executable is ever inline.
_MEDIA_MIME: dict[str, str] = {
# video containers
"mp4": "video/mp4",
"m4v": "video/mp4",
"mov": "video/quicktime",
"webm": "video/webm",
"mkv": "video/x-matroska",
"ogv": "video/ogg",
"avi": "video/x-msvideo",
# audio containers
"m4a": "audio/mp4",
"mp3": "audio/mpeg",
"aac": "audio/aac",
"ogg": "audio/ogg",
"oga": "audio/ogg",
"opus": "audio/ogg",
"weba": "audio/webm",
"wav": "audio/wav",
"flac": "audio/flac",
}
def served_media_type(media_kind: str | None, container: str | None) -> str | None:
"""The Content-Type a library file MUST be served with — a real audio/video MIME for a recognised
media container, else ``None`` meaning "serve as a neutral force-download attachment".
Returns None for a generic ``file`` kind AND for any media whose container we don't positively
recognise as safe. Callers serve None as ``application/octet-stream`` + ``attachment`` so the
served Content-Type is NEVER derived from the (attacker-controlled) file extension — the guard
that stops uploaded HTML/SVG or a media/HTML polyglot from executing inline on the serving
origin. A ``None`` media_kind (a legacy asset before the discriminator was backfilled) is treated
as media so an old download keeps its inline player."""
if media_kind == mediakind.FILE:
return None
return _MEDIA_MIME.get((container or "").lower())
def download_filename(display_name: str, container: str | None, path: Path) -> str:
"""The Content-Disposition filename for a served download: the cleaned display name carrying a single
correct extension — the asset container, else the file's own suffix — without doubling it when the
+122
View File
@@ -0,0 +1,122 @@
"""Local file ingest for the download center (E7 S5) — the non-yt-dlp path into the media library.
A user-uploaded file joins the SAME library as a downloaded one, so it inherits the whole function
set already built (metadata chips, share links, and — for media — in-app play/edit). Unlike a
download it bypasses yt-dlp AND the worker entirely: the file is already on the host, so ingest
probes it, places it on disk, and creates an already-`ready` MediaAsset + `done` DownloadJob in one
step — the same "the file exists, mark it done now" shortcut `service.enqueue` takes on a cache hit.
Kinds (the invariant lives in `mediakind`): a probe with a real video stream -> video, audio-only
-> audio, neither -> a generic 'file' (pdf/zip/docx/…) that is download-only and never rendered
inline (see the force-download serving in the file/watch routes). This module is the ingest seam a
future sub-function (e.g. convert) plugs a new adapter into, mirroring how it sidesteps the worker.
An upload does NOT get an `expires_at`: it's user-provided and may be irreplaceable (there's no
source to re-download), so it doesn't age out on the retention clock the way a re-fetchable download
does — it lives until the user deletes it.
"""
import secrets
from datetime import datetime, timezone
from pathlib import Path
from sqlalchemy.orm import Session
from app.config import settings
from app.downloads import edit as editmod
from app.downloads import mediakind, probe, quota, service, storage
from app.models import DownloadJob, MediaAsset
def _title_from_filename(filename: str) -> str:
"""A human display title from the original upload name: the stem with separators tidied to
spaces (kept as-is otherwise — this is display metadata, not the shell-safe on-disk name)."""
stem = Path(filename).stem.replace("_", " ").replace(".", " ").strip()
return (stem or "Upload")[:255]
def ingest_upload(
db: Session,
user_id: int,
*,
filename: str,
src_path: Path,
size: int,
display_name: str | None = None,
) -> DownloadJob:
"""Ingest an already-uploaded file at `src_path` into `user_id`'s library and return the `done`
job. `src_path` is MOVED into place on success (the caller stages it to a temp file first).
Raises quota.QuotaExceeded (holdings/footprint cap) — checked BEFORE any disk write, so a
rejected upload leaves nothing behind and the caller just cleans up its temp file."""
quota.check_upload(db, user_id, size) # the file's size is known → charge it up front
vcodec, acodec, width, height, duration = probe.probe_media(src_path)
kind = mediakind.derive(vcodec, acodec)
# An upload never dedups (each file is its own asset): a random ref makes the cache key unique,
# so `format_sig` is a constant marker rather than a format hash.
ref = f"{user_id}:{secrets.token_hex(8)}"[:512]
asset = MediaAsset(
source_kind="upload", source_ref=ref, format_sig="upload", status="pending", ref_count=0
)
db.add(asset)
db.flush() # need asset.id for the on-disk path
rel = storage.upload_rel_path(user_id, asset.id, filename)
storage.place_file(src_path, settings.download_root, rel)
# From here the file is on disk under `.uploads/`. If the transaction fails before it commits, the
# asset/job rows roll back but the placed file would be ORPHANED — nothing references it, the
# per-user delete path keys off asset.rel_path (which is gone), and the GC orphan sweep skips the
# dot-prefixed `.uploads/` tree. So roll back the on-disk side too on any failure.
try:
final = storage.abs_path(settings.download_root, rel)
actual_size = final.stat().st_size if final.exists() else size
asset.status = "ready"
asset.rel_path = rel
asset.size_bytes = actual_size
asset.container = storage.upload_ext(filename)
asset.vcodec = vcodec
asset.acodec = acodec
asset.width = width
asset.height = height
asset.duration_s = duration
asset.media_kind = kind
asset.title = (display_name or "").strip() or _title_from_filename(filename)
asset.last_access_at = datetime.now(timezone.utc)
# No expires_at — see the module docstring (an upload is irreplaceable, so it doesn't age out).
if kind == mediakind.VIDEO:
# A frame poster gives the library row a thumbnail + the share link an og:image. Audio has
# no frame and a generic file no player, so neither gets one. Best-effort (ffmpeg decodes
# server side even for a codec the browser can't play).
asset.poster_path = editmod.ensure_poster(asset, settings.download_root)
job = DownloadJob(
user_id=user_id,
asset_id=asset.id,
job_kind="upload",
source_kind="upload",
source_ref=ref,
profile_snapshot={},
display_name=(display_name or "").strip() or None,
status="done",
progress=100,
queue_pos=service.next_queue_pos(db),
)
db.add(job)
service.incr_ref_count(db, asset.id)
db.commit()
except Exception:
db.rollback()
# The rows are gone; remove the now-unreferenced file so no bytes leak. Clean the poster by its
# DETERMINISTIC path (rel with a .jpg suffix, the same path ensure_poster writes to) rather than
# ensure_poster's return value: it writes the frame directly to that path and returns None on an
# ffmpeg failure, so a partial poster can be on disk while the return is None — gating on the
# return would skip that orphan. unlink is missing_ok, so the poster path is a harmless no-op
# when none was generated (audio/file, or a video whose poster never started).
for leaked in (rel, str(Path(rel).with_suffix(".jpg"))):
storage.abs_path(settings.download_root, leaked).unlink(missing_ok=True)
raise
db.refresh(job)
return job
+252 -114
View File
@@ -3,36 +3,103 @@
Gmail SMTP + App Password by default. Deliberately fail-soft: if SMTP isn't configured
or a send errors, we log and return False so the caller can fall back to in-app
notifications — email is never load-bearing for the app's core flow.
Fail-soft must not mean invisible, though: every drop also leaves an in-app notice in the admins'
inbox (see `_alert_admins`). An instance whose SMTP was never filled in silently swallows
verification links, password resets and access-request notices, and the operator's first clue used
to be a person telling them nothing arrived.
"""
import html
import logging
import re
import smtplib
import ssl
from email.message import EmailMessage
from email.utils import formatdate
from app import sysconfig
from sqlalchemy import select
from sqlalchemy.orm import Session
from app import mailtext, sysconfig
from app.config import settings
from app.db import SessionLocal
from app.models import User
from app.notifications import create_notification
from app.ratelimit import RateLimiter
log = logging.getLogger("siftlode.email")
def _smtp() -> dict:
"""Effective SMTP config (admin DB override over env defaults). Opens its own short
session because email is often sent from a BackgroundTask, outside a request's db."""
with SessionLocal() as db:
return {
"host": sysconfig.get_str(db, "smtp_host"),
"port": sysconfig.get_int(db, "smtp_port"),
"user": sysconfig.get_str(db, "smtp_user"),
"password": sysconfig.get_str(db, "smtp_password"),
"from": sysconfig.get_str(db, "smtp_from"),
}
# One admin notice an hour per reason. A relay that is down fails every send, and the inbox must
# stay readable; the log keeps the full record either way.
_alert_throttle = RateLimiter(max_events=1, window_seconds=3600)
def email_enabled() -> bool:
c = _smtp()
def _alert_admins(reason: str, subject: str, recipients: list[str]) -> None:
"""Record an in-app notice that a message did NOT go out. Best effort in every direction: it
opens its own session (callers are often background tasks), swallows its own errors, and never
reports failure to the caller — this is the fallback path, it must not become a new way to
break a send."""
if not _alert_throttle.allow(reason):
return
try:
with SessionLocal() as db:
admins = (
db.execute(
select(User.id).where(
User.role == "admin",
User.is_active.is_(True),
User.is_suspended.is_(False),
)
)
.scalars()
.all()
)
if not admins:
# Nothing was recorded, so don't let this cost the next hour of alerts on the very
# path whose job is to stop drops going unseen. Same on the failure branch below.
_alert_throttle.reset()
return
for uid in admins:
create_notification(
db,
user_id=uid,
type="email_undelivered",
# English fallback; the inbox renders a translated version from `data`.
title="Email could not be sent",
body=subject,
data={"reason": reason, "subject": subject, "recipients": recipients},
commit=False,
)
db.commit()
except Exception:
_alert_throttle.reset()
log.exception("Could not record the undelivered-email notice (%r)", subject)
def _read_smtp(db: Session) -> dict:
return {
"host": sysconfig.get_str(db, "smtp_host"),
"port": sysconfig.get_int(db, "smtp_port"),
"user": sysconfig.get_str(db, "smtp_user"),
"password": sysconfig.get_str(db, "smtp_password"),
"from": sysconfig.get_str(db, "smtp_from"),
}
def _smtp(db: Session | None = None) -> dict:
"""Effective SMTP config (admin DB override over env defaults). Opens its own short session when
the caller has none, because email is usually sent from a BackgroundTask outside a request's db.
A caller that IS inside a request should pass its session: reading the password decrypts a Fernet
value, and /api/me polls this for every admin tab."""
if db is not None:
return _read_smtp(db)
with SessionLocal() as own:
return _read_smtp(own)
def email_enabled(db: Session | None = None) -> bool:
c = _smtp(db)
return bool(c["host"] and c["user"] and c["password"])
@@ -40,6 +107,48 @@ def _admin_contact() -> str | None:
return next(iter(sorted(settings.admin_email_set)), None)
# A bare URL in a text/plain mail is only clickable if the CLIENT decides to linkify it. Gmail,
# Outlook and Apple Mail do; plenty of lighter web clients render the body verbatim and leave the
# link as dead text — which, for a verification or password-reset mail, is the whole message. So
# every mail also carries an HTML alternative built from the SAME plain body (below), rather than
# per-template HTML that would have to be kept in sync with it.
#
# `[^\s<]` stops at whitespace and at the `<` of any following markup; the trailing-punctuation trim
# keeps a sentence-final period out of the href. `;` and `:` are deliberately NOT trimmed — escaping
# turns `&` into `&amp;`, so a query string can legitimately end in a semicolon.
_URL_RE = re.compile(r"https?://[^\s<]+")
def _html_body(text: str) -> str:
"""Render a plain-text mail body as minimal HTML: escaped, blank-line-separated paragraphs, with
real links. Escaping happens FIRST and the linkifier then runs over the escaped text — bodies
interpolate untrusted values (a requester's email address, the operator contact), so no caller
can inject markup, and `&` inside a URL is already `&amp;` by the time it reaches the href, which
is what an HTML attribute wants anyway."""
def _anchor(m: re.Match[str]) -> str:
url = m.group(0)
trail = ""
while url and url[-1] in ".,!?)":
url, trail = url[:-1], url[-1] + trail
if not url:
return trail
return f'<a href="{url}">{url}</a>{trail}'
paragraphs = [p for p in html.escape(text).split("\n\n") if p.strip()]
rendered = "".join(
f"<p>{_URL_RE.sub(_anchor, p).replace(chr(10), '<br>')}</p>" for p in paragraphs
)
# Inline styles only — many clients drop <style> blocks. No images, no external CSS, no tracking:
# the HTML part exists to make the link clickable, not to become a template to maintain.
return (
'<html><body style="margin:0;padding:16px;font-family:system-ui,-apple-system,'
"'Segoe UI',sans-serif;font-size:15px;line-height:1.6\">"
f'<div style="max-width:560px">{rendered}</div>'
"</body></html>"
)
def _send(to: list[str], subject: str, body: str, reply_to: str | None = None) -> bool:
recipients = [e for e in to if e]
if not recipients:
@@ -47,6 +156,7 @@ def _send(to: list[str], subject: str, body: str, reply_to: str | None = None) -
c = _smtp()
if not (c["host"] and c["user"] and c["password"]):
log.info("SMTP not configured; skipping email %r to %s", subject, recipients)
_alert_admins("not_configured", subject, recipients)
return False
msg = EmailMessage()
msg["Subject"] = subject
@@ -57,7 +167,10 @@ def _send(to: list[str], subject: str, body: str, reply_to: str | None = None) -
msg["Date"] = formatdate(localtime=True)
if reply_to:
msg["Reply-To"] = reply_to
# text/plain stays the primary part (it is the source of truth, and a text-only client still
# gets the full message); the HTML alternative below is what makes the link clickable everywhere.
msg.set_content(body)
msg.add_alternative(_html_body(body), subtype="html")
try:
with smtplib.SMTP(c["host"], c["port"], timeout=20) as s:
s.starttls(context=ssl.create_default_context())
@@ -67,128 +180,153 @@ def _send(to: list[str], subject: str, body: str, reply_to: str | None = None) -
return True
except Exception:
log.exception("SMTP send failed (%r to %s)", subject, recipients)
_alert_admins("send_failed", subject, recipients)
return False
def send_access_approved(email: str) -> bool:
def _next_step(verify_link: str | None, has_account: bool, hours: int, lang: str) -> str:
"""What this person actually has to do next, in the one paragraph that carries the whole flow.
Three states, because a mail that guesses wrong sends someone looking for a link that isn't
coming — or worse, tells them to sign in when their address is still unconfirmed and sign-in will
refuse them (E9.5 S4 named that state on screen; the mail must not contradict it).
The link branch is the point of the C option: in the common ordering — approve first, the person
signs up later, or signs up and waits — they would otherwise have to find and open TWO separate
messages. One mail, one click.
"""
if verify_link:
return mailtext.text(
"next_step.verify", lang, link=verify_link, expiry=mailtext.link_expiry(hours, lang)
)
if has_account:
return mailtext.text("next_step.sign_in", lang, app=settings.app_base)
return mailtext.text("next_step.register", lang, app=settings.app_base)
def _reply_contact(lang: str) -> tuple[str | None, str]:
"""The admin address to answer to, and the closing line that names it — empty when no admin
contact is configured, so the mail never promises a reply nobody will read."""
admin = _admin_contact()
body = (
"Hi,\n\n"
"Your request to use Siftlode has been approved — welcome aboard.\n\n"
"Open Siftlode and sign in, and your YouTube subscriptions feed will be ready:\n\n"
f"{settings.app_base}\n\n"
"If you weren't expecting this, you can ignore the message.\n\n"
"— Siftlode" + (f"\n\nQuestions? Just reply to this email ({admin})." if admin else "")
return admin, (mailtext.text("contact.reply", lang, admin=admin) if admin else "")
def send_access_approved(
email: str,
lang: str,
verify_link: str | None = None,
has_account: bool = False,
hours: int = 24,
) -> bool:
"""Answer someone who ASKED for access. Sent automatically on approval, because a person who
made a request is owed a reply — unlike the admin-initiated invitation below."""
admin, contact = _reply_contact(lang)
body = mailtext.text(
"access_approved.body",
lang,
next_step=_next_step(verify_link, has_account, hours, lang),
contact=contact,
)
return _send([email], "You're in — your Siftlode access is approved", body, reply_to=admin)
return _send([email], mailtext.text("access_approved.subject", lang), body, reply_to=admin)
def send_access_denied(email: str) -> bool:
admin = _admin_contact()
body = (
"Hi,\n\n"
"Thanks for your interest in Siftlode. After review, your access request wasn't approved "
"this time.\n\n"
"If you think this was a mistake, you're welcome to reply and let us know.\n\n"
"— Siftlode" + (f"\n\nQuestions? Just reply to this email ({admin})." if admin else "")
def send_access_invitation(
email: str,
lang: str,
verify_link: str | None = None,
has_account: bool = False,
hours: int = 24,
) -> bool:
"""Invite someone the admin let in WITHOUT them asking — sent only when the admin presses send.
Whitelisting an address is opening a door, not knocking on one: the operator has usually already
agreed it with the person, and an address can sit approved for weeks without anyone registering.
So adding to the whitelist stays silent and this is a separate, deliberate act. That is also why
the closing line offers no deadline — nudging is the one thing this mail must not do.
Its language is the one place the app has no signal to read: the admin typed this address in, so
nobody has ever told us how to address its owner. That is why the send button asks (E9.5 S7).
"""
admin, contact = _reply_contact(lang)
body = mailtext.text(
"access_invitation.body",
lang,
next_step=_next_step(verify_link, has_account, hours, lang),
contact=contact,
)
return _send([email], "About your Siftlode access request", body, reply_to=admin)
return _send([email], mailtext.text("access_invitation.subject", lang), body, reply_to=admin)
def send_role_changed(to: str, role: str, operator: str | None) -> bool:
line = (
"You've been granted admin access on Siftlode — you can now manage settings, the "
"scheduler and users."
if role == "admin"
else "Your admin access on Siftlode has been removed — you're now a regular user."
def send_access_denied(email: str, lang: str) -> bool:
admin, contact = _reply_contact(lang)
body = mailtext.text("access_denied.body", lang, contact=contact)
return _send([email], mailtext.text("access_denied.subject", lang), body, reply_to=admin)
def send_role_changed(to: str, lang: str, role: str, operator: str | None) -> bool:
line = mailtext.text(
"role_changed.granted" if role == "admin" else "role_changed.revoked", lang
)
contact = f"\n\nQuestions? Contact the operator at {operator}." if operator else ""
body = "Hi,\n\n" + line + contact + "\n\n— Siftlode"
return _send([to], "Your Siftlode role has changed", body, reply_to=operator)
contact = mailtext.text("contact.operator", lang, operator=operator) if operator else ""
body = mailtext.text("role_changed.body", lang, line=line, contact=contact)
return _send([to], mailtext.text("role_changed.subject", lang), body, reply_to=operator)
def send_account_unsuspended(to: str, operator: str | None) -> bool:
contact = f"\n\nQuestions? Contact the operator at {operator}." if operator else ""
body = (
"Hi,\n\n"
"Your Siftlode account has been reinstated — the suspension was lifted and you can sign "
"in again." + contact + "\n\n— Siftlode"
)
return _send([to], "Your Siftlode account has been reinstated", body, reply_to=operator)
def send_account_unsuspended(to: str, lang: str, operator: str | None) -> bool:
contact = mailtext.text("contact.operator", lang, operator=operator) if operator else ""
body = mailtext.text("account_unsuspended.body", lang, contact=contact)
return _send([to], mailtext.text("account_unsuspended.subject", lang), body, reply_to=operator)
def send_account_deleted(to: str, operator: str | None) -> bool:
def send_account_deleted(to: str, lang: str, operator: str | None) -> bool:
contact = mailtext.text("contact.deleted", lang, operator=operator) if operator else ""
body = mailtext.text("account_deleted.body", lang, contact=contact)
return _send([to], mailtext.text("account_deleted.subject", lang), body, reply_to=operator)
def send_account_suspended(to: str, lang: str, operator: str | None) -> bool:
contact = (
f"\n\nIf you didn't request this, contact the Siftlode operator at {operator}."
mailtext.text("contact.suspended", lang, operator=operator)
if operator
else ""
else mailtext.text("contact.suspended_unknown", lang)
)
body = (
"Hi,\n\n"
"Your Siftlode account and all associated data — subscriptions, tags, watch history, "
"playlists and settings — have been permanently deleted. Nothing is retained.\n\n"
"Thanks for having used Siftlode." + contact + "\n\n— Siftlode"
body = mailtext.text("account_suspended.body", lang, contact=contact)
return _send([to], mailtext.text("account_suspended.subject", lang), body, reply_to=operator)
def send_admin_new_request(admins: list[str], lang: str, requester: str) -> bool:
"""Tell the admins someone is waiting. One call per language group (see
`routes.auth.notify_admins_of_request`) — admins are people with their own UI language, and a
single blast to all of them would have to pick one and be wrong for the rest.
The link deep-links straight to the admin Access-requests page (the SPA reads ?admin=…). The
requester address is spelled out (mail clients auto-link both the URL and the address), and the
reply note is accurate: _send sets Reply-To to the requester, so a reply reaches THEM — not the
sending mailbox. (The old copy said "Settings → Account", which was the wrong menu.)
"""
body = mailtext.text(
"admin_new_request.body",
lang,
requester=requester,
link=f"{settings.app_base}/?admin=access-requests",
)
return _send([to], "Your Siftlode account has been deleted", body, reply_to=operator)
subject = mailtext.text("admin_new_request.subject", lang, requester=requester)
return _send(admins, subject, body, reply_to=requester)
def send_account_suspended(to: str, operator: str | None) -> bool:
contact = (
f"If you think this is a mistake or have questions, contact the Siftlode operator "
f"at {operator}.\n\n"
if operator
else "If you think this is a mistake, please contact the Siftlode operator.\n\n"
def send_verify_email(to: str, lang: str, link: str, hours: int = 24) -> bool:
# The body says what the link's rules are. The reset mail always has; this one left the reader to
# discover both by meeting a dead link, which is precisely the silence this epic removes.
body = mailtext.text(
"verify_email.body", lang, link=link, expiry=mailtext.link_expiry(hours, lang)
)
body = (
"Hi,\n\n"
"A sign-in to your Siftlode account was just attempted, but the account is currently "
"suspended, so access was denied.\n\n" + contact + "— Siftlode"
)
return _send([to], "Your Siftlode account is suspended", body, reply_to=operator)
return _send([to], mailtext.text("verify_email.subject", lang), body)
def send_admin_new_request(admins: list[str], requester: str) -> bool:
# The link deep-links straight to the admin Access-requests page (the SPA reads ?admin=…). The
# requester address is spelled out (mail clients auto-link both the URL and the address), and the
# reply note is accurate: _send sets Reply-To to the requester, so a reply reaches THEM — not the
# sending mailbox. (The old copy said "Settings → Account", which was the wrong menu.)
approve_url = f"{settings.app_base}/?admin=access-requests"
body = (
f"{requester} just requested access to Siftlode.\n\n"
f"Approve or deny it here:\n{approve_url}\n"
f"(or in Siftlode: open Users → Access requests)\n\n"
f"Requester's email: {requester}\n"
f"Replying to this message goes straight to the requester (it's the Reply-To address).\n"
)
return _send(admins, f"Siftlode access request from {requester}", body, reply_to=requester)
def send_password_reset(to: str, lang: str, link: str) -> bool:
body = mailtext.text("password_reset.body", lang, link=link)
return _send([to], mailtext.text("password_reset.subject", lang), body)
def send_verify_email(to: str, link: str) -> bool:
body = (
"Hi,\n\n"
"Confirm your email to finish creating your Siftlode account:\n\n"
f"{link}\n\n"
"If you didn't sign up, you can ignore this message.\n\n"
"— Siftlode"
)
return _send([to], "Confirm your Siftlode email", body)
def send_password_reset(to: str, link: str) -> bool:
body = (
"Hi,\n\n"
"Use this link to set a new Siftlode password (it expires in an hour):\n\n"
f"{link}\n\n"
"If you didn't request this, you can ignore this message — your password is unchanged.\n\n"
"— Siftlode"
)
return _send([to], "Reset your Siftlode password", body)
def send_test(to: str) -> bool:
body = (
"This is a test email from Siftlode.\n\n"
"If you're reading this, your SMTP settings work.\n\n"
"— Siftlode"
)
return _send([to], "Siftlode SMTP test", body)
def send_test(to: str, lang: str) -> bool:
return _send([to], mailtext.text("test.subject", lang), mailtext.text("test.body", lang))
+373
View File
@@ -0,0 +1,373 @@
"""Bilingual text for every outbound mail, and the one resolver that decides which language a
recipient gets (E9.5 S7).
Why a catalog instead of literals in `email.py`: HU/EN parity is a house rule for every user-facing
string, and a mail is the one user-facing surface the reader CANNOT re-render by switching the UI
language — it arrives once, in whatever language we picked, and stays that way. `test_mail_language.py`
enforces that every key carries both languages *and* the same `{placeholders}`, so a half-translated
mail fails the gate instead of reaching a person.
The language is resolved by the REQUEST handler and handed to `send_*` as a plain code. It is never
resolved inside the mail: every send runs as a `BackgroundTasks` job, after the request's session is
gone — the same reason `_welcome_mail_parts` computes its link in the handler and not in the task.
"""
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models import Invite, User
from app.utils import canonical_email
SUPPORTED: tuple[str, ...] = ("en", "hu")
DEFAULT = "en"
def normalize(lang: str | None) -> str:
"""Coerce anything to a language we can actually render. Storage is not a guarantee: a
`preferences.language` written by an older build (or a locale we no longer ship) must degrade to
English rather than raise inside a background task nobody is watching."""
return lang if lang in SUPPORTED else DEFAULT
def text(key: str, lang: str | None, **values: object) -> str:
"""Render one catalog entry. Unknown languages fall back to English (see `normalize`); an
unknown KEY raises, because that is a programming error the tests must surface, not something to
paper over with an empty line in someone's inbox."""
return MAIL_TEXT[key][normalize(lang)].format(**values)
def link_expiry(hours: int, lang: str) -> str:
"""How long a link lasts, in a unit a reader converts on sight. A whole number of days is
said in days — the approval mail's token lives seven of them, and "168 hours" is not an answer
to "how long have I got". A single day stays "24 hours", which is how everyone writes it."""
if hours >= 48 and hours % 24 == 0:
return text("duration.days", lang, n=hours // 24)
return text("duration.hours", lang, n=hours)
def lang_for_user(user: User | None) -> str:
"""The language a signed-up recipient gets: the one they picked in the app."""
prefs = (user.preferences or {}) if user is not None else {}
return normalize(prefs.get("language"))
def lang_for_email(db: Session, email: str) -> str:
"""The language for an address that may not have an account yet: their own account preference,
else the language they filled our access-request form in, else English.
Matched CANONICALLY like every other identity lookup in this epic, and it walks past rows that
carry no usable code instead of stopping at the first one — prod really does hold two accounts
for a single mailbox, and only one of them may have ever picked a language.
"""
canon = canonical_email(email)
for prefs in (
db.execute(select(User.preferences).where(User.email_canonical == canon).order_by(User.id))
.scalars()
.all()
):
code = (prefs or {}).get("language")
if code in SUPPORTED:
return code
for code in (
db.execute(select(Invite.lang).where(Invite.email_canonical == canon).order_by(Invite.id))
.scalars()
.all()
):
if code in SUPPORTED:
return code
return DEFAULT
# The brand line ("— Siftlode") and the product name are deliberately NOT catalog entries: they are
# a name, not a translated string, and giving them keys would invite someone to "translate" them.
MAIL_TEXT: dict[str, dict[str, str]] = {
# ── How long a link lasts ───────────────────────────────────────────────────────────────
# Rendered, not interpolated as a raw hour count: the approval mail's token lives seven days
# (E9.5 S6), and "expires in 168 hours" is a unit no reader converts.
"duration.hours": {"en": "{n} hours", "hu": "{n} óra"},
"duration.days": {"en": "{n} days", "hu": "{n} nap"},
# ── Shared closing fragments ────────────────────────────────────────────────────────────────
"contact.reply": {
"en": "\n\nQuestions? Just reply to this email ({admin}).",
"hu": "\n\nKérdésed van? Csak válaszolj erre a levélre ({admin}).",
},
"contact.operator": {
"en": "\n\nQuestions? Contact the operator at {operator}.",
"hu": "\n\nKérdésed van? Írj az üzemeltetőnek: {operator}.",
},
"contact.deleted": {
"en": "\n\nIf you didn't request this, contact the Siftlode operator at {operator}.",
"hu": "\n\nHa nem te kérted, írj a Siftlode üzemeltetőjének: {operator}.",
},
"contact.suspended": {
"en": (
"If you think this is a mistake or have questions, contact the Siftlode operator "
"at {operator}.\n\n"
),
"hu": (
"Ha szerinted ez tévedés, vagy kérdésed van, írj a Siftlode üzemeltetőjének: "
"{operator}.\n\n"
),
},
"contact.suspended_unknown": {
"en": "If you think this is a mistake, please contact the Siftlode operator.\n\n",
"hu": "Ha szerinted ez tévedés, keresd a Siftlode üzemeltetőjét.\n\n",
},
# ── The one paragraph that carries the whole onboarding flow (see email._next_step) ──────────
"next_step.verify": {
"en": (
"You already started an account, so there is just one step left. Confirm your email "
"address and you're in:\n\n"
"{link}\n\n"
"The link works once and expires in {expiry}. If it does, you can ask for a fresh "
"one from the sign-in page."
),
"hu": (
"A fiókodat már elkezdted, úgyhogy egyetlen lépés maradt. Erősítsd meg az "
"e-mail-címed, és bent is vagy:\n\n"
"{link}\n\n"
"A link egyszer használható, és {expiry} múlva lejár. Ha lejárt, a belépési oldalon "
"kérhetsz újat."
),
},
"next_step.sign_in": {
"en": "Your account is ready — just sign in:\n\n{app}",
"hu": "A fiókod készen áll — csak lépj be:\n\n{app}",
},
"next_step.register": {
"en": (
"Create your account whenever you feel like it:\n\n"
"{app}\n\n"
"We'll send you a confirmation link to finish, and then you're in."
),
"hu": (
"Hozd létre a fiókod, amikor kedved tartja:\n\n"
"{app}\n\n"
"Küldünk egy megerősítő linket a befejezéshez, és már bent is vagy."
),
},
# ── Access decision ─────────────────────────────────────────────────────────────────────────
"access_approved.subject": {
"en": "You're in — your Siftlode access is approved",
"hu": "Bent vagy — jóváhagytuk a Siftlode-hozzáférésed",
},
"access_approved.body": {
"en": (
"Hi,\n\n"
"Your request to use Siftlode has been approved — welcome aboard.\n\n"
"{next_step}\n\n"
"If you weren't expecting this, you can ignore the message.\n\n"
"— Siftlode{contact}"
),
"hu": (
"Szia,\n\n"
"Jóváhagytuk a Siftlode-hozzáférési kérelmed — üdv a fedélzeten.\n\n"
"{next_step}\n\n"
"Ha nem számítottál erre, nyugodtan hagyd figyelmen kívül ezt a levelet.\n\n"
"— Siftlode{contact}"
),
},
"access_invitation.subject": {
"en": "You're invited to Siftlode",
"hu": "Meghívtak a Siftlode-ra",
},
"access_invitation.body": {
"en": (
"Hi,\n\n"
"Someone has opened up Siftlode for you — a quiet, self-hosted reader for your YouTube "
"subscriptions. Every upload from the channels you follow lands in one feed you can "
"actually filter and sort, with no algorithm deciding what you see, and no Shorts or "
"livestreams unless you want them.\n\n"
"{next_step}\n\n"
"No rush — the door stays open.\n\n"
"— Siftlode{contact}"
),
"hu": (
"Szia,\n\n"
"Valaki megnyitotta előtted a Siftlode-ot — ez egy csendes, saját üzemeltetésű olvasó "
"a YouTube-feliratkozásaidhoz. A követett csatornák minden feltöltése egyetlen "
"hírfolyamba érkezik, amit tényleg tudsz szűrni és rendezni; nincs algoritmus, ami "
"eldönti, mit látsz, és nincs Shorts vagy élő adás, hacsak nem kéred.\n\n"
"{next_step}\n\n"
"Nem sürget semmi — az ajtó nyitva marad.\n\n"
"— Siftlode{contact}"
),
},
"access_denied.subject": {
"en": "About your Siftlode access request",
"hu": "A Siftlode-hozzáférési kérelmedről",
},
"access_denied.body": {
"en": (
"Hi,\n\n"
"Thanks for your interest in Siftlode. After review, your access request wasn't "
"approved this time.\n\n"
"If you think this was a mistake, you're welcome to reply and let us know.\n\n"
"— Siftlode{contact}"
),
"hu": (
"Szia,\n\n"
"Köszönjük az érdeklődésed a Siftlode iránt. Átnéztük a kérelmed, de most nem hagytuk "
"jóvá.\n\n"
"Ha szerinted ez tévedés, válaszolj nyugodtan erre a levélre.\n\n"
"— Siftlode{contact}"
),
},
# ── Account lifecycle ───────────────────────────────────────────────────────────────────────
"role_changed.subject": {
"en": "Your Siftlode role has changed",
"hu": "Megváltozott a szereped a Siftlode-on",
},
"role_changed.granted": {
"en": (
"You've been granted admin access on Siftlode — you can now manage settings, the "
"scheduler and users."
),
"hu": (
"Admin jogot kaptál a Siftlode-on — mostantól kezelheted a beállításokat, az ütemezőt "
"és a felhasználókat."
),
},
"role_changed.revoked": {
"en": "Your admin access on Siftlode has been removed — you're now a regular user.",
"hu": "Elvettük az admin jogod a Siftlode-on — mostantól sima felhasználó vagy.",
},
"role_changed.body": {
"en": "Hi,\n\n{line}{contact}\n\n— Siftlode",
"hu": "Szia,\n\n{line}{contact}\n\n— Siftlode",
},
"account_unsuspended.subject": {
"en": "Your Siftlode account has been reinstated",
"hu": "Visszaállítottuk a Siftlode-fiókod",
},
"account_unsuspended.body": {
"en": (
"Hi,\n\n"
"Your Siftlode account has been reinstated — the suspension was lifted and you can "
"sign in again.{contact}\n\n— Siftlode"
),
"hu": (
"Szia,\n\n"
"Visszaállítottuk a Siftlode-fiókod — a felfüggesztést feloldottuk, újra be tudsz "
"lépni.{contact}\n\n— Siftlode"
),
},
"account_deleted.subject": {
"en": "Your Siftlode account has been deleted",
"hu": "Töröltük a Siftlode-fiókod",
},
"account_deleted.body": {
"en": (
"Hi,\n\n"
"Your Siftlode account and all associated data — subscriptions, tags, watch history, "
"playlists and settings — have been permanently deleted. Nothing is retained.\n\n"
"Thanks for having used Siftlode.{contact}\n\n— Siftlode"
),
"hu": (
"Szia,\n\n"
"A Siftlode-fiókod és minden hozzá tartozó adat — feliratkozások, címkék, megtekintési "
"előzmények, lejátszási listák és beállítások — véglegesen törölve. Semmit nem őrzünk "
"meg.\n\n"
"Köszönjük, hogy használtad a Siftlode-ot.{contact}\n\n— Siftlode"
),
},
"account_suspended.subject": {
"en": "Your Siftlode account is suspended",
"hu": "A Siftlode-fiókod fel van függesztve",
},
"account_suspended.body": {
"en": (
"Hi,\n\n"
"A sign-in to your Siftlode account was just attempted, but the account is currently "
"suspended, so access was denied.\n\n{contact}— Siftlode"
),
"hu": (
"Szia,\n\n"
"Valaki most próbált belépni a Siftlode-fiókodba, de a fiók jelenleg fel van "
"függesztve, ezért a belépést elutasítottuk.\n\n{contact}— Siftlode"
),
},
# ── To the admins ───────────────────────────────────────────────────────────────────────────
"admin_new_request.subject": {
"en": "Siftlode access request from {requester}",
"hu": "Siftlode hozzáférési kérelem: {requester}",
},
"admin_new_request.body": {
"en": (
"{requester} just requested access to Siftlode.\n\n"
"Approve or deny it here:\n{link}\n"
"(or in Siftlode: open Users → Access requests)\n\n"
"Requester's email: {requester}\n"
"Replying to this message goes straight to the requester (it's the Reply-To address).\n"
),
"hu": (
"{requester} most kért hozzáférést a Siftlode-hoz.\n\n"
"Itt hagyhatod jóvá vagy utasíthatod el:\n{link}\n"
"(vagy a Siftlode-ban: Felhasználók → Hozzáférési kérések)\n\n"
"A kérelmező e-mail-címe: {requester}\n"
"Ha erre a levélre válaszolsz, az egyenesen a kérelmezőhöz megy (ő a Reply-To cím).\n"
),
},
# ── Link mails ──────────────────────────────────────────────────────────────────────────────
"verify_email.subject": {
"en": "Confirm your Siftlode email",
"hu": "Erősítsd meg a Siftlode e-mail-címed",
},
"verify_email.body": {
"en": (
"Hi,\n\n"
"Confirm your email to finish creating your Siftlode account:\n\n"
"{link}\n\n"
"The link works once and expires in {expiry}. If it does, you can ask for a fresh "
"one from the sign-in page.\n\n"
"If you didn't sign up, you can ignore this message.\n\n"
"— Siftlode"
),
"hu": (
"Szia,\n\n"
"Erősítsd meg az e-mail-címed, hogy elkészüljön a Siftlode-fiókod:\n\n"
"{link}\n\n"
"A link egyszer használható, és {expiry} múlva lejár. Ha lejárt, a belépési oldalon "
"kérhetsz újat.\n\n"
"Ha nem te regisztráltál, nyugodtan hagyd figyelmen kívül ezt a levelet.\n\n"
"— Siftlode"
),
},
"password_reset.subject": {
"en": "Reset your Siftlode password",
"hu": "Állíts be új Siftlode-jelszót",
},
"password_reset.body": {
"en": (
"Hi,\n\n"
"Use this link to set a new Siftlode password (it expires in an hour):\n\n"
"{link}\n\n"
"If you didn't request this, you can ignore this message — your password is "
"unchanged.\n\n"
"— Siftlode"
),
"hu": (
"Szia,\n\n"
"Ezzel a linkkel állíthatsz be új Siftlode-jelszót (egy óra múlva lejár):\n\n"
"{link}\n\n"
"Ha nem te kérted, hagyd figyelmen kívül ezt a levelet — a jelszavad változatlan.\n\n"
"— Siftlode"
),
},
"test.subject": {
"en": "Siftlode SMTP test",
"hu": "Siftlode SMTP-teszt",
},
"test.body": {
"en": (
"This is a test email from Siftlode.\n\n"
"If you're reading this, your SMTP settings work.\n\n"
"— Siftlode"
),
"hu": (
"Ez egy teszt e-mail a Siftlode-tól.\n\n"
"Ha ezt olvasod, az SMTP-beállításaid működnek.\n\n"
"— Siftlode"
),
},
}
+73 -3
View File
@@ -17,9 +17,10 @@ from sqlalchemy import (
func,
)
from sqlalchemy.dialects.postgresql import JSONB, TSVECTOR
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.orm import Mapped, mapped_column, relationship, validates
from app.db import Base
from app.utils import canonical_email
class TimestampMixin:
@@ -29,6 +30,21 @@ class TimestampMixin:
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
class CanonicalEmailMixin:
"""Keeps `email_canonical` derived from `email` on every write.
The invariant — "identity is matched on the canonical form, mail is sent to the typed one" —
lives HERE, in code, instead of in a comment every write site has to remember. That distinction
is what the 2026-09-03 lockout was made of: one gate lowercased, nothing else did, and an
approved user's Google sign-in silently became a second access request.
"""
@validates("email")
def _derive_email_canonical(self, _key: str, value: str | None) -> str | None:
self.email_canonical = canonical_email(value)
return value
class UpdatedAtMixin:
"""Adds an `updated_at` that server-defaults on insert and bumps on every UPDATE."""
@@ -37,7 +53,7 @@ class UpdatedAtMixin:
)
class User(Base, TimestampMixin):
class User(Base, TimestampMixin, CanonicalEmailMixin):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
@@ -45,8 +61,26 @@ class User(Base, TimestampMixin):
# multiple NULLs under a unique index). Set on Google sign-in / in-app linking.
google_sub: Mapped[str | None] = mapped_column(String(64), unique=True, index=True)
email: Mapped[str] = mapped_column(String(320), unique=True, index=True)
# The same mailbox, in the form used to MATCH identities (utils.canonical_email) — Gmail dots and
# `+tags` folded away. Kept by the validator below so no write site can forget it. Deliberately
# NOT unique: prod already carries two rows for one mailbox (a password registration and the
# Google sign-in that failed to find it), and a migration must not merge accounts on its own.
email_canonical: Mapped[str] = mapped_column(String(320), index=True, server_default="")
# The address Google asserted for this account, recorded at sign-in / link time. NOT the same
# thing as `email`: since E9.5 S3 a Google sign-in no longer overwrites the address the user
# registered with, so a deliberate `me+siftlode@gmail.com` survives — and this is where the other
# half of the truth lives, for the UI to show when the two differ. NULL until the next Google
# sign-in for accounts linked before this column existed.
google_email: Mapped[str | None] = mapped_column(String(320))
# argon2id hash for email+password login; NULL for Google-only / demo accounts.
password_hash: Mapped[str | None] = mapped_column(String(255))
# When SEC-10 dropped a preset password on Google adoption (see the single caller of
# auth.revoke_preset_password_if_unverified; admin approval no longer revokes — E9.5 S1). Without
# this trace a revoked account was indistinguishable from a Google-only one, so it failed the
# password-reset eligibility check and could never be recovered — it could neither sign in nor
# reset. Set = "had a password, lost it to the guard" -> reset-eligible. Cleared whenever a
# password is set again (reset-confirm / set-password).
password_revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
# Email ownership proven (Google sign-in is implicitly verified; password registration
# verifies via a link). Login requires this AND is_active.
email_verified: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
@@ -114,7 +148,7 @@ class AuthToken(Base, TimestampMixin):
used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
class Invite(Base):
class Invite(Base, CanonicalEmailMixin):
"""Access-request / whitelist row. Source of truth for who may sign in; the env
ALLOWED_EMAILS/ADMIN_EMAILS act only as a bootstrap fallback (see auth.is_allowed)."""
@@ -122,6 +156,9 @@ class Invite(Base):
id: Mapped[int] = mapped_column(primary_key=True)
email: Mapped[str] = mapped_column(String(320), unique=True, index=True)
# See User.email_canonical. Not unique here either: prod has approved invites for both spellings
# of the same mailbox, dating back to 2026-06-16.
email_canonical: Mapped[str] = mapped_column(String(320), index=True, server_default="")
# pending | approved | denied
status: Mapped[str] = mapped_column(String(16), default="pending", server_default="pending")
note: Mapped[str | None] = mapped_column(Text)
@@ -130,6 +167,17 @@ class Invite(Base):
)
decided_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
decided_by: Mapped[str | None] = mapped_column(String(320)) # admin email
# When the admin last SENT this person an invitation from the whitelist row. Adding an address
# stays silent — it opens a door, it does not knock — so telling them is a separate, deliberate
# act, and this is what stops it happening twice to a real person.
invited_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
# The language this person asked us in ("en"/"hu"), captured when the request was made: the page
# their form was on, or the locale Google reported for a refused sign-in. NULL for a row the
# admin whitelisted by hand — nobody has told us anything about that mailbox, which is exactly
# why the invitation send asks (E9.5 S7). Captured rather than derived for the same reason
# `preferences.language` and `google_email` are: the signal exists only in that one moment and
# cannot be backfilled afterwards.
lang: Mapped[str | None] = mapped_column(String(8))
class DemoWhitelist(Base, TimestampMixin):
@@ -708,6 +756,12 @@ class MediaAsset(Base, TimestampMixin):
container: Mapped[str | None] = mapped_column(String(16))
vcodec: Mapped[str | None] = mapped_column(String(32))
acodec: Mapped[str | None] = mapped_column(String(32))
# "video" | "audio" | "file" — the media-kind discriminator that drives which library actions
# apply (play/edit for media; download-only for a generic uploaded file). Null while an asset is
# still pending (kind is known once the file is probed at finalize). It is the single stored home
# of the invariant `downloads.mediakind.derive` used to compute — never re-derived in prose (an
# earlier ad-hoc `vcodec is None and acodec` check lived only in links.py; consolidated here).
media_kind: Mapped[str | None] = mapped_column(String(16))
nfo_written: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
# Set once the GC has warned holders of the upcoming expiry, so it doesn't warn every run.
gc_notified: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
@@ -1121,6 +1175,22 @@ class PlexItem(Base, TimestampMixin, UpdatedAtMixin):
# Browser-playability class from ffprobe: "direct" | "remux" | "transcode" (None = unprobed).
playable: Mapped[str | None] = mapped_column(String(12), index=True)
probed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
# Transcode HW-capability facet (E7 playback-class filter). NULL for non-transcode items; for a
# `playable="transcode"` item: True = the prod iGPU can hardware-transcode it (codec is HW-decodable
# AND the source is 8-bit SDR), False = software libx264 only (AV1/XviD, or a 10-bit/HDR source that
# breaks the VAAPI nv12 pipeline — see plex/stream.py). Derived by `plex.sync.transcode_hw_capable`:
# set codec-only at sync (bit depth unknown from the cheap listing), then refined by the bit-depth
# backfill (`_probe_media_batch`) which downgrades a 10-bit/HDR HEVC to False. The one filterable
# home of the invariant `_want_hw`'s source gate computes at play time — kept in sync via the shared
# classifier so the catalog facet and the runtime encoder choice never disagree by codec.
transcode_hw: Mapped[bool | None] = mapped_column(Boolean, index=True)
# Video bit depth (8/10/12…) from the Plex media Stream, filled by the bit-depth backfill. NULL =
# not yet probed. Surfaced on the info page (e.g. "HEVC · 10-bit") and feeds `transcode_hw`.
video_bit_depth: Mapped[int | None] = mapped_column(Integer)
# Watermark for the bit-depth backfill: when this item's media Stream was last probed for
# bit-depth/HDR. NULL = never (the backfill's work-list). Only HW-decodable transcode items are
# probed (the rest are SW-only by codec and need no bit-depth); see `_probe_media_batch`.
media_probed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
# Intro/credit markers: [{"type": "intro"|"credits", "start_s": int, "end_s": int}, ...]
markers: Mapped[list | None] = mapped_column(JSON)
added_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True)
+2
View File
@@ -33,6 +33,7 @@ def movie_card(it: PlexItem, st: PlexState | None) -> dict:
"thumb": f"/api/plex/image/{it.rating_key}",
"art": _art_url(it.rating_key, it.art_key),
"playable": it.playable,
"transcode_hw": it.transcode_hw, # HW/SW transcode split → the 4-way corner-dot badge
"status": st.status if st else "new",
"position_seconds": st.position_seconds if st else 0,
}
@@ -65,6 +66,7 @@ def episode_card(e: PlexItem, st: PlexState | None) -> dict:
"duration_seconds": e.duration_s,
"thumb": f"/api/plex/image/{e.rating_key}",
"playable": e.playable,
"transcode_hw": e.transcode_hw, # HW/SW transcode split → the 4-way corner-dot badge
"status": st.status if st else "new",
"position_seconds": st.position_seconds if st else 0,
}
+31
View File
@@ -9,6 +9,8 @@ from datetime import datetime, timedelta, timezone
from sqlalchemy import and_, case, func, or_, select, type_coerce
from sqlalchemy.dialects.postgresql import JSONB
from app.models import PlexItem
TS_CONFIG = "public.unaccent_simple"
# --- Language-aware column pickers (E5 S6) ----------------------------------
@@ -206,6 +208,33 @@ def apply_meta_filters(q, model, p: dict, lang: str = "en"):
return q.where(*conds) if conds else q
# --- Playback-class filter (E7) — MOVIE-ONLY (a show is an aggregate with no single class) ---------
# The 4 classes the feed filter + facet expose, derived from the stored `playable` + `transcode_hw`.
# ONE definition (this SQL CASE) so the filter WHERE and the facet counts can't drift: `playable_conds`
# filters on it, `facets` groups by it. transcode_hw IS NOT True groups NULL (unprobed) with software.
# Public — it is the shared source of truth imported by routes.plex.browse (facet order + zero-fill).
PLAYBACK_CLASSES = ("direct", "remux", "transcode_hw", "transcode_sw")
def playback_class_expr():
"""SQL CASE mapping a PlexItem (movie) to its playback-class string, or NULL. The HW branch must
precede the generic transcode branch (ordered evaluation)."""
return case(
(PlexItem.playable == "direct", "direct"),
(PlexItem.playable == "remux", "remux"),
(and_(PlexItem.playable == "transcode", PlexItem.transcode_hw.is_(True)), "transcode_hw"),
(PlexItem.playable == "transcode", "transcode_sw"),
else_=None,
)
def playable_conds(sel: list[str]) -> list:
"""A one-element condition list (or empty) selecting the given playback classes — an OR via
`class IN (...)` over the shared CASE. Unknown keys are dropped."""
keep = [c for c in sel if c in PLAYBACK_CLASSES]
return [playback_class_expr().in_(keep)] if keep else []
@dataclass
class LibraryFilters:
"""The shared sidebar filter query-params for /library + /facets, declared once and injected via
@@ -228,6 +257,7 @@ class LibraryFilters:
keywords: str | None = None # TMDB keyword chips (E5 S5), CSV; language-neutral
collections: str | None = None # CSV of collection rating_keys
collection_mode: str = "any" # how multiple collections combine: any (OR, default) | all (AND)
playable: str | None = None # CSV of playback classes: direct|remux|transcode_hw|transcode_sw
def as_dict(self) -> dict:
return {
@@ -247,6 +277,7 @@ class LibraryFilters:
"keywords": self.keywords,
"collections": self.collections,
"collection_mode": self.collection_mode,
"playable": self.playable,
}
+418 -41
View File
@@ -3,7 +3,10 @@
Playback is from the LOCAL physical file. Browser-compatible files (playable="direct") are served
raw with HTTP range requests. Everything else that is h264 video (playable="remux") is remuxed on
the fly to HLS with **video stream-copy** (cheap, I/O-bound — no video re-encode) and audio to AAC
when needed. HEVC/VP9 (playable="transcode") needs a full re-encode and is deferred to P3.
when needed. Non-h264 video (playable="transcode": HEVC/VP9/AV1/MPEG-2/XviD…) needs a full re-encode
to H.264: on the prod host that runs on the Intel iGPU via VAAPI (`h264_vaapi`, near-zero CPU), with
software libx264 as the automatic fallback where the render node is absent or the source codec isn't
hardware-decodable. Same HLS session/seek machinery either way — only the video encoder differs.
Seek model (Jellyfin-style): one ffmpeg session per item, started at a given offset via `-ss`
(fast keyframe seek). A seek beyond the generated region restarts the session at the new offset,
@@ -16,6 +19,7 @@ CPU-only prod host; only P3's full transcode is CPU-heavy.
"""
import logging
import os
import re
import shutil
import subprocess
@@ -27,8 +31,10 @@ from sqlalchemy.orm import Session as DbSession
from app import sysconfig
from app.config import settings
from app.downloads.edit import encode_thread_cap
from app.models import PlexItem
from app.plex import paths
from app.plex.sync import HW_DECODABLE_CODECS
log = logging.getLogger("siftlode.plex")
@@ -83,11 +89,22 @@ _sessions: dict[str, "HlsSession"] = {}
class HlsSession:
def __init__(
self, key: str, directory: Path, proc: subprocess.Popen, start_s: float, entry: str
self,
key: str,
directory: Path,
proc: subprocess.Popen,
start_s: float,
entry: str,
transcode: bool = False,
hw: bool = False,
tonemap: bool = False,
):
self.key = key
self.dir = directory
self.proc = proc
self.transcode = transcode # video re-encoded (non-h264 source), not stream-copied
self.hw = hw # transcode ran on the VAAPI hardware encoder (else libx264 software)
self.tonemap = tonemap # HW HDR/10-bit path: OpenCL tonemap between VAAPI decode and encode
self.start_s = start_s # the REQUESTED seek offset
# The REAL media start: with `-ss X -c:v copy` ffmpeg can only cut at the nearest earlier
# keyframe K<=X, and hls.js zero-bases the timeline to K (so video.currentTime=0 is K, not X).
@@ -127,6 +144,269 @@ _HLS_TAIL = [
]
# --- Transcode (playable="transcode") video re-encode -----------------------------------------
# The remux path stream-copies the video (`-c:v copy`); a non-h264 source can't be copied and must be
# re-encoded to H.264. The encoder + tonemap are chosen per-session by `_select_encode`:
# * VAAPI hardware (`h264_vaapi`) — the prod default once the iGPU is passed in. A full GPU pipeline
# (decode + scale + encode all on-device) offloads the CPU almost entirely, so several concurrent
# 1080p transcodes are fine and the API stays responsive.
# * libx264 software — the automatic fallback where `/dev/dri` is absent (dev box, or prod before the
# iGPU enablement) or the source codec isn't hardware-decodable (AV1/XviD on Gen 9.5). CPU-bound, so
# it reserves a core via `-threads` (see encode_thread_cap).
# Everything else — the `-copyts`/`_HLS_TAIL`/segment/seek-restart model and the AAC audio path — is
# unchanged, so hls.js, subtitles, multi-audio and seeking keep working exactly as for remux.
_VAAPI_DEVICE = "/dev/dri/renderD128"
# Where an OpenCL ICD registers its driver. The Intel NEO runtime (intel-opencl-icd) drops an .icd file
# here; its presence is the precondition for the HW HDR→SDR tonemap graph (see _opencl_present).
_OPENCL_ICD_DIR = Path("/etc/OpenCL/vendors")
# Codecs the prod iGPU (UHD 630 / Quick Sync Gen 9.5) can hardware-DECODE — the precondition for the
# full-GPU VAAPI pipeline. A source outside it (AV1, MPEG-4 ASP/XviD…) falls back to software even with
# the render node present, because the GPU can't decode it to feed its own encoder. THE single source is
# plex.sync, so the runtime encoder choice here and the catalog `transcode_hw` facet can't disagree by
# codec; aliased to the old name to keep the call sites below unchanged.
_HW_DECODABLE = HW_DECODABLE_CODECS
# Color-transfer values that mark a true-HDR source (PQ / HLG). A HW-decodable HDR source can't take the
# plain scale_vaapi=format=nv12 path (Gen 9.5 VPP refuses it) — it needs the OpenCL tonemap graph
# (_venc_opts tonemap branch) when the OpenCL runtime is present, else software libx264. A 10-bit source
# WITHOUT one of these transfers is SDR, not HDR, and must not be tone-mapped. See _probe_video_kind.
_HDR_TRANSFERS = {"smpte2084", "arib-std-b67"}
# The three source classes that drive the transcode-pipeline choice (see _probe_video_kind).
_VK_HWSAFE = "hwsafe" # 8-bit SDR — the plain scale_vaapi=format=nv12 GPU path
_VK_HDR = "hdr" # PQ/HLG HDR — needs the OpenCL tonemap graph (or software if no runtime)
_VK_SDR10 = "sdr10" # 10-bit *SDR* — a plain 10→8-bit downconvert, NEVER the HDR tone curve
def _probe_video_kind(src: Path) -> str:
"""Classify the source for the transcode pipeline — one of `_VK_HWSAFE` / `_VK_HDR` / `_VK_SDR10`:
* `_VK_HWSAFE` — 8-bit SDR: safe for the plain VAAPI path (`scale_vaapi=format=nv12` + h264_vaapi).
* `_VK_HDR` — PQ/HLG HDR (any depth): the plain scale path breaks on Gen 9.5 VPP, so it needs the
OpenCL tonemap graph (`tonemap_opencl=hable`) when the runtime is present, else software libx264.
* `_VK_SDR10` — 10-bit but *SDR* (bt709 etc.): NOT HDR. Applying the HDR hable tone curve here would
visibly alter an SDR picture (crushed highlights/midtones) where only a 10→8-bit downconvert was
needed, so it must NOT take the tonemap graph — it goes to software libx264, which CPU-decodes and
downconverts to 8-bit yuv420p exactly. (Kept off the plain scale_vaapi 10-bit path too: that path
is unverified on Gen 9.5 VPP, and software is the known-correct downconvert.)
Ground truth from ffprobe (bit depth via pix_fmt / bits_per_raw_sample, HDR via color_transfer); the
Plex mirror doesn't store these. Returns `_VK_HWSAFE` on any probe failure: the runtime HW→SW fallback
in start_session backstops a HW session that still produces nothing, so a probe miss only costs the
rare wasted first attempt, never a stranded item."""
try:
out = subprocess.run(
[
"ffprobe",
"-v",
"error",
"-select_streams",
"v:0",
"-show_entries",
"stream=pix_fmt,bits_per_raw_sample,color_transfer",
"-of",
"default=noprint_wrappers=1",
str(src),
],
capture_output=True,
text=True,
timeout=10,
)
except (subprocess.SubprocessError, OSError):
return _VK_HWSAFE
pix_fmt = bits = transfer = ""
for line in out.stdout.splitlines():
k, _, v = line.strip().partition("=")
v = v.strip().lower()
if k == "pix_fmt":
pix_fmt = v
elif k == "bits_per_raw_sample":
bits = v
elif k == "color_transfer":
transfer = v
if transfer in _HDR_TRANSFERS:
return _VK_HDR # PQ/HLG — needs a real HDR→SDR tonemap, not a plain downconvert
# >8-bit but not HDR = 10-bit SDR. bits_per_raw_sample OR the pix_fmt bit-depth suffix
# (yuv420p10le, p010le, yuv444p12le…) signals >8-bit even when the other is absent.
if (bits.isdigit() and int(bits) > 8) or re.search(r"(?:10|12|16)(?:le|be)$", pix_fmt):
return _VK_SDR10
return _VK_HWSAFE
def _opencl_present() -> bool:
"""Whether an OpenCL runtime (Intel NEO / intel-opencl-icd) is installed — the precondition for the
HW HDR→SDR tonemap graph. Gen 9.5 VPP can't tonemap natively (tonemap_vaapi fails), so the working
HW path for a 10-bit/HDR source is VAAPI-decode → OpenCL tonemap → VAAPI-encode, which needs an ICD
registered here. Absent on the dev box and until the image bakes NEO in → those sources take
software libx264. Its own function so it's monkeypatchable in tests, mirroring _render_node_present."""
try:
return any(_OPENCL_ICD_DIR.glob("*.icd"))
except OSError:
return False
def _hwaccel_in_opts(hw: bool, tonemap: bool = False) -> list[str]:
"""VAAPI decode options, placed before `-i` so the source is decoded (and kept) on the GPU.
`-hwaccel_output_format vaapi` keeps frames as GPU surfaces all the way to `h264_vaapi` — no
round-trip to system memory. Empty for the software path.
The tonemap variant additionally derives an OpenCL device FROM the VAAPI one (`opencl=ocl@va`) so
tonemap_opencl can map the decoded surfaces zero-copy (see _venc_opts); it needs named devices, so it
uses `-init_hw_device` + `-hwaccel_device` rather than the plain path's implicit `-vaapi_device`."""
if not hw:
return []
if tonemap:
return [
"-init_hw_device",
f"vaapi=va:{_VAAPI_DEVICE}",
"-init_hw_device",
"opencl=ocl@va",
"-hwaccel",
"vaapi",
"-hwaccel_output_format",
"vaapi",
"-hwaccel_device",
"va",
]
return ["-hwaccel", "vaapi", "-hwaccel_output_format", "vaapi", "-vaapi_device", _VAAPI_DEVICE]
def _venc_opts(
transcode: bool,
hw: bool,
tonemap: bool = False,
preset: str = "veryfast",
crf: int = 20,
max_height: int = 0,
) -> list[str]:
"""The video-encoder args that replace `-c:v copy` for a transcode session (returns the copy args
when not transcoding). A re-encode must MINT its own keyframes at the segment boundaries — the copy
path leaned on the source's keyframes, but a re-encoded stream has none the HLS muxer can cut on
unless we force them (`-force_key_frames` every `_SEG_SECONDS`), else segments aren't independent and
seeking breaks. `preset`/`crf`/`max_height` (software only) come from admin sysconfig."""
if not transcode:
return ["-c:v", "copy"]
force_kf = ["-force_key_frames", f"expr:gte(t,n_forced*{_SEG_SECONDS})"]
if hw and tonemap:
# HDR/10-bit HW path: Gen 9.5 VPP can't tonemap (scale_vaapi=format=nv12 fails), so the decoded
# VAAPI surfaces are mapped to OpenCL (hwmap=derive_device=opencl), tone-mapped HDR→SDR to 8-bit
# nv12 there (tonemap_opencl, hable curve, bt709 out), then mapped back to VAAPI (reverse=1) for
# h264_vaapi. `-filter_hw_device ocl` tells the filter graph which device to run the OpenCL
# filters on. Full GPU pipeline — ~1 CPU core, several×realtime on the UHD 630 (measured).
return [
"-filter_hw_device",
"ocl",
"-vf",
"hwmap=derive_device=opencl,"
"tonemap_opencl=tonemap=hable:transfer=bt709:matrix=bt709:primaries=bt709:format=nv12,"
"hwmap=derive_device=vaapi:reverse=1",
"-c:v",
"h264_vaapi",
*force_kf,
]
if hw:
# scale_vaapi with format=nv12 downconverts a 10-bit (p010) HEVC source to 8-bit nv12 so
# h264_vaapi (8-bit High) can encode it; for an 8-bit source it's a cheap format pass-through.
# The GPU encode is cheap, so it runs at source resolution (max_height is a software-only knob).
return ["-vf", "scale_vaapi=format=nv12", "-c:v", "h264_vaapi", *force_kf]
# Software fallback: same browser-safe recipe as downloads/edit.py (libx264). yuv420p forces 8-bit
# High (not High10) so any browser decodes it; -threads reserves a core so the CPU-bound encode can't
# starve request handling / playback on the shared box.
args: list[str] = []
if max_height and max_height > 0:
# Downscale-ONLY to the height cap (min(ih,H)), keeping aspect with an even width (-2), so a tall
# source doesn't make the software re-encode ruinously expensive. The comma in min() is escaped
# (\,) so ffmpeg's filter parser reads one filter, not two. Never upscales a smaller source.
args += ["-vf", f"scale=-2:min(ih\\,{max_height})"]
args += [
"-c:v",
"libx264",
"-preset",
preset,
"-crf",
str(crf),
"-pix_fmt",
"yuv420p",
"-threads",
str(encode_thread_cap()),
*force_kf,
]
return args
def _render_node_present() -> bool:
"""Whether the VAAPI render node is available — i.e. `/dev/dri` was passed into the container.
Present on prod after the iGPU enablement; absent on the CPU-only dev box and in the localdev
Docker. Its own function so the selection is monkeypatchable in tests and one place for S1c's knob."""
return Path(_VAAPI_DEVICE).exists()
def _select_encode(db: DbSession, item: PlexItem, transcode: bool, src: Path) -> tuple[bool, bool]:
"""Pick the transcode video pipeline for this session — returns `(hw, tonemap)`:
* `(False, False)` → software libx264 (the fallback: no render node, non-decodable source, or a
10-bit/HDR source with no OpenCL runtime to tonemap on the GPU).
* `(True, False)` → full-GPU VAAPI (`scale_vaapi=format=nv12` + `h264_vaapi`) — an 8-bit SDR source.
* `(True, True)` → VAAPI decode → OpenCL tonemap → VAAPI encode — a true-HDR (PQ/HLG) source, when
the OpenCL runtime is present (Gen 9.5 VPP can't tonemap natively).
A 10-bit *SDR* source is deliberately NOT a `(True, True)` case: it needs a plain 10→8-bit downconvert,
not the HDR tone curve (which would visibly darken/flatten an SDR picture), so it drops to software.
Honours the admin `plex_transcode_hw` policy: "off" → always software; "on" → HW wherever the render
node exists (trusting the admin, skipping the decodability gate — the runtime HW→SW fallback still
backstops a session that produces nothing — but still routing a true-HDR source through the tonemap
graph so it doesn't hit the scale_vaapi wall); "auto" (default) → HW only when the render node is
present AND the iGPU can hardware-decode the source, with tonemap chosen only for a true-HDR (PQ/HLG)
source that has an OpenCL runtime, while a 10-bit SDR source drops to software."""
if not transcode:
return (False, False)
mode = (sysconfig.get_str(db, "plex_transcode_hw") or "auto").lower()
if mode == "off" or not _render_node_present():
return (False, False)
if mode == "on":
# Trust the admin: HW wherever the render node exists, skipping the decodability gate (the runtime
# backstop catches a miss). Only a true-HDR (PQ/HLG) source needs the tonemap graph — a 10-bit SDR
# source takes the plain scale path here, NOT the HDR tone curve. Probe only when OpenCL exists
# (short-circuit), so "on" stays probe-free where tonemap can't apply anyway.
tonemap = _opencl_present() and _probe_video_kind(src) == _VK_HDR
return (True, tonemap)
# auto
if (item.codec_video or "").lower() not in _HW_DECODABLE:
return (False, False)
kind = _probe_video_kind(src)
if kind == _VK_HWSAFE:
return (True, False) # 8-bit SDR → full-GPU scale path
if kind == _VK_HDR:
# PQ/HLG: HW OpenCL tonemap when the runtime is present, else software libx264.
return (True, True) if _opencl_present() else (False, False)
# _VK_SDR10 — 10-bit SDR: a plain 10→8-bit downconvert, done exactly on the CPU. Never the HDR tone
# curve (it would alter an SDR picture), and off the unverified scale_vaapi 10-bit path — software.
return (False, False)
def _want_hw(db: DbSession, item: PlexItem, transcode: bool, src: Path) -> bool:
"""The HW gate on its own (the `hw` half of `_select_encode`). A readability shim for the tests that
assert only whether a session runs on the GPU (production decides via `_select_encode` directly)."""
return _select_encode(db, item, transcode, src)[0]
# CPU-nice increment for the SOFTWARE transcode child. The Plex ffmpeg is a Popen in the long-lived API
# process (which must NOT itself be niced), so — unlike the download worker's _deprioritize (run once in
# its own main()) — we lower priority per-child via preexec_fn in the fork. The GPU path needs none of
# this. Reuses the download child's nice default so all co-located re-encoders share one handicap.
_SW_TRANSCODE_NICE = settings.download_child_nice
def _sw_child_preexec() -> None:
"""Runs in the forked child (POSIX) before exec: renice this software-transcode ffmpeg down so it
can't starve the co-located API / Postgres / playback. Best-effort — a failed renice must not fail
playback."""
try:
os.nice(_SW_TRANSCODE_NICE)
except OSError:
pass
def _probe_audio_langs(src: Path) -> list[str]:
"""One entry per audio stream (its language tag, or "" if untagged). len() = audio-track count.
Used to build the multi-rendition var_stream_map. Empty on probe failure → single-audio path."""
@@ -160,8 +440,19 @@ def _ffmpeg_cmd(
audio_ord: int | None,
aoff: float = 0.0,
audio_langs: list[str] | None = None,
transcode: bool = False,
hw: bool = False,
tonemap: bool = False,
preset: str = "veryfast",
crf: int = 20,
max_height: int = 0,
) -> list[str]:
args = ["ffmpeg", "-nostdin", "-loglevel", "error"]
# For a transcode session the video is re-encoded (VAAPI or libx264) instead of stream-copied; the
# hwaccel decode options go before -i, the encoder args replace `-c:v copy` below. Everything else is
# identical to remux. `tonemap` is the HDR/10-bit HW variant (OpenCL tonemap between VAAPI decode and
# encode) — it needs matching decode-side (named devices) and encode-side (the OpenCL filter graph) opts.
venc = _venc_opts(transcode, hw, tonemap, preset, crf, max_height)
args = ["ffmpeg", "-nostdin", "-loglevel", "error", *_hwaccel_in_opts(hw, tonemap)]
# Keep ORIGINAL timestamps on the output so the first segment carries the true absolute PTS of the
# keyframe ffmpeg actually seeked to. hls.js still zero-bases playback to that PTS; we read it back
# (see _probe_first_pts) to learn the real content offset. Without -copyts the segment PTS is
@@ -182,7 +473,7 @@ def _ffmpeg_cmd(
args += ["-map", "0:v:0"]
for i in range(len(audio_langs)):
args += ["-map", f"{ai}:a:{i}?"]
args += ["-sn", "-c:v", "copy", "-c:a", "aac", "-ac", "2", "-b:a", "192k"]
args += ["-sn", *venc, "-c:a", "aac", "-ac", "2", "-b:a", "192k"]
var = ["v:0,agroup:aud"]
for i, lang in enumerate(audio_langs):
entry = f"a:{i},agroup:aud,name:a{i}"
@@ -203,7 +494,7 @@ def _ffmpeg_cmd(
else:
ao = audio_ord if audio_ord is not None else 0
args += ["-map", "0:v:0", "-map", f"{ai}:a:{ao}?", "-sn"]
args += ["-c:v", "copy", "-c:a", "aac", "-ac", "2", "-b:a", "192k"]
args += [*venc, "-c:a", "aac", "-ac", "2", "-b:a", "192k"]
args += _HLS_TAIL + [
"-hls_segment_filename",
str(out_dir / "seg_%d.ts"),
@@ -258,12 +549,14 @@ def _kill(s: HlsSession) -> None:
def _enforce_cap(cap: int) -> None:
# Caller holds _lock. Drop the least-recently-accessed sessions over the cap.
if len(_sessions) <= cap:
# Caller holds _lock. Only TRANSCODE sessions count toward plex_max_transcodes — remux
# (stream-copy) and direct-serve are cheap and deliberately uncapped (see config.py:
# 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.
tx = [(key, s) for key, s in _sessions.items() if s.transcode]
if len(tx) <= cap:
return
for key, s in sorted(_sessions.items(), key=lambda kv: kv[1].last_access)[
: len(_sessions) - cap
]:
for key, s in sorted(tx, key=lambda kv: kv[1].last_access)[: len(tx) - cap]:
_kill(s)
_sessions.pop(key, None)
@@ -275,11 +568,14 @@ def start_session(
audio_ord: int | None = None,
aoff: float = 0.0,
multi: bool = False,
transcode: bool = False,
) -> HlsSession | None:
"""(Re)start the HLS remux for an item at the given offset. `multi` (item has >1 audio track) maps
every audio track as an HLS rendition so the client switches audio without a restart; otherwise a
single audio track (`audio_ord`) is muxed. Subtitles are separate WebVTT tracks (not muxed here).
Returns None if the local file can't be read."""
"""(Re)start the HLS session for an item at the given offset. `transcode` (item is non-h264,
playable="transcode") re-encodes the video to H.264 (VAAPI or libx264) instead of stream-copying it;
otherwise the video is copied (remux). `multi` (item has >1 audio track) maps every audio track as an
HLS rendition so the client switches audio without a restart; otherwise a single audio track
(`audio_ord`) is muxed. Subtitles are separate WebVTT tracks (not muxed here). Returns None if the
local file can't be read."""
src = paths.local_media_path(db, item.file_path)
if src is None:
return None
@@ -287,34 +583,102 @@ def start_session(
start_s = max(0.0, float(start_s))
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
with _lock:
old = _sessions.pop(key, None)
if old is not None:
_kill(old)
tag = "multi" if is_multi else str(audio_ord)
directory = _HLS_ROOT / f"{key}_{int(start_s)}_{tag}_{aoff:+.2f}"
shutil.rmtree(directory, ignore_errors=True)
directory.mkdir(parents=True, exist_ok=True)
proc = subprocess.Popen(
_ffmpeg_cmd(
src, start_s, directory, audio_ord, aoff, audio_langs if is_multi else None
),
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
s = HlsSession(key, directory, proc, start_s, "master.m3u8" if is_multi else "index.m3u8")
_sessions[key] = s
# Admin-configurable concurrency cap (Configuration → Plex → Max concurrent transcodes);
# at least 1 so playback can't be capped to zero.
_enforce_cap(max(1, sysconfig.get_int(db, "plex_max_transcodes")))
tag = "multi" if is_multi else str(audio_ord)
directory = _HLS_ROOT / f"{key}_{int(start_s)}_{tag}_{aoff:+.2f}"
# Wait for the first video segment, then warm up a few more — all outside the lock (this blocks on
# ffmpeg producing them, and we must not hold up other sessions).
def _seg(i: int) -> Path:
# multi-audio: the video rendition is variant v0 → seg_{i}_0.ts; single-audio → seg_{i}.ts
return directory / (f"seg_{i}_0.ts" if is_multi else f"seg_{i}.ts")
if wait_for(_seg(0), timeout=25.0):
def _spawn(
hw: bool, tonemap: bool = False, *, replace_only: "HlsSession | None" = None
) -> "HlsSession | None":
# `replace_only` makes the supersede identity-aware for the runtime fallback: the whole
# check-and-replace happens under ONE _lock, so a newer same-key session (a concurrent
# seek-restart) installed between the fallback's decision and here is never clobbered. When the
# registered session is no longer `replace_only`, we abort and return None (the caller yields to
# whoever is registered now). The INITIAL spawn passes replace_only=None and always supersedes —
# that is the seek-restart's intended last-writer-wins.
# Software-fallback encode tuning — read only on the SW transcode path (the VAAPI path and
# remux ignore preset/crf/max_height, and remux must not touch the DB for them).
preset, crf, max_height = "veryfast", 20, 0
if transcode and not hw:
preset = sysconfig.get_str(db, "plex_transcode_preset") or "veryfast"
crf = sysconfig.get_int(db, "plex_transcode_crf")
max_height = sysconfig.get_int(db, "plex_transcode_max_height")
# Nice the SOFTWARE transcode child (not the HW path, and not remux) so a CPU-bound re-encode
# can't starve the co-located API/playback. preexec_fn runs in the forked child (POSIX); on a
# 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
with _lock:
if replace_only is not None and _sessions.get(key) is not replace_only:
return None # superseded/culled while we waited out of lock — don't clobber the newer one
old = _sessions.pop(key, None)
if old is not None:
_kill(old)
shutil.rmtree(directory, ignore_errors=True)
directory.mkdir(parents=True, exist_ok=True)
proc = subprocess.Popen(
_ffmpeg_cmd(
src,
start_s,
directory,
audio_ord,
aoff,
audio_langs if is_multi else None,
transcode=transcode,
hw=hw,
tonemap=tonemap,
preset=preset,
crf=crf,
max_height=max_height,
),
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
preexec_fn=preexec,
)
s = HlsSession(
key,
directory,
proc,
start_s,
"master.m3u8" if is_multi else "index.m3u8",
transcode=transcode,
hw=hw,
tonemap=tonemap,
)
_sessions[key] = s
# Admin-configurable concurrency cap (Configuration → Plex → Max concurrent transcodes);
# at least 1 so playback can't be capped to zero.
_enforce_cap(max(1, sysconfig.get_int(db, "plex_max_transcodes")))
return s
s = _spawn(*_select_encode(db, item, transcode, src))
# Wait for the first video segment outside the lock (this blocks on ffmpeg, and we must not hold up
# other sessions). Pass the proc so a HW session that dies at VAAPI init fails fast (not after 25s).
ready = wait_for(_seg(0), timeout=25.0, proc=s.proc)
if not ready and s.hw:
# We waited on _seg(0) OUTSIDE _lock, so `s.proc` dying is ambiguous: it may have failed on its
# own (VAProfile/init/tonemap refusal), OR a concurrent same-key _spawn (a rapid seek-restart) or
# _enforce_cap may have killed it to install/keep a newer session. Respawn on libx264 (no HW, no
# tonemap) so a genuinely-failed GPU session never strands playback — but pass `replace_only=s` so
# the respawn is a no-op if we've been superseded: the identity check and the pop+replace share
# ONE lock in _spawn, so we can never tear down the newer session. s.hw is now False, so the route
# honestly reports "CPU" and the player's Preparing-stream state stays correct.
log.warning(
"plex transcode: HW session produced no segments — falling back to software key=%s", key
)
respawned = _spawn(False, False, replace_only=s)
if respawned is None:
# Superseded/culled mid-fallback — yield whoever is registered now (a newer seek-restart), or
# our own now-dead session if the key was cap-culled. Never None here: the POST /session route
# 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" /
# "Not available yet") the client re-requests.
return current_session(key) or s
s = respawned
ready = wait_for(_seg(0), timeout=25.0, proc=s.proc)
if ready:
# For a seek (start_s>0) learn the REAL start offset K from the first segment's absolute PTS,
# so the client's absolute clock + subtitle shift key off the true keyframe, not the requested
# offset. On probe failure we keep the provisional requested offset (old behaviour, small drift).
@@ -326,15 +690,18 @@ def start_session(
# at the first segment that isn't ready in time — ffmpeg produces them faster than realtime once
# warm, so a miss means something slow and we proceed rather than block the request further.
for i in range(1, _WARMUP_SEGMENTS):
if not wait_for(_seg(i), timeout=_WARMUP_SEG_TIMEOUT_S):
if not wait_for(_seg(i), timeout=_WARMUP_SEG_TIMEOUT_S, proc=s.proc):
break
log.info(
"plex hls session start key=%s req=%.1f real=%.3f multi=%s audio=%s",
"plex hls session start key=%s req=%.1f real=%.3f multi=%s audio=%s transcode=%s hw=%s tonemap=%s",
key,
start_s,
s.media_start_s,
is_multi,
audio_ord,
transcode,
s.hw,
s.tonemap,
)
return s
@@ -347,10 +714,14 @@ def current_session(key: str) -> HlsSession | None:
return s
def wait_for(path: Path, timeout: float = 20.0) -> bool:
def wait_for(path: Path, timeout: float = 20.0, proc: subprocess.Popen | None = None) -> bool:
"""Wait until a session file (playlist / segment) exists and is non-empty. Segments are
produced ~faster than realtime, so a segment just ahead of playback appears quickly; a segment
far beyond the generated region won't (the frontend restarts the session at a seek instead)."""
far beyond the generated region won't (the frontend restarts the session at a seek instead).
With `proc` given, also stop as soon as that ffmpeg exits: a VAAPI session that fails to init
(10-bit/HDR VAProfile refusal) dies within ~1s producing nothing, and without this we'd block the
whole `timeout` before the HW→SW fallback could kick in — turning the fix into a stall."""
end = time.time() + timeout
while time.time() < end:
try:
@@ -358,6 +729,12 @@ def wait_for(path: Path, timeout: float = 20.0) -> bool:
return True
except OSError:
pass
if proc is not None and proc.poll() is not None:
# the producer exited — no more segments are coming; one last check then give up.
try:
return path.exists() and path.stat().st_size > 0
except OSError:
return False
time.sleep(0.15)
return path.exists()
+130 -3
View File
@@ -18,7 +18,7 @@ item-detail/player path (P2), not here.
import logging
from datetime import datetime, timezone
from sqlalchemy import select
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app import sysconfig
@@ -58,6 +58,57 @@ def classify_playable(vcodec: str | None, acodec: str | None, container: str | N
return "direct"
# Video codecs the prod iGPU (UHD 630 / Quick Sync Gen 9.5) can hardware-DECODE — the precondition for
# the full-GPU VAAPI transcode pipeline. THE single source of this set: plex.stream imports it here so
# the runtime encoder choice (`_want_hw`) and this catalog facet can never disagree by codec. A source
# outside it (AV1, MPEG-4 ASP/XviD…) is software-only even on the iGPU (it can't decode it to feed its
# own encoder).
HW_DECODABLE_CODECS = frozenset({"h264", "hevc", "h265", "vp9", "vc1", "mpeg2video"})
# Color-transfer values marking an HDR source (PQ / HLG). A 10-bit/HDR source breaks the VAAPI
# scale=nv12 path on Gen 9.5 (see plex.stream) → software transcode, so it counts as NOT HW-capable.
_HDR_TRANSFERS = frozenset({"smpte2084", "arib-std-b67"})
def transcode_hw_capable(
playable: str | None,
vcodec: str | None,
bit_depth: int | None = None,
hdr: bool = False,
) -> bool | None:
"""The stored `PlexItem.transcode_hw` facet: for a `playable="transcode"` item, whether the prod
iGPU can HARDWARE-transcode it (else software libx264 only); None for a non-transcode item. HW needs
a HW-decodable codec AND an 8-bit SDR source — a 10-bit/HDR source (bit_depth>8 or an HDR transfer)
breaks the VAAPI nv12 pipeline and falls back to software. `bit_depth`/`hdr` are unknown at sync
time (the cheap listing carries no Stream), so they default to 8-bit SDR (codec-only) and the
bit-depth backfill refines a 10-bit/HDR HEVC to False later."""
if playable != "transcode":
return None
if (vcodec or "").lower() not in HW_DECODABLE_CODECS:
return False
if bit_depth is not None and bit_depth > 8:
return False
return not hdr
def _video_stream_props(meta: dict) -> tuple[int | None, bool]:
"""(bit_depth, is_hdr) from a FULL Plex metadata dict's video Stream (Media→Part→Stream, streamType
1). Only the rich metadata call carries Stream; the cheap listing does not — so this is read by the
bit-depth backfill, not the listing sync. (None, False) when absent/unparseable."""
for m in meta.get("Media") or []:
for part in m.get("Part") or []:
for s in part.get("Stream") or []:
if s.get("streamType") != 1: # 1 = video
continue
bd = s.get("bitDepth")
try:
depth = int(bd) if bd else None
except (TypeError, ValueError):
depth = None
hdr = str(s.get("colorTrc") or "").lower() in _HDR_TRANSFERS
return depth, hdr
return None, False
def _epoch(v) -> datetime | None:
try:
return datetime.fromtimestamp(int(v), tz=timezone.utc) if v else None
@@ -217,6 +268,13 @@ def sync(db: Session) -> dict:
stats["people_enriched"] = _enrich_people_batch(db, plex)
except Exception as e: # best-effort — never fail the sync over the backfill
log.warning("Plex people enrich batch failed: %s", e)
# Probe a bounded batch of HW-decodable transcode items for bit-depth/HDR, so the
# playback-class filter's HW/SW split is precise (a 10-bit/HDR HEVC → software) over
# successive runs. Only HW-decodable-codec transcodes are probed — the rest are SW by codec.
try:
stats["media_probed"] = _probe_media_batch(db, plex)
except Exception as e: # best-effort — never fail the sync over the backfill
log.warning("Plex media probe batch failed: %s", e)
except PlexNotConfigured as e:
return {"skipped": str(e)}
except PlexError as e:
@@ -236,6 +294,49 @@ def sync(db: Session) -> dict:
# How many titles to rich-fetch per sync run for the cast backfill — bounds the extra Plex load; the
# whole library is covered over successive runs (each fetched item is stamped people_enriched_at once).
_ENRICH_BATCH = 60
# Same bounded-per-run pattern for the bit-depth probe. Only HW-decodable-codec transcode items are
# probed (typically a small minority — the bulk of transcodes are old codecs that are SW by codec), so
# a modest batch covers the work-list in a run or two; a stuck item retries next run (watermark unset).
_PROBE_BATCH = 60
def _probe_media_batch(db: Session, plex: PlexClient, limit: int = _PROBE_BATCH) -> int:
"""Rich-fetch up to `limit` not-yet-probed HW-decodable transcode items, read the video Stream's
bit-depth + HDR, and store `video_bit_depth` + the refined `transcode_hw` (a 10-bit/HDR HEVC →
False/software). Stamps `media_probed_at` on every attempt (success OR a Plex miss with no Stream),
so an item is probed once and not re-fetched every sync; a transient fetch FAILURE leaves the
watermark unset to retry. Oldest-id first. Returns how many items were probed this run."""
rows = (
db.execute(
select(PlexItem)
.where(
PlexItem.playable == "transcode",
func.lower(PlexItem.codec_video).in_(HW_DECODABLE_CODECS),
PlexItem.media_probed_at.is_(None),
)
.order_by(PlexItem.id)
.limit(limit)
)
.scalars()
.all()
)
now = datetime.now(timezone.utc)
n = 0
for row in rows:
try:
meta = plex.metadata(row.rating_key) or {}
except Exception as e: # transient — leave media_probed_at NULL so it retries next run
log.debug("Plex media probe fetch failed for %s: %s", row.rating_key, e)
continue
bit_depth, hdr = _video_stream_props(meta)
row.video_bit_depth = bit_depth
row.transcode_hw = transcode_hw_capable("transcode", row.codec_video, bit_depth, hdr)
row.media_probed_at = (
now # probed (even if Plex carried no Stream — don't re-fetch forever)
)
n += 1
db.commit()
return n
def _enrich_people_batch(db: Session, plex: PlexClient, limit: int = _ENRICH_BATCH) -> int:
@@ -369,15 +470,41 @@ def _apply_item(row: PlexItem, lib_id: int, kind: str, meta: dict) -> None:
row.added_at = _epoch(meta.get("addedAt"))
_apply_facet_fields(row, meta) # filterable/orderable extras (shared with shows)
mf = _media_facts(meta)
new_vcodec = mf.get("codec_video")
new_part_key = mf.get("part_key")
# The physical file was replaced when its video codec changes OR — for a SINGLE-version item — when its
# Plex Part key changes (a re-encode / quality upgrade gets a new part id). Either way any probed
# bit-depth is stale, so reset the probe watermark and re-derive transcode_hw from codec alone until the
# backfill re-probes. Codec alone is NOT enough: a same-codec bit-depth re-encode (8-bit→10-bit HEVC)
# keeps the codec `hevc` but flips the HW class, and only the part-key move catches it.
#
# The part-key check is gated on single-version because `_media_facts` reads media[0]/part[0], and for a
# MULTI-version item (an original + a Plex "Optimized" copy) that order isn't guaranteed stable across
# listings — an unguarded part-key compare would then reset+re-probe every sync and oscillate the
# transcode_hw bucket between the two versions. A multi-version same-codec re-encode not self-healing is
# an acceptable rare edge; a per-sync re-probe loop is not.
single_version = len(meta.get("Media") or []) <= 1
codec_changed = (row.codec_video or "").lower() != (new_vcodec or "").lower()
if codec_changed or (single_version and row.part_key != new_part_key):
row.video_bit_depth = None
row.media_probed_at = None
row.file_path = mf.get("file_path")
row.part_key = mf.get("part_key")
row.part_key = new_part_key
row.container = mf.get("container")
row.codec_video = mf.get("codec_video")
row.codec_video = new_vcodec
row.codec_audio = mf.get("codec_audio")
row.size_bytes = mf.get("size_bytes")
row.playable = classify_playable(
mf.get("codec_video"), mf.get("codec_audio"), mf.get("container")
)
# transcode_hw: keep the bit-depth backfill's authoritative value (it folds in bit-depth + HDR) once
# probed; otherwise (re)derive the provisional codec-only class. A non-transcode item is always None,
# even if it had been probed as a transcode before (an audio/container flip to remux without a codec
# change wouldn't reset the watermark, so pin it here).
if row.playable != "transcode":
row.transcode_hw = None
elif row.media_probed_at is None:
row.transcode_hw = transcode_hw_capable("transcode", row.codec_video, row.video_bit_depth)
def _prune_stale(
+7
View File
@@ -18,6 +18,13 @@ class RateLimiter:
self._hits: dict[str, list[float]] = {}
self._lock = threading.Lock()
def reset(self) -> None:
"""Forget every recorded attempt. Each limiter is a module-level singleton, so a test suite
sharing one process would otherwise pool all of its attempts into a single window and
eventually trip an unrelated test with a 429."""
with self._lock:
self._hits.clear()
def allow(self, key: str) -> bool:
"""Record an attempt for ``key`` and return whether it is within the limit.
+394 -19
View File
@@ -1,20 +1,26 @@
"""Admin-only onboarding: review access requests (Invites), approve/deny, manual add.
Also the demo-account admin surface: the demo email whitelist + a manual state reset."""
from collections.abc import Iterable
from datetime import datetime, timezone
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy import or_, select
from sqlalchemy.orm import Session
from app import audit
from app import email as email_mod
from app import mailtext
from app.audit import AuditAction
from app.auth import (
admin_user,
can_activate,
count_admins,
env_allowed_canonicals,
find_user_by_email,
get_or_create_demo_user,
is_allowed,
operator_contact,
purge_user,
)
@@ -25,13 +31,42 @@ from app.models import (
Invite,
User,
)
from app.routes.auth import revoke_preset_password_if_unverified
from app.utils import iso, valid_email
from app.routes._common import get_or_404
from app.routes.auth import APPROVAL_VERIFY_TTL, issue_verification_link, send_verification_link
from app.utils import canonical_email, iso, valid_email
router = APIRouter(prefix="/api/admin", tags=["admin"])
def _serialize(inv: Invite) -> dict:
def _accounts_for(db: Session, emails: Iterable[str]) -> set[str]:
"""Which of these addresses already have an account, as a set of CANONICAL forms. Batched, so
listing the invites costs one extra query. An invite whose address has an account must not be
deletable: `is_allowed` reads this row, so removing it would silently revoke a live user's access.
Canonical on both sides, because `is_allowed` matches that way: an invite for `x.y@gmail.com`
whose account registered as `xy@gmail.com` would otherwise look account-less, offer the admin a
delete button, and take away a live user's access — precisely the accident this guard exists to
prevent."""
wanted = {canonical_email(e) for e in emails if e}
if not wanted:
return set()
rows = db.execute(
select(User.email_canonical).where(User.email_canonical.in_(wanted))
).scalars()
return {e for e in rows if e}
def _env_allowed(email: str) -> bool:
"""Whether the env bootstrap lists still let this address in. `is_allowed` consults them only
when no Invite row exists, so this is exactly what deleting the row would fall back to — and the
admin has to know that BEFORE they confirm, not from a toast afterwards.
Canonical, because `is_allowed` is: a warning that disagrees with the gate it is warning about is
worse than no warning."""
return canonical_email(email or "") in env_allowed_canonicals()
def _serialize(inv: Invite, *, has_account: bool = False) -> dict:
return {
"id": inv.id,
"email": inv.email,
@@ -40,6 +75,19 @@ def _serialize(inv: Invite) -> dict:
"requested_at": iso(inv.requested_at),
"decided_at": iso(inv.decided_at),
"decided_by": inv.decided_by,
# When (if ever) the admin sent this person an invitation. Adding an address is silent, so
# without this the admin has no way to answer "did I already write to them?" — and the only
# failure mode is writing to a real person twice.
"invited_at": iso(inv.invited_at),
# Drives the delete affordance: an entry backed by an account is not removable here.
"has_account": has_account,
# …and whether removing it would actually take access away, or leave the env allow-list
# granting it anyway (for an ADMIN_EMAILS address, as an admin).
"still_allowed_by_env": _env_allowed(inv.email),
# The language this person asked us in, or null when nobody ever told us (a hand-added
# whitelist row). The invitation dialog preselects it, and falls back to the admin's own UI
# language — a guess, but a visible one the admin can correct before sending.
"lang": inv.lang,
}
@@ -56,18 +104,65 @@ def list_invites(
rows = db.execute(stmt.order_by(Invite.requested_at.desc())).scalars().all()
order = {"pending": 0, "approved": 1, "denied": 2}
rows.sort(key=lambda i: order.get(i.status, 9))
return [_serialize(i) for i in rows]
accounts = _accounts_for(db, (i.email for i in rows))
return [_serialize(i, has_account=canonical_email(i.email) in accounts) for i in rows]
def _welcome_mail_parts(db: Session, email: str) -> tuple[str | None, bool, int]:
"""What a welcome or invitation mail should tell this address to do next: a confirmation link (if
there is an unconfirmed account behind it), whether an account exists at all, and how long the
link lasts.
This is the C option's whole mechanic. In the common ordering — the address is approved and the
person signs up either side of that — they would otherwise have to find and open TWO messages:
one saying they are in, another asking them to confirm. Carrying the link inside the mail that is
already going out makes it one mail and one click.
Resolved through `find_user_by_email`, so an account registered under another spelling of the
same Gmail mailbox is still recognised — and, deliberately, so several candidates resolve to
"no single answer" rather than a guess: an invitation that carried the wrong account's link would
be worse than one that simply pointed at the sign-up page.
"""
hours = int(APPROVAL_VERIFY_TTL.total_seconds() // 3600)
user = find_user_by_email(db, email)
if user is None or user.is_demo:
return None, False, hours
if user.email_verified or not email_mod.email_enabled(db):
return None, True, hours
return issue_verification_link(db, user, APPROVAL_VERIFY_TTL), True, hours
def _activate_user_by_email(db: Session, email: str) -> None:
"""Approving access activates a matching (e.g. pending password-registration) account so
it can sign in. No-op if no such user exists yet (a Google user is created at first login)."""
user = db.execute(select(User).where(User.email == email.lower())).scalar_one_or_none()
it can sign in. No-op if no such user exists yet (a Google user is created at first login).
E9.5 S1: this path deliberately does NOT revoke a preset password. Revoking here destroyed the
password a legitimate registrant had just chosen and made "your password was cleared for
security" the DEFAULT experience, because approve-before-verify is the normal ordering.
S1 justified that by saying `password_login` refuses an unverified account BEFORE it looks at
`is_active`, so a pre-emptor could not sign in "approved or not". A review of the finished epic
showed that argument only holds while the address stays unverified — and S6's approval mail
hands the real owner a confirmation link precisely so they will end that state. Nothing replaced
the revoke, and that is deliberate: round 2 of the same review tried "the newest registration
owns an unverified account" and reverted it, because the rule is symmetric — an attacker can
always register last — and it handed anonymous callers a write onto a real account's credential.
The owner's recourse is password-reset, which proves the mailbox, rewrites the hash and bumps
the session epoch. The residual is logged as SEC-16. The revoke also stays on the Google-adoption path
(auth.revoke_preset_password_if_unverified), where ownership is actually attested."""
# Canonical, with the literal spelling preferred when both exist: approving
# `npeter83.temu@gmail.com` must activate the account that registered as `npeter83temu@gmail.com`,
# since Gmail delivers both to one mailbox and the admin decided about the mailbox.
email = email.lower()
candidates = (
db.execute(
select(User).where(User.email_canonical == canonical_email(email)).order_by(User.id)
)
.scalars()
.all()
)
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:
# SEC-10: same guard as the Google-adoption path — approving an account whose email was never
# verified must not hand a working login to a password set on it by whoever registered the
# address (a pre-emption). Shared single-source helper so the two paths can't drift.
revoke_preset_password_if_unverified(user)
user.is_active = True
db.commit()
@@ -106,8 +201,13 @@ def approve_invite(
) -> dict:
inv = _decide(db, invite_id, admin, approved=True)
_activate_user_by_email(db, inv.email)
background.add_task(email_mod.send_access_approved, inv.email)
return _serialize(inv)
# Computed here, not in the background task: issuing the token needs this request's session, and
# the task must not outlive it. Approval answers a request the person MADE, so unlike the
# invitation below it is sent automatically — a reply is owed, not a nudge.
link, has_account, hours = _welcome_mail_parts(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)
return _serialize(inv, has_account=bool(_accounts_for(db, [inv.email])))
@router.post("/invites/{invite_id}/deny")
@@ -120,8 +220,10 @@ def deny_invite(
# Symmetry with approve: the requester hears back either way, instead of a silent limbo. No
# account is activated, so this is an email only (the requester has no in-app inbox yet).
inv = _decide(db, invite_id, admin, approved=False)
background.add_task(email_mod.send_access_denied, inv.email)
return _serialize(inv)
background.add_task(
email_mod.send_access_denied, inv.email, mailtext.lang_for_email(db, inv.email)
)
return _serialize(inv, has_account=bool(_accounts_for(db, [inv.email])))
class AddInviteIn(BaseModel):
@@ -138,7 +240,16 @@ def add_invite(
email = body.email.strip().lower()
if not valid_email(email):
raise HTTPException(status_code=400, detail="Enter a valid email address.")
inv = db.execute(select(Invite).where(Invite.email == email)).scalar_one_or_none()
# 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@),
# which is how an admin ends up approving the same human twice and still seeing them bounce.
canon = canonical_email(email)
rows = (
db.execute(select(Invite).where(Invite.email_canonical == canon).order_by(Invite.id))
.scalars()
.all()
)
inv = next((r for r in rows if r.email == email), None) or next(iter(rows), None)
if inv is None:
inv = Invite(email=email)
db.add(inv)
@@ -150,7 +261,126 @@ def add_invite(
)
db.commit()
_activate_user_by_email(db, email)
return _serialize(inv)
return _serialize(inv, has_account=bool(_accounts_for(db, [email])))
class InviteSendIn(BaseModel):
# Which language to write this person in. Optional so an existing caller (and a test) can post
# an empty body, but the admin UI always sends it: this is the one recipient the app has no
# signal about, because the admin typed the address in themselves.
lang: str | None = None
@router.post("/invites/{invite_id}/invite")
def send_invitation(
invite_id: int,
body: InviteSendIn | None = None,
admin: User = Depends(admin_user),
db: Session = Depends(get_db),
) -> dict:
"""Tell someone their address is whitelisted — only when the admin asks for it.
Whitelisting stays SILENT on purpose. The operator opens a door as a possibility, usually having
already agreed it with the person, and an address can sit approved for weeks with nobody
registering; a mail fired automatically would be nudging someone who never asked to be nudged.
So the send is its own act, on its own button, and `invited_at` records it so the admin can see
they have already written rather than writing twice.
Only an APPROVED row can be invited: there is nothing to invite someone to while their request is
undecided, and inviting a denied address would contradict the decision."""
inv = get_or_404(db, Invite, invite_id, detail="No such invite")
if inv.status != "approved":
raise HTTPException(
status_code=409,
detail={"code": "invite_send", "reason": "not_approved", "email": inv.email},
)
if not email_mod.email_enabled(db):
# Same reasoning as the admin-side verification resend: mail is fail-soft everywhere else,
# but a button the admin pressed must not answer with a success toast for a mail that was
# never sent.
raise HTTPException(status_code=409, detail={"code": "invite_send", "reason": "email_off"})
link, has_account, hours = _welcome_mail_parts(db, inv.email)
# An explicit pick from the dialog is recorded, so a later mail to this address matches the one
# the admin already sent. Only the explicit pick — writing the fallback back would turn a column
# that means "what this person told us" into "what we last guessed".
picked = body.lang if body else None
if picked in mailtext.SUPPORTED:
lang = inv.lang = picked
else:
lang = mailtext.lang_for_email(db, inv.email)
# Sent INLINE, so `invited_at` records the send rather than the intent. Every other mail here is
# fail-soft in the background, which is right when nobody is waiting on it — but this column
# exists to answer "did I already write to them?", and the UI dims the button and warns about a
# second mail on the strength of it. Stamping it before a send that then failed makes the one
# question it answers a lie, and talks the admin out of the retry that would fix it.
#
# One thing a failed send does leave behind: the confirmation token `_welcome_mail_parts` minted
# to render the link, which commits inside `_issue_token`. Accepted rather than chased — the
# token is single-use, expires on its own, and was mailed nowhere; deferring it would mean
# rewriting a helper three callers share so the handler owns its commit.
if not email_mod.send_access_invitation(inv.email, lang, link, has_account, hours):
raise HTTPException(
status_code=422, detail={"code": "invite_send", "reason": "send_failed"}
)
# Only after the mail is actually out. Same as the other two paths that decide for an address
# (`approve_invite`, `add_invite`): an approved row means this mailbox may come in, so finish it,
# or the mail can promise "your account is ready — just sign in" to someone `password_login` will
# refuse for `is_active`. Doing it BEFORE the send would mean a request the UI reports as failed
# had quietly activated an account behind the admin's back.
_activate_user_by_email(db, inv.email)
inv.invited_at = datetime.now(timezone.utc)
audit.record(
db,
admin.id,
AuditAction.INVITE_SEND,
target_type="invite",
target_id=inv.id,
summary=inv.email,
)
db.commit()
return _serialize(inv, has_account=has_account)
@router.delete("/invites/{invite_id}")
def delete_invite(
invite_id: int,
admin: User = Depends(admin_user),
db: Session = Depends(get_db),
) -> dict:
"""Remove a whitelist/request entry outright — the counterpart to add_invite, for an address that
never became an account (an approval added by mistake, or one that is no longer wanted). Deny
records a decision and keeps the row for the audit trail; delete forgets the address. Neither
stops the person asking again: `upsert_pending_invite` reopens a denied row, and a deleted one
simply comes back as a fresh pending request.
Refused once an account exists for the address, whatever its state: this row is the account's
approval gate, and orphaning the account from it would leave a person who can neither sign in nor
be approved. Delete the USER instead — purge_user removes the invite along with it.
Reports `still_allowed_by_env`, because deleting the row is NOT always a revoke: `is_allowed`
falls back to the env ALLOWED_EMAILS / ADMIN_EMAILS bootstrap lists once no invite exists, so for
an address on those the deletion leaves access intact (and an ADMIN_EMAILS address is still
promoted to admin on its next Google sign-in). The admin has to hear that, not guess it."""
inv = db.get(Invite, invite_id)
if inv is None:
raise HTTPException(status_code=404, detail="No such invite")
if _accounts_for(db, [inv.email]):
raise HTTPException(
status_code=409,
detail="That address already has an account — delete the account instead.",
)
still_allowed_by_env = _env_allowed(inv.email)
audit.record(
db,
admin.id,
AuditAction.INVITE_DELETE,
target_type="invite",
target_id=inv.id,
summary=inv.email,
)
db.delete(inv)
db.commit()
return {"deleted": invite_id, "still_allowed_by_env": still_allowed_by_env}
# --- Users & roles --------------------------------------------------------------------
@@ -224,7 +454,13 @@ def set_user_role(
target.role = role
db.commit()
if changed:
background.add_task(email_mod.send_role_changed, target.email, role, operator_contact())
background.add_task(
email_mod.send_role_changed,
target.email,
mailtext.lang_for_user(target),
role,
operator_contact(),
)
return _serialize_user(target)
@@ -273,7 +509,12 @@ def set_user_suspended(
target.is_suspended = suspended
db.commit()
if was_suspended and not suspended:
background.add_task(email_mod.send_account_unsuspended, target.email, operator_contact())
background.add_task(
email_mod.send_account_unsuspended,
target.email,
mailtext.lang_for_user(target),
operator_contact(),
)
return _serialize_user(target)
@@ -318,6 +559,140 @@ def admin_delete_user(
# --- Demo account: email whitelist + state reset -------------------------------------
# --- Stuck sign-ups: the admin lifeline (E9.5 S5, roadmap UX-25) ---------------------------------
def _pending_access_rows(db: Session) -> list[User]:
"""Accounts that have signed up but still cannot sign in, newest first.
Two different dead ends share this list, because from the admin's chair they look the same — a
person who registered and is still outside:
· `is_active=False` — approved BEFORE they signed up, so nothing ever landed in the
Access-requests queue and its Approve button doesn't apply (`_decide` only takes a pending
row). This is roadmap UX-25, and it is why the list exists.
· `email_verified=False` — the confirmation mail hasn't been acted on. Not the admin's to
decide, but theirs to help with: on 2026-09-03 the first mail to a fresh Gmail landed in
Spam, so "send it again" is a real move.
A suspended account is deliberately excluded: it cannot sign in either, but that is a decision
already made, not a person stuck waiting on one.
"""
return list(
db.execute(
select(User)
.where(
User.is_demo.is_(False),
User.is_suspended.is_(False),
or_(User.is_active.is_(False), User.email_verified.is_(False)),
)
.order_by(User.created_at.desc())
)
.scalars()
.all()
)
@router.get("/pending-access")
def list_pending_access(_: User = Depends(admin_user), db: Session = Depends(get_db)) -> list[dict]:
"""The sign-ups that are still outside, with which gate is shut for each. `can_activate` cannot
be derived on the client — it needs the invite/allow-list lookup — so it is answered here.
One `is_allowed` query per row rather than a batched re-implementation of it: the row count is
the admin's own backlog of stuck sign-ups (single digits on any real instance), and a second,
subtly different copy of the "may this address sign in" rule is exactly the drift this codebase
keeps getting bitten by."""
out = []
for u in _pending_access_rows(db):
# The whole rule in one line: this address MAY sign in, and this account still can't. An
# undecided request fails it (nothing is allowed yet) and so does a denied one — both belong
# to the queue above, which owns that decision. Without this, every fresh registration
# appeared in both lists at once, which UAT caught on 2026-09-05.
if not is_allowed(db, u.email):
continue
out.append(_serialize_user(u) | {"can_activate": can_activate(db, u)})
return out
@router.post("/users/{user_id}/activate")
def activate_user(
user_id: int,
admin: User = Depends(admin_user),
db: Session = Depends(get_db),
) -> dict:
"""Let in a registrant whose address was approved before they signed up (roadmap UX-25).
Admin-side ONLY, and that is the whole design. Two earlier attempts at this were reverted after
review, and both moved authority rather than visibility: auto-activating on registration turns a
standing whitelist entry into a bearer grant for whoever registers the address first, and
reopening the approved invite to `pending` lets an anonymous sign-up REVOKE a standing approval.
This endpoint changes nothing about what an unauthenticated caller can cause — the admin could
already do exactly this by re-entering the address in the whitelist box (`add_invite` →
`_activate_user_by_email`). What was missing was any way to know they had to."""
target = get_or_404(db, User, user_id, detail="No such user")
if target.is_demo:
raise HTTPException(status_code=400, detail="The demo account can't be activated.")
if target.is_active:
return _serialize_user(target) # idempotent: a double click is not an error
if not is_allowed(db, target.email):
raise HTTPException(
status_code=409,
detail={"code": "activate", "reason": "not_allowed", "email": target.email},
)
audit.record(
db,
admin.id,
AuditAction.USER_ACTIVATE,
target_type="user",
target_id=target.id,
summary=target.email,
before={"is_active": False},
after={"is_active": True},
)
target.is_active = True
db.commit()
return _serialize_user(target)
@router.post("/users/{user_id}/resend-verification")
def resend_user_verification(
user_id: int,
admin: User = Depends(admin_user),
db: Session = Depends(get_db),
) -> dict:
"""Re-send an account's confirmation link from the admin side.
The public /auth/verify/resend answers uniformly and reveals nothing, which is right for an
anonymous caller but leaves the admin unable to tell a delivered mail from a dropped one. Here
the caller is already authenticated as an admin, so this one says plainly what happened.
Not rate-limited: an admin is not a threat model to enumerate against, and the deliberate silence
of the public endpoint is exactly what this exists to escape."""
target = get_or_404(db, User, user_id, detail="No such user")
if target.is_demo or target.email_verified:
raise HTTPException(
status_code=409,
detail={"code": "resend", "reason": "not_applicable", "email": target.email},
)
if not email_mod.email_enabled(db):
# Mail is fail-soft everywhere else, which is what left the 0.78.5 sign-ups with nothing to
# fall back on. An admin pressing a button deserves the failure, not a success toast.
raise HTTPException(status_code=409, detail={"code": "resend", "reason": "email_off"})
if not send_verification_link(db, target):
# Same rule as the invitation above: a button an admin pressed must not answer with a
# success for a mail that was dropped. Mail is fail-soft everywhere else because nobody is
# waiting on the answer; here somebody is.
raise HTTPException(status_code=422, detail={"code": "resend", "reason": "send_failed"})
audit.record(
db,
admin.id,
AuditAction.USER_VERIFY_RESEND,
target_type="user",
target_id=target.id,
summary=target.email,
)
db.commit()
return {"ok": True, "email": target.email}
def _serialize_demo(row: DemoWhitelist) -> dict:
return {
"id": row.id,
+461 -105
View File
@@ -6,6 +6,8 @@ helpers) stays in app/auth.py, imported below."""
import ipaddress
import secrets
from datetime import datetime, timedelta, timezone
from typing import NamedTuple
from urllib.parse import urlencode
from authlib.integrations.starlette_client import OAuth, OAuthError
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request
@@ -16,6 +18,7 @@ from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app import email as email_mod
from app import mailtext
from app import sysconfig
from app.auth import (
_YOUTUBE_SCOPES,
@@ -25,6 +28,8 @@ from app.auth import (
READ_SCOPES,
WRITE_SCOPES,
current_user,
env_admin_canonicals,
find_user_by_email,
get_or_create_demo_user,
google_enabled,
is_allowed,
@@ -37,9 +42,15 @@ from app.db import SessionLocal, get_db
from app.models import AuthToken, DemoWhitelist, Invite, OAuthToken, User
from app.ratelimit import RateLimiter
from app.security import encrypt, hash_password, hash_token, verify_password
from app.utils import valid_email
from app.utils import canonical_email, valid_email
VERIFY_TTL = timedelta(hours=24)
# A confirmation link that rides an APPROVAL or an invitation lives longer than one the person is
# waiting for. Nobody sits refreshing their inbox for an approval — it can easily be read on Monday
# after a weekend, and if it has expired by then the one-mail-one-click promise is spent and they are
# back to two messages. The risk is small: this token only ever confirms an address. It signs nobody
# in and sets no password.
APPROVAL_VERIFY_TTL = timedelta(days=7)
RESET_TTL = timedelta(hours=1)
_DECOY_PASSWORD_HASH = hash_password(secrets.token_urlsafe(16))
@@ -47,17 +58,27 @@ _DECOY_PASSWORD_HASH = hash_password(secrets.token_urlsafe(16))
_register_limiter = RateLimiter(max_events=5, window_seconds=300)
_login_limiter = RateLimiter(max_events=10, window_seconds=300)
_reset_limiter = RateLimiter(max_events=5, window_seconds=300)
# Separate from _reset_limiter on purpose: opening a link must not eat the budget for REQUESTING one.
_reset_check_limiter = RateLimiter(max_events=10, window_seconds=300)
_verify_resend_limiter = RateLimiter(max_events=5, window_seconds=300)
_request_access_limiter = RateLimiter(max_events=5, window_seconds=300)
_suspend_email_limiter = RateLimiter(max_events=1, window_seconds=3600)
def _notify_suspended(background: BackgroundTasks, email: str) -> None:
def _notify_suspended(background: BackgroundTasks, user: User) -> None:
"""Schedule the one-per-hour 'account suspended' email to a user whose (otherwise valid)
sign-in was just blocked. Gated by verified credentials at the call site, so this can't be
triggered by someone who doesn't control the account."""
if _suspend_email_limiter.allow(email):
background.add_task(email_mod.send_account_suspended, email, operator_contact())
triggered by someone who doesn't control the account.
Takes the user rather than the address because the mail needs their language, and the send runs
as a background task — after this request's session, and the row, are out of reach."""
if _suspend_email_limiter.allow(user.email):
background.add_task(
email_mod.send_account_suspended,
user.email,
mailtext.lang_for_user(user),
operator_contact(),
)
_demo_limiter = RateLimiter(max_events=5, window_seconds=60)
@@ -90,18 +111,43 @@ def google_oauth(db: Session):
return _oauth.google
def admin_notify_emails(db: Session) -> list[str]:
"""Who to email about admin events (e.g. a new access request). The ACTUAL admins — every active,
non-suspended `role=admin` user — UNIONED with the env ADMIN_EMAILS bootstrap list. Using the DB
roles means an admin promoted through the UI is notified too (env alone would miss them, yet they
CAN approve requests — the approve UI is role-gated, so the notify set must match); the env union
keeps a freshly-seeded instance working before any DB admin exists."""
db_admins = db.execute(
select(User.email).where(
def admin_notify_groups(db: Session) -> dict[str, list[str]]:
"""Who to email about admin events (e.g. a new access request), grouped by the language each of
them reads. The ACTUAL admins — every active, non-suspended `role=admin` user — UNIONED with the
env ADMIN_EMAILS bootstrap list. Using the DB roles means an admin promoted through the UI is
notified too (env alone would miss them, yet they CAN approve requests — the approve UI is
role-gated, so the notify set must match); the env union keeps a freshly-seeded instance working
before any DB admin exists.
Grouped rather than one blast to everyone, because these recipients are people with their own UI
language: a single message would have to pick one of them and be wrong for the rest. An env
address with no admin account still gets looked up, so an operator listed in ADMIN_EMAILS whose
account is (say) suspended is still written to in the language they chose."""
rows = db.execute(
select(User.email, User.preferences).where(
User.role == "admin", User.is_active.is_(True), User.is_suspended.is_(False)
)
).scalars()
return sorted({e.lower() for e in db_admins if e} | settings.admin_email_set)
).all()
by_email = {e.lower(): mailtext.normalize((p or {}).get("language")) for e, p in rows if e}
for email in settings.admin_email_set:
if email not in by_email:
by_email[email] = mailtext.lang_for_email(db, email)
groups: dict[str, list[str]] = {}
for email in sorted(by_email):
groups.setdefault(by_email[email], []).append(email)
return groups
def notify_admins_of_request(
db: Session, background: BackgroundTasks | None, requester: str
) -> None:
"""Tell every admin that someone is waiting — one mail per language group. Sends inline when
there is no BackgroundTasks to hand (the registration path already runs outside a response)."""
for lang, recipients in admin_notify_groups(db).items():
if background is None:
email_mod.send_admin_new_request(recipients, lang, requester)
else:
background.add_task(email_mod.send_admin_new_request, recipients, lang, requester)
def is_demo_allowed(db: Session, email: str) -> bool:
@@ -147,14 +193,64 @@ def _client_ip(request: Request) -> str:
return peer
def upsert_pending_invite(db: Session, email: str) -> Invite | None:
"""Idempotently record an access request. Returns the Invite if a *new* pending row
was created (so the caller can notify admins), else None for already-known emails."""
class InviteRequest(NamedTuple):
"""What an access request settled on.
`invite` is the row the request now lives under — which is NOT always the address that was just
typed: one Gmail mailbox spelled two ways settles on whichever row already exists, and that row's
address is where mail will go. Callers that tell the user what happened need it (E9.5 S4).
`is_new` is the old boolean return in a name: true only when a row was created or a denied one
reopened, i.e. exactly when admins should hear about it. A repeat request must not re-spam them.
"""
invite: Invite | None
is_new: bool
def upsert_pending_invite(db: Session, email: str, lang: str | None = None) -> InviteRequest:
"""Idempotently record an access request, and the language it was made in.
"Already known" is decided on the CANONICAL form: `npeter83.temu@` and `npeter83temu@` are one
Gmail mailbox, and treating them as two is what put a second, silent access request in the admin
queue on 2026-09-03 for an already-approved user. An approved sibling wins — re-requesting must
not reopen a decision that has already been made for that mailbox. The row is still STORED under
the address as typed, because that is where mail has to go.
`lang` is the language of the surface the request came from — the sign-in page, or the locale
Google reported for a refused sign-in. The latest one wins: someone who asks again from a
Hungarian page is telling us something newer than last time. Anything unsupported is ignored
rather than stored, so a forged body cannot put junk in the column.
The commit contract is unchanged: this still commits ONLY when it writes something. Callers rely
on that — `_register_account` persists its own flushed user precisely because a no-op here
commits nothing (E9.5 S6).
"""
email = (email or "").lower()
if not valid_email(email):
return None
inv = db.execute(select(Invite).where(Invite.email == email)).scalar_one_or_none()
return InviteRequest(None, False)
lang = lang if lang in mailtext.SUPPORTED else None
canon = canonical_email(email)
rows = (
db.execute(select(Invite).where(Invite.email_canonical == canon).order_by(Invite.id))
.scalars()
.all()
)
# An approved row wins, so a denied duplicate can't mask an approval. Then a row that is not
# DENIED, preferring the exact spelling — a denied sibling must never be picked while a pending
# row exists, or the reopen branch below would fire and put one person in the admin's queue
# twice. Only when every row is denied does the spelling decide which one reopens.
undecided = [r for r in rows if r.status != "denied"]
inv = (
next((r for r in rows if r.status == "approved"), None)
or next((r for r in undecided if r.email == email), None)
or next(iter(undecided), None)
or next((r for r in rows if r.email == email), None)
or next(iter(rows), None)
)
if inv is not None:
relearned = lang is not None and inv.lang != lang
if relearned:
inv.lang = lang
# Let a previously-denied person ask again; don't disturb approved/pending rows.
if inv.status == "denied":
inv.status = "pending"
@@ -162,12 +258,39 @@ def upsert_pending_invite(db: Session, email: str) -> Invite | None:
inv.decided_at = None
inv.decided_by = None
db.commit()
return inv
return None
inv = Invite(email=email, status="pending")
return InviteRequest(inv, True)
if relearned:
db.commit()
return InviteRequest(inv, False)
inv = Invite(email=email, status="pending", lang=lang)
db.add(inv)
db.commit()
return inv
return InviteRequest(inv, True)
def _access_notice_url(kind: str, email: str = "", inv: Invite | None = None) -> str:
"""The landing URL for a Google sign-in that did not reach an account: `requested` (we recorded
the request) or `denied` (nothing to record).
It rides the FRAGMENT, not the query string, for the same reason the verify/reset tokens do
(SB3): a fragment never reaches the server, a proxy access log or a Referer header — and this
notice now carries the user's address. Naming it is the point of E9.5 S4: on 2026-09-03 a
registration as `npeter83.temu@` met an OAuth claim of `npeter83temu@`, and a screen that said
"request recorded" without saying FOR WHAT hid the mismatch for a day.
`linked` names the address the request actually lives under, and ONLY when that differs from the
one Google just returned — the honest version of "we didn't open a second request". Same
spelling twice is just a repeat, and saying anything there would be noise.
urlencode (not an f-string): a `+tag` address must arrive as %2B, or the SPA's URLSearchParams
would read it back as a space and mangle exactly the address this epic exists to protect.
"""
params = {"access": kind}
if email:
params["email"] = email
if inv is not None and inv.email != email:
params["linked"] = inv.email
return "/#" + urlencode(params)
_DEV_OAUTH_HOSTS = frozenset(
@@ -201,6 +324,12 @@ class EmailIn(BaseModel):
class EmailPasswordIn(BaseModel):
email: str | None = None
password: str | None = None
# The language the sign-up page was in. Captured at registration because it is the ONLY moment
# the choice exists anywhere the server can see: it lives in the browser until an account does,
# and a preference nobody recorded cannot be backfilled later (the lesson `google_email` taught
# in E9.5 S3 — those rows still read NULL until each user signs in again). Nothing reads it yet;
# the bilingual mail templates are E9.5 S7.
lang: str | None = None
class TokenIn(BaseModel):
@@ -283,26 +412,37 @@ async def callback(
# A denied Google login doubles as an access request: record it for the admin and
# land the user on a friendly "request received" screen instead of a raw 403.
if email:
inv = upsert_pending_invite(db, email)
admins = admin_notify_emails(db)
if inv is not None and admins:
background.add_task(email_mod.send_admin_new_request, admins, email)
return RedirectResponse(url="/?access=requested")
return RedirectResponse(url="/?access=denied")
# Google just told us this person's locale — the only language signal that will ever
# exist for an address with no account, and the decision mail is written from it.
req = upsert_pending_invite(db, email, (userinfo.get("locale") or "").split("-")[0])
if req.is_new:
notify_admins_of_request(db, background, email)
return RedirectResponse(url=_access_notice_url("requested", email, req.invite))
return RedirectResponse(url=_access_notice_url("denied"))
user = db.query(User).filter(User.google_sub == sub).one_or_none()
if user is None:
# No account carries this Google identity yet. If one already exists for this email —
# No account carries this Google identity yet. If one already exists for this MAILBOX —
# e.g. an email+password registration — adopt the Google identity onto it instead of
# inserting a duplicate (which would collide on the unique email and 500). Google has
# verified the address, so this safely unifies password + Google sign-in on one account.
existing = db.query(User).filter(User.email == email).one_or_none()
# inserting a duplicate. Google has verified the address, so this safely unifies password +
# Google sign-in on one account.
#
# Matched canonically, and that is not cosmetic: on 2026-09-03 a registration as
# `npeter83.temu@gmail.com` met an OAuth claim of `npeter83temu@gmail.com`, the literal
# lookup found nothing, and prod ended up with TWO active user rows for one Gmail mailbox
# (ids 13 and 14). Exact spelling still wins when both exist, so an account that already
# matches literally is never passed over for a canonical sibling.
canon = canonical_email(email)
candidates = db.query(User).filter(User.email_canonical == canon).order_by(User.id).all()
existing = next((u for u in candidates if u.email == email), None) or next(
iter(candidates), None
)
if existing is not None:
if existing.google_sub is not None and existing.google_sub != sub:
# A different Google identity is already bound to this email — refuse rather than
# hijack it. (Near-impossible with real Google accounts: email↔sub is stable.)
log.warning("Google login email collision (different sub): %s", email)
return RedirectResponse(url="/?access=denied")
return RedirectResponse(url=_access_notice_url("denied"))
if not userinfo.get("email_verified", False):
# Adopting (and activating) a pre-existing password account requires Google to
# actually attest the address — otherwise an unverified Google email that merely
@@ -311,7 +451,7 @@ async def callback(
"Google login: unverified email, refusing to adopt account: %s",
email,
)
return RedirectResponse(url="/?access=denied")
return RedirectResponse(url=_access_notice_url("denied"))
user = existing
user.google_sub = sub
# SEC-10: drop any password pre-set on this account while its email was still unverified,
@@ -328,22 +468,23 @@ async def callback(
# tell the (verified) owner why — a successful Google auth proves they control this account.
if user.is_suspended:
log.info("Suspended account blocked at Google login: %s", email)
_notify_suspended(background, user.email)
_notify_suspended(background, user)
return RedirectResponse(url="/?login=suspended")
# Keep the email in sync with Google, but never overwrite it with one another account already
# owns — that would hit the unique constraint and 500, wedging this user's sign-in (rare: a
# Google-side email change to a value that collides with a different local account).
if user.email != email:
clash = db.query(User).filter(User.email == email, User.id != user.id).one_or_none()
if clash is None:
user.email = email
# Record what Google actually answers with, beside the address the user registered with. The two
# are the same string for most people; where they differ (a deliberate `+tag`, a different dot
# pattern) the UI shows both, so nobody has to guess which one signs them in.
if email:
user.google_email = email
_sync_google_email(db, user, email)
user.display_name = userinfo.get("name")
user.avatar_url = userinfo.get("picture")
# ADMIN_EMAILS (env) is the bootstrap admin list and always wins, so the configured admin can't
# be locked out. Everyone else's role is managed in the admin UI and stored in the DB, so a
# routine login must NOT reset it (that would clobber a UI promotion/demotion). Only seed a
# default for a brand-new account (its role attribute is still unset before flush).
if email in settings.admin_email_set:
# Canonical, like every other read of the env lists: a dotted or `+tag`ged ADMIN_EMAILS entry
# would otherwise never bootstrap its admin, because Google answers with the plain primary.
if canonical_email(email) in env_admin_canonicals():
user.role = "admin"
elif not user.role:
user.role = "user"
@@ -355,8 +496,7 @@ async def callback(
# picked one yet); unsupported locales fall back to English.
prefs = dict(user.preferences or {})
if not prefs.get("language"):
loc = (userinfo.get("locale") or "").split("-")[0].lower()
prefs["language"] = loc if loc in {"en", "hu"} else "en"
prefs["language"] = mailtext.normalize((userinfo.get("locale") or "").split("-")[0].lower())
user.preferences = prefs
db.flush()
@@ -375,6 +515,30 @@ async def callback(
return RedirectResponse(url="/")
def _sync_google_email(db: Session, user: User, email: str) -> None:
"""Follow a genuine Google-side email change — and ONLY that.
A different SPELLING of the same mailbox is the user's own choice and must survive. People
register as `me+siftlode@gmail.com` precisely so a later leak from this site is identifiable, and
silently rewriting that to `me@gmail.com` destroys the thing it was for. It also locked them out
of the spelling they chose, because sign-in matches the stored address first. Google returns the
account's PRIMARY address, which need not be what they registered with — and can never contain a
`+` at all — so without this guard every plus-tagged registration lost its tag on the first Google
sign-in. (E9.5 S3 made that reachable by matching adoption across spellings; the rewrite itself is
older.)
Never overwrite with an address another account already owns either: that would hit the unique
constraint and 500, wedging this user's sign-in.
"""
if not email or user.email == email:
return
if canonical_email(user.email) == canonical_email(email):
return # same mailbox, their spelling — leave it alone
clash = db.query(User).filter(User.email == email, User.id != user.id).one_or_none()
if clash is None:
user.email = email
def _store_token(db: Session, user: User, token: dict) -> None:
"""Persist the OAuth grant on the user's single token row, creating it if absent. Google
only returns a refresh_token on (re)consent, so the previous one is kept otherwise.
@@ -435,6 +599,9 @@ def _complete_link(
if user.google_sub is not None and user.google_sub != sub:
return RedirectResponse(url="/?link=mismatch")
user.google_sub = sub
linked_email = (userinfo.get("email") or "").strip().lower()
if linked_email:
user.google_email = linked_email # see the sign-in callback: the other half of the identity
if not user.display_name:
user.display_name = userinfo.get("name")
if not user.avatar_url:
@@ -468,15 +635,23 @@ async def logout(request: Request):
return JSONResponse({"ok": True, "switched": False})
class AccessRequestIn(EmailIn):
# The language of the page this request was made from. Same reasoning as registration's `lang`
# (and the same shape): it is the ONLY moment this preference exists anywhere the server can
# see, since the requester has no account to carry it, and it decides what language the approval
# or denial mail is written in.
lang: str | None = None
@router.post("/request-access")
async def request_access(
body: EmailIn,
body: AccessRequestIn,
request: Request,
background: BackgroundTasks,
db: Session = Depends(get_db),
) -> dict:
"""Public: ask for access. Idempotent — repeat requests for the same email don't
duplicate or re-spam admins (upsert returns None for an already-pending row).
duplicate or re-spam admins (upsert answers `is_new=False` for an already-pending row).
Answers a uniform ``"pending"`` for EVERY outcome — already-allowed, newly-pending,
already-pending, and rate-limited alike. The old ``"approved"`` reply for an allow-listed
@@ -491,10 +666,9 @@ async def request_access(
return {"status": "pending"}
if is_allowed(db, email):
return {"status": "pending"} # already allowed — don't reveal it
inv = upsert_pending_invite(db, email)
admins = admin_notify_emails(db)
if inv is not None and admins:
background.add_task(email_mod.send_admin_new_request, admins, email)
req = upsert_pending_invite(db, email, body.lang)
if req.is_new:
notify_admins_of_request(db, background, email)
return {"status": "pending"}
@@ -551,6 +725,29 @@ def _issue_token(db: Session, user: User, kind: str, ttl: timedelta) -> str:
return raw
def _peek_token(db: Session, raw: str | None, kind: str) -> User | None:
"""Is this token still usable — WITHOUT spending it. Same four conditions as _consume_token, and
the same "does its user still exist" answer, so the two can never disagree about a given token.
Deliberately NOT the read half of a read-check-write: _consume_token stays one atomic UPDATE, and
this never feeds it. A peek is only ever an early warning — the confirm endpoint remains the
authority, because a token can expire, be spent, or lose its account between the two calls.
"""
if not raw:
return None
row = db.execute(
select(AuthToken).where(
AuthToken.token_hash == hash_token(raw),
AuthToken.kind == kind,
AuthToken.used_at.is_(None),
AuthToken.expires_at >= datetime.now(timezone.utc),
)
).scalar_one_or_none()
# db.get, exactly as _consume_token does: a deleted account cascades its tokens away, so this is
# belt-and-braces — but the two must agree, and agreeing by construction beats agreeing by luck.
return db.get(User, row.user_id) if row is not None else None
def _consume_token(db: Session, raw: str | None, kind: str) -> User | None:
"""Validate + burn a token atomically. Returns its user, or None if missing/expired/used/wrong
kind. The burn is a single conditional UPDATE (…WHERE used_at IS NULL…RETURNING) rather than a
@@ -606,17 +803,59 @@ def register(
# 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
# skips all that) — a timing oracle for enumeration. Off-path, any valid email responds the same.
background.add_task(_register_account, email, password)
background.add_task(_register_account, email, password, body.lang)
return {"status": "ok"}
def _register_account(email: str, password: str) -> None:
def _register_account(email: str, password: str, lang: str | None = None) -> None:
"""Background worker for /register: create the pending account (+ verification email + admin
notice) for a genuinely new email; no-op for an already-registered one. Runs after the response
with its own DB session, so the request's timing never reveals whether the email already exists."""
with SessionLocal() as db:
if db.execute(select(User).where(User.email == email)).scalar_one_or_none() is not None:
return # already registered — nothing to do (the uniform "ok" was already returned)
# Canonical, so a second spelling of one Gmail mailbox cannot open a SECOND account. Prod
# already carries such a pair (user ids 13 and 14, both for npeter83temu@gmail.com); this is
# the end that stops new ones appearing, while the Google callback copes with the existing.
candidates = (
db.execute(
select(User).where(User.email_canonical == canonical_email(email)).order_by(User.id)
)
.scalars()
.all()
)
# Exact spelling wins when a mailbox carries two rows, same as every other canonical lookup
# in this epic (`find_user_by_email`, `_activate_user_by_email`, the Google callback). Without
# it `.first()` handed back whichever row Postgres felt like, so a re-registration of the
# dotted spelling could resolve to the undotted, already-confirmed twin and send nothing.
existing = next((u for u in candidates if u.email == email), None) or next(
iter(candidates), None
)
if existing is not None:
# Already registered. This used to return in silence, and that silence is the most
# likely explanation for the 2026-09-03 report that a confirmation mail "never arrived":
# nothing was ever SENT. The person re-registered, got the same cheerful "check your
# inbox", and waited for a message that did not exist.
#
# Re-sending the link instead leaks nothing new. The response and its timing are
# unchanged (uniform "ok", decided off the response path), the mail goes to the address
# ON THE ACCOUNT, and this is exactly what the public /auth/verify/resend already does
# for the same input — so it is not a new capability, just the end of a dead end. An
# account that is already confirmed still gets nothing: there is nothing to confirm.
#
# And a re-send is ALL it does: the submitted password is discarded. E9.5's closing
# review tried "while the address is unverified, the newest registration owns it"
# (replace the hash, kill the outstanding verify tokens) as a fix for address
# pre-emption, and round 2 showed it was worse than the problem — DO NOT REPEAT IT.
# The rule is symmetric, and an attacker can always be last: it hands an unauthenticated
# caller the power to overwrite a real registrant's chosen password and delete the
# confirmation link already in their inbox, and on a SEC-10-stripped row (password_hash
# NULL, password_revoked_at set) it would install a password where the whole point was
# that there is none. The owner's real recourse already exists and needs no new
# primitive: password-reset accepts this account, proves the mailbox, rewrites the hash
# and bumps the session epoch. The residual is logged as SEC-16.
user = existing
if not user.email_verified and not user.is_demo and email_mod.email_enabled():
send_verification_link(db, user)
return
# Without working SMTP we can't deliver a verification link, so email ownership can't gate
# sign-in. Admin approval stays the real gate (is_active=False); mark the account verified so
# the flow still completes on a no-SMTP self-host. See email.email_enabled().
@@ -626,6 +865,8 @@ def _register_account(email: str, password: str) -> None:
password_hash=hash_password(password),
is_active=False,
email_verified=not email_ok,
# Only a supported code is stored, so a forged body can't put junk in preferences.
preferences={"language": lang} if lang in mailtext.SUPPORTED else {},
)
db.add(user)
try:
@@ -636,28 +877,81 @@ def _register_account(email: str, password: str) -> None:
# no-op as the pre-check hit — roll back and stop.
db.rollback()
return
upsert_pending_invite(db, email) # admin-approval gate (commits)
upsert_pending_invite(db, email) # admin-approval gate
# Persist the account ourselves. upsert_pending_invite commits only when it actually writes an
# invite row — not when this address already has a pending or approved one — and _issue_token
# below only runs with SMTP on. So for an address the admin had already approved (or one whose
# request was still pending), on an instance with no working SMTP, NOTHING along this path
# committed and the flushed user was silently rolled back when the session closed: the
# would-be user got the uniform "ok" and no account, and re-registering did the same again.
# Commit unconditionally; it is a no-op when upsert already committed the same transaction.
db.commit()
if email_ok:
raw = _issue_token(db, user, "verify", VERIFY_TTL)
# Fragment (#), not a query token: keeps the token out of proxy/access logs + Referer (SB3).
email_mod.send_verify_email(email, f"{_app_base()}/#verify={raw}")
admins = admin_notify_emails(db)
if admins:
email_mod.send_admin_new_request(admins, email)
send_verification_link(db, user)
# Fired unconditionally, unlike the other two callers which gate on a fresh invite row: for
# an address the admin already approved, nothing lands in the Access-requests queue, so this
# is the only place the account's existence is announced at all. Delivery is a separate
# matter — with no working SMTP `_send` drops it, which is why the pre-approved registrant
# still needs the admin to notice them (roadmap UX-25).
notify_admins_of_request(db, None, email)
def issue_verification_link(db: Session, user: User, ttl: timedelta | None = None) -> str:
"""A fresh confirmation link for `user`, returned rather than sent — for a mail that carries the
link inside a larger message (the branching approval/invitation mails, E9.5 S6).
Same token machinery and the same fragment (SB3) as the standalone confirmation mail, so the two
kinds of link cannot drift apart in shape or in how they are consumed."""
raw = _issue_token(db, user, "verify", ttl or VERIFY_TTL)
return f"{_app_base()}/#verify={raw}"
def send_verification_link(db: Session, user: User) -> bool:
"""Issue a fresh verification token for `user` and mail them the link.
Always to the address ON THE ACCOUNT, never a spelling typed into some form: the account's
address is the one its owner chose to be reachable at, and a `+tag` there is usually deliberate.
The token rides the URL FRAGMENT (#) so it can never reach a proxy/access log or a Referer (SB3).
One definition, three callers — registration, the self-service resend, and the admin-side resend
(E9.5 S5). The link's shape and TTL must not drift between them; a verification mail that differs
by who triggered it is a bug waiting to be discovered by exactly one user.
Returns whether the mail actually went out, so a caller with a human waiting on the answer (the
admin-side resend) can say so instead of reporting success for a message that was dropped.
"""
return email_mod.send_verify_email(
user.email,
mailtext.lang_for_user(user),
issue_verification_link(db, user),
hours=int(VERIFY_TTL.total_seconds() // 3600),
)
@router.post("/verify")
def verify_email_confirm(body: TokenIn, db: Session = Depends(get_db)) -> dict:
"""Confirm an email-verification token the SPA read from the URL fragment (SB3). Uniform 400
on any invalid/expired/used token."""
on any invalid/expired/used token.
`password_reset_required` tells the SPA to send the user straight to the reset flow: SEC-10 had
dropped the password this account registered with, so sign-in would answer with the deliberately
uniform "Invalid email or password" — true, but unreadable to the person who just proved they own
the mailbox. Saying so leaks nothing: holding a valid single-use verify token for this account
already proves that much. Since E9.5 S1 the common source of that state is gone (admin approval no
longer revokes), leaving accounts stripped before that change and the Google-adoption path, so this
flag is rare now — but it must stay, because those users have no other way back in."""
user = _consume_token(db, body.token, "verify")
if user is None:
raise HTTPException(
status_code=400, detail="This verification link is invalid or has expired."
)
raise HTTPException(status_code=400, detail={"code": "verify", "reason": "invalid"})
user.email_verified = True
needs_password = user.password_hash is None and user.password_revoked_at is not None
email = user.email
db.commit()
return {"ok": True}
# Naming the address lets the SPA say WHICH mailbox it just confirmed — the same reasoning as
# password_reset_required above: holding a valid single-use verify token for this account already
# proves that much, so echoing it back leaks nothing. It matters in a browser that holds a session
# for a different account, where an unqualified "email confirmed" would be actively misleading.
return {"ok": True, "password_reset_required": needs_password, "email": email}
@router.post("/verify/resend")
@@ -687,22 +981,36 @@ def _resend_verify_if_eligible(email: str) -> None:
if not email_mod.email_enabled():
return
with SessionLocal() as db:
user = db.execute(select(User).where(User.email == email)).scalar_one_or_none()
user = find_user_by_email(db, email)
if user is not None and not user.email_verified and not user.is_demo:
raw = _issue_token(db, user, "verify", VERIFY_TTL)
email_mod.send_verify_email(email, f"{_app_base()}/#verify={raw}")
send_verification_link(db, user)
@router.get("/verify")
def verify_email(token: str, db: Session = Depends(get_db)):
"""Legacy query-token verification link (pre-SB3 emails still in flight). New emails use the
fragment + POST flow above; this bounces to a friendly status for older links until they expire."""
user = _consume_token(db, token, "verify")
if user is None:
return RedirectResponse(url="/?verify=invalid")
user.email_verified = True
db.commit()
return RedirectResponse(url="/?verify=ok")
def _login_refused(status: int, reason: str, user: User | None = None, **extra) -> HTTPException:
"""Every refusal password_login can answer with, in the codebase's structured `{code, reason}`
shape so the SPA can say it in the user's own language (the old bare English strings were
unreadable under a bilingual UI, and unusable as anything but a one-line error).
The account-shaped reasons carry the address ON THE ACCOUNT plus both gate flags. That is what
turns a flat 403 into an honest screen — it names the mailbox and says which of the two gates is
still shut. It is not an enumeration leak: this branch is only reachable once the password has
been proven, so the caller already knows the account exists. The two reasons that answer BEFORE
that proof (`invalid`, `rate_limited`) carry nothing at all, so they stay byte-identical for a
real account and an unknown one.
Since E9.5 S1 the two gates are order-independent, so `approved` really can be true while
`email_verified` is false, and vice versa — the SPA renders whichever is actually outstanding
rather than assuming a sequence.
"""
detail: dict = {"code": "login", "reason": reason}
if user is not None:
detail |= {
"email": user.email,
"email_verified": user.email_verified,
"approved": user.is_active,
}
detail |= {k: v for k, v in extra.items() if v is not None}
return HTTPException(status_code=status, detail=detail)
@router.post("/password-login")
@@ -716,29 +1024,23 @@ def password_login(
Once the password is proven correct, the owner gets a specific reason if their account is
still pending or suspended — that's not an enumeration leak (they already hold the password)."""
if not _login_limiter.allow(_client_ip(request)):
raise HTTPException(status_code=429, detail="Too many attempts. Try again shortly.")
raise _login_refused(429, "rate_limited")
email = (body.email or "").strip().lower()
password = body.password or ""
user = db.execute(select(User).where(User.email == email)).scalar_one_or_none()
user = find_user_by_email(db, email)
# Always run the KDF (against a decoy hash for an unknown / passwordless / demo account) so the
# response time is the same whether or not the email exists — no timing-based enumeration.
candidate_hash = user.password_hash if (user and not user.is_demo) else None
password_ok = verify_password(password, candidate_hash or _DECOY_PASSWORD_HASH)
if user is None or user.is_demo or not password_ok:
raise HTTPException(status_code=401, detail="Invalid email or password.")
raise _login_refused(401, "invalid")
if user.is_suspended:
_notify_suspended(background, user.email)
op = operator_contact()
detail = "Your account has been suspended."
if op:
detail += f" If you have questions, contact the operator at {op}."
raise HTTPException(status_code=403, detail=detail)
_notify_suspended(background, user)
raise _login_refused(403, "suspended", user, operator=operator_contact() or None)
if not user.email_verified:
raise HTTPException(
status_code=403, detail="Please verify your email first (check your inbox)."
)
raise _login_refused(403, "email_unverified", user)
if not user.is_active:
raise HTTPException(status_code=403, detail="Your account is awaiting admin approval.")
raise _login_refused(403, "pending_approval", user)
_establish_session(request, user)
log.info("Password login: %s (id=%s, role=%s)", email, user.id, user.role)
return {"ok": True}
@@ -767,12 +1069,43 @@ def _send_reset_if_eligible(email: str) -> None:
"""Background worker for password_reset_request: issue a reset token + email the link, but ONLY
for a real (non-demo) password account. Runs after the response with its own DB session, so the
request's timing never reveals whether the account exists. Token rides the URL FRAGMENT (#) so it
can't leak into proxy/access logs or a Referer (SB3)."""
can't leak into proxy/access logs or a Referer (SB3).
"Password account" is `password_hash OR password_revoked_at` — an account SEC-10 stripped had a
password and must be able to get a new one, or it is locked out permanently (no password to sign in
with, and formerly no reset link either). A Google-only account that never had a password stays
ineligible on purpose: its owner adds one from Settings (/auth/set-password) while signed in, which
proves identity through the session rather than by mail."""
with SessionLocal() as db:
user = db.execute(select(User).where(User.email == email)).scalar_one_or_none()
if user is not None and user.password_hash and not user.is_demo:
user = find_user_by_email(db, email)
if user is None or user.is_demo:
return
if user.password_hash or user.password_revoked_at:
raw = _issue_token(db, user, "reset", RESET_TTL)
email_mod.send_password_reset(email, f"{_app_base()}/#reset={raw}")
# The account's own address, as above — never the spelling that was typed into the form.
email_mod.send_password_reset(
user.email, mailtext.lang_for_user(user), f"{_app_base()}/#reset={raw}"
)
@router.post("/password-reset/check")
def password_reset_check(body: TokenIn, request: Request, db: Session = Depends(get_db)) -> dict:
"""Is the reset link the SPA just opened still good? Answers without spending the token.
Why this exists: the card used to render "set a new password" purely because a `#reset=` fragment
was present, never asking whether it could still succeed. Reported on 2026-09-05 after deleting an
account and then opening its reset link — the user types a new password into a form that answers
400. Opening a dead reset link is not an edge case: the token lives one hour, mail is slow or lands
in spam, and whoever got two mails usually clicks the older one.
Not an enumeration oracle: probing requires already holding a 43-character random, and the answer
is exactly what submitting it would reveal. Throttled per IP anyway — and a throttled caller is
told `true`, never `false`: this check may only ever save a user a wasted form, so when it cannot
answer it must degrade to the old behaviour instead of declaring a live link dead.
"""
if not _reset_check_limiter.allow(_client_ip(request)):
return {"ok": True}
return {"ok": _peek_token(db, body.token, "reset") is not None}
@router.post("/password-reset/confirm")
@@ -783,12 +1116,13 @@ def password_reset_confirm(body: TokenPasswordIn, db: Session = Depends(get_db))
if len(new_password) < PASSWORD_MIN_LEN:
raise HTTPException(
status_code=400,
detail=f"Password must be at least {PASSWORD_MIN_LEN} characters.",
detail={"code": "reset", "reason": "weak_password", "min": PASSWORD_MIN_LEN},
)
user = _consume_token(db, token, "reset")
if user is None:
raise HTTPException(status_code=400, detail="This reset link is invalid or has expired.")
raise HTTPException(status_code=400, detail={"code": "reset", "reason": "invalid"})
user.password_hash = hash_password(new_password)
user.password_revoked_at = None # the account has a password again — SEC-10's trace is spent
# A valid reset link proves control of the mailbox, so completing a reset also verifies the
# email — this rescues an account that registered but never clicked the verification link (it
# can then sign in). The separate admin-approval gate (is_active) is deliberately NOT touched
@@ -828,12 +1162,33 @@ def bump_session_epoch(user: User) -> None:
def revoke_preset_password_if_unverified(user: User) -> None:
"""SEC-10 (account pre-emption): a password set on an account whose email was NEVER verified is
untrusted — whoever registered the address (to pre-empt the real owner) could have chosen it. When
ownership is finally attested (Google adoption, or admin approval), drop that password and bump the
epoch to revoke any session it minted; the owner re-sets one via password-reset. No-op for an
already-verified account, so a legit no-SMTP registration is unaffected. The single source for this
rule — both the Google-callback and admin-activation paths call it so they can't drift."""
the real owner attests ownership through Google, drop that password and bump the epoch to revoke any
session it minted; the owner re-sets one via password-reset. No-op for an already-verified account,
so a legit no-SMTP registration is unaffected.
The Google-adoption callback is the ONLY caller (E9.5 S1). Admin approval used to call this too, and
dropping it was right for the reason S1 gave — the revoke destroyed the password a legitimate
registrant had just chosen, and since the admin normally decides before the user reads their mail,
that made the "your password was cleared" detour the DEFAULT path rather than an edge case.
S1 also claimed the revoke was REDUNDANT there, because `password_login` refuses an unverified
account before it reads `is_active`. That part was wrong, and the epic's own review caught it: the
gate holds only while the address is unverified, and S6's approval mail exists to get the real
owner to end that state — at which point a pre-emptor's password works. Nothing replaced the
revoke, and that is deliberate. The review tried "the newest registration owns an unverified
account" and reverted it: the rule is symmetric, so an attacker can simply register last, and it
would have handed anonymous callers a write onto a real account's credential. What the owner
actually has is password-reset, which proves the mailbox, rewrites the hash and bumps the epoch.
The residual is logged as SEC-16; re-adding a caller here would put "your password was cleared"
back in front of every legitimate registrant, which is the whole reason S1 removed it.
`password_revoked_at` is what makes "the owner re-sets one via password-reset" actually true. The
revoke leaves `password_hash IS NULL`, which is also what a Google-only account looks like — and
_send_reset_if_eligible keyed off exactly that, so a revoked account silently got no reset link and
was locked out for good (it could not sign in either). The stamp separates the two cases."""
if not user.email_verified:
user.password_hash = None
user.password_revoked_at = datetime.now(timezone.utc)
bump_session_epoch(user)
@@ -903,6 +1258,7 @@ def set_password(
if not verify_password(body.current_password or "", user.password_hash):
raise HTTPException(status_code=403, detail="Current password is incorrect.")
user.password_hash = hash_password(new_password)
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
# the person making the change stays signed in here. Commit BEFORE re-stamping so a failed commit
# can't leave the cookie at a newer epoch than the DB (which would wrongly 401 THIS session).
+2 -1
View File
@@ -10,6 +10,7 @@ from sqlalchemy.orm import Session
from app import audit
from app import email as email_mod
from app import mailtext
from app import sysconfig
from app.audit import AuditAction
from app.auth import admin_user
@@ -115,7 +116,7 @@ def send_test_email(
lets the admin verify the settings actually work."""
if not email_mod.email_enabled():
raise HTTPException(status_code=400, detail="SMTP is not configured.")
sent = email_mod.send_test(user.email)
sent = email_mod.send_test(user.email, mailtext.lang_for_user(user))
if not sent:
raise HTTPException(status_code=422, detail="SMTP send failed — check the settings.")
return {"sent": True, "to": user.email}
+88 -6
View File
@@ -6,11 +6,14 @@ worker is a separate process); this module never downloads — it enqueues, mana
serves finished files (range-aware, with the user's custom display name), and shares them.
"""
import os
import re
import tempfile
from datetime import datetime, timedelta, timezone
from pathlib import Path
from urllib.parse import parse_qs, urlparse
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile
from fastapi.responses import FileResponse
from pydantic import BaseModel
from sqlalchemy import func, select
@@ -24,6 +27,7 @@ from app.downloads import credentials as dlcreds
from app.downloads import edit as editmod
from app.downloads import links as linksmod
from app.downloads import quota, service, storage
from app.downloads import upload as uploadmod
from app.models import (
DownloadJob,
DownloadLink,
@@ -123,6 +127,9 @@ def _serialize(job: DownloadJob, asset: MediaAsset | None) -> dict:
"width": asset.width,
"height": asset.height,
"container": asset.container,
# "video" | "audio" | "file" — the library gates play/edit on this (media only); a
# generic uploaded file is download-only. Null while still in flight (set at finalize).
"media_kind": asset.media_kind,
"uploader_url": asset.uploader_url,
"thumbnail_url": asset.thumbnail_url,
"expires_at": iso(asset.expires_at),
@@ -354,6 +361,61 @@ def enqueue_download(
return _serialize_job(db, job)
# Read the upload in 1 MiB chunks so a large file never sits fully in memory, and the size cap is
# enforced on the ACTUAL bytes read (a client-set Content-Length can't be trusted to gate it).
_UPLOAD_CHUNK = 1024 * 1024
@router.post("/upload")
def upload_download(
file: UploadFile = File(...),
display_name: str | None = Form(None),
user: User = Depends(require_human),
db: Session = Depends(get_db),
) -> dict:
"""Ingest a local file into the caller's library (E7 S5). Any file type is accepted; media
(video/audio) gets the full function set, a generic file is download-only. The file is streamed
to a temp path under the download volume (enforcing the per-file cap as it goes), then handed to
the non-yt-dlp ingest which probes + places it and returns an already-`done` job.
A sync (threadpool) handler on purpose: the ingest does blocking work — an ffprobe of up to 30s
plus sync DB writes — so it must NOT run on the event loop. `file.file` is the sync file object
Starlette spools the multipart body into."""
cap = sysconfig.get_int(db, "download_upload_max_bytes")
staging = Path(settings.download_root) / ".staging" / "uploads"
staging.mkdir(parents=True, exist_ok=True)
fd, tmp = tempfile.mkstemp(dir=str(staging))
tmp_path = Path(tmp)
try:
size = 0
with os.fdopen(fd, "wb") as out:
while chunk := file.file.read(_UPLOAD_CHUNK):
size += len(chunk)
if cap and size > cap:
raise HTTPException(
status_code=422, detail={"code": "upload_too_large", "limit": cap}
)
out.write(chunk)
if size == 0:
raise HTTPException(status_code=400, detail={"code": "upload_empty"})
try:
job = uploadmod.ingest_upload(
db,
user.id,
filename=file.filename or "upload",
src_path=tmp_path,
size=size,
display_name=(display_name or "").strip() or None,
)
except quota.QuotaExceeded as e:
raise HTTPException(status_code=422, detail=_quota_detail(e))
return _serialize_job(db, job)
finally:
# Ingest MOVES the temp file into place on success (unlink is then a no-op); on any early exit
# — cap hit, empty, quota, probe/move error — this removes the abandoned staging file.
tmp_path.unlink(missing_ok=True)
class EnqueueEditIn(BaseModel):
source_job_id: int = 0
edit_spec: dict
@@ -544,8 +606,10 @@ def redownload_download(
never deletes a file another job/user still shares: the old asset is only removed if THIS job
was its last holder."""
job = _own_job(db, user, job_id)
if job.job_kind == "edit":
raise HTTPException(status_code=409, detail="Editor clips can't be re-downloaded.")
if job.job_kind in ("edit", "upload"):
# 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.
raise HTTPException(status_code=409, detail="This item can't be re-downloaded.")
if job.status in ("queued", "running", "paused"):
raise HTTPException(status_code=409, detail="This download is still in progress.")
service.redownload(db, job)
@@ -610,8 +674,21 @@ def download_file(
asset.last_access_at = datetime.now(timezone.utc)
db.commit()
# Starlette's FileResponse honours the Range header (206 partial content) for seek/resume.
return FileResponse(path, filename=filename)
# The served Content-Type comes from the ffprobe-verified media_kind + a container allow-list
# (storage.served_media_type), NEVER guessed from the file's own extension. A generic 'file' (and
# any media whose container we don't recognise — e.g. a media/HTML polyglot named "evil.html")
# returns None → a neutral force-download attachment, so uploaded HTML/SVG can't execute inline on
# this origin. Recognised media is served with its real type so the in-app player streams it. Both
# are range-aware (FileResponse → 206).
media_type = storage.served_media_type(asset.media_kind, asset.container)
if media_type is None:
return FileResponse(
path,
filename=filename,
media_type="application/octet-stream",
content_disposition_type="attachment",
)
return FileResponse(path, filename=filename, media_type=media_type)
# --- editor filmstrip (scrub track) --------------------------------------------------------
@@ -823,7 +900,12 @@ def list_links(
class CreateLinkIn(BaseModel):
password: str = ""
allow_download: bool = False
# D1 (E7 S3): a newly shared download is downloadable by default — the common intent is "let the
# recipient have the file", not stream-only. Forward-only: this only sets the default for links
# minted from now on; existing links keep whatever `allow_download` they were created with (the
# column default stays False, so no back-catalogue link silently flips). The owner can still
# uncheck it in the Share dialog, and toggle any link stream-only afterwards.
allow_download: bool = True
expires_days: int | None = None
+22
View File
@@ -5,6 +5,7 @@ from sqlalchemy.orm import Session
from app.auth import (
count_admins,
count_pending_activations,
current_user,
google_enabled,
has_read_scope,
@@ -13,6 +14,7 @@ from app.auth import (
optional_current_user,
purge_user,
)
from app import email as email_mod
from app import sysconfig
from app.db import get_db
from app.models import Invite, User
@@ -98,11 +100,21 @@ def get_me(
if user is None:
return {"authenticated": False}
pending_invites = 0
pending_activations = 0
# Admin-only, because it drives an operator warning: email is fail-soft, so a half-filled SMTP
# config costs verification links, password resets and access-request notices with nothing
# visible anywhere. Surfacing it here puts the warning on the screens where onboarding happens.
email_configured = True
if user.role == "admin":
pending_invites = (
db.scalar(select(func.count()).select_from(Invite).where(Invite.status == "pending"))
or 0
)
# Counted separately from pending_invites rather than folded into it: the two are different
# facts (a request awaiting a decision vs a decided address whose sign-up never got let in),
# and quietly widening one number would make an existing field mean something new.
pending_activations = count_pending_activations(db)
email_configured = email_mod.email_enabled(db) # this request's session; no second connect
return {
"authenticated": True,
"id": user.id,
@@ -112,6 +124,12 @@ def get_me(
"role": user.role,
"is_demo": user.is_demo,
"has_google": user.google_sub is not None,
# The address Google answers with, when it DIFFERS from the account's own. Same string for
# most people, and then this is None so no surface has to decide whether to show it — the
# comparison lives here, once, instead of in every consumer.
"google_email": (
user.google_email if user.google_email and user.google_email != user.email else None
),
"has_password": user.password_hash is not None,
# 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).
@@ -121,6 +139,10 @@ def get_me(
"can_read": has_read_scope(user),
"can_write": has_write_scope(user),
"pending_invites": pending_invites,
# Sign-ups an admin still has to let in (roadmap UX-25): approved BEFORE they registered, so
# nothing ever reached the Access-requests queue and the badge used to read zero.
"pending_activations": pending_activations,
"email_configured": email_configured,
"preferences": user.preferences or {},
}
+42
View File
@@ -2,6 +2,7 @@
from fastapi import APIRouter, Depends, Query
from sqlalchemy import (
Boolean,
Integer,
String,
and_,
@@ -27,6 +28,7 @@ from app.plex import rollup
from app.plex.cards import episode_card
from app.plex.query import (
FIELD_COLUMNS,
PLAYBACK_CLASSES,
TS_CONFIG,
LibraryFilters,
apply_meta_filters,
@@ -36,6 +38,8 @@ from app.plex.query import (
genres_col,
meta_filter_conds,
movie_status_case,
playable_conds,
playback_class_expr,
show_status_case,
split_csv,
summary_col,
@@ -135,6 +139,7 @@ def unified_library(
PlexItem.originally_available_at.label("rel"),
PlexItem.duration_s.label("duration_s"),
PlexItem.playable.label("playable"),
PlexItem.transcode_hw.label("transcode_hw"),
cast(null(), Integer).label("season_count"),
func.coalesce(st.position_seconds, 0).label("position_seconds"),
m_status.label("status"),
@@ -156,6 +161,8 @@ def unified_library(
mq = mq.where(PlexItem.duration_s >= f.duration_min)
if f.duration_max is not None:
mq = mq.where(PlexItem.duration_s <= f.duration_max)
# Playback-class filter (movie-only — a show is an aggregate with no single class).
mq = mq.where(*playable_conds(split_csv(f.playable)))
if tsq is not None:
mq = mq.where(vector_match(PlexItem, lang, tsq))
if recheck is not None:
@@ -179,6 +186,7 @@ def unified_library(
PlexShow.originally_available_at.label("rel"),
cast(null(), Integer).label("duration_s"),
cast(null(), String).label("playable"),
cast(null(), Boolean).label("transcode_hw"),
PlexShow.child_count.label("season_count"),
literal(0).label("position_seconds"),
s_status.label("status"),
@@ -237,6 +245,7 @@ def unified_library(
"duration_seconds": r.duration_s,
"thumb": f"/api/plex/image/{r.rk}",
"playable": r.playable,
"transcode_hw": r.transcode_hw,
"status": r.status,
"position_seconds": r.position_seconds,
}
@@ -312,6 +321,7 @@ def facets(
"genres": [],
"content_ratings": [],
"studios": [],
"playable": [],
"year_min": None,
"year_max": None,
"rating_max": None,
@@ -338,6 +348,9 @@ def facets(
query = query.where(model.duration_s >= pp["duration_min"])
if with_dur and pp.get("duration_max") is not None:
query = query.where(model.duration_s <= pp["duration_max"])
# Playback-class filter (movie-only) — applied for the OTHER groups so an active class
# narrows them; the playable facet itself passes pp with playable=None (self-exclusion).
query = query.where(*playable_conds(split_csv(pp.get("playable"))))
# Join watch-state and reuse the grid's exact movie state filter (unified_library) —
# hidden movies never appear in the grid, so they must not inflate facet counts/bounds either.
st = aliased(PlexState)
@@ -356,6 +369,7 @@ def facets(
genre_counts: dict[str, int] = {}
cr_counts: dict[str, int] = {}
studio_counts: dict[str, int] = {}
playable_counts: dict[str, int] = {}
years: list[int] = []
ratings: list[float] = []
durs: list[int] = []
@@ -420,6 +434,23 @@ def facets(
)
for s, c in db.execute(stq).all():
studio_counts[s] = studio_counts.get(s, 0) + c
# Playback class (movie-only) — exclude its own so each class stays an addable OR option.
if kind_movie:
pcls = playback_class_expr()
pcq = (
wsq(
select(pcls.label("cls"), func.count()),
model,
ids,
{**p, "playable": None},
kind_movie,
True,
)
.where(pcls.isnot(None))
.group_by(pcls)
)
for cls, c in db.execute(pcq).all():
playable_counts[cls] = playable_counts.get(cls, 0) + c
# Year bounds — exclude the year filter.
by = db.execute(
wsq(
@@ -477,6 +508,11 @@ def facets(
# Same for the actively-selected studios, so a selected studio stays removable even at zero matches.
for s in split_csv(f.studios):
studio_counts.setdefault(s, 0)
# Keep an actively-selected playback class visible even at zero matches (the sidebar hides the whole
# section when its facet list is empty, which would trap a zero-result selection with no way to undo).
for c in split_csv(f.playable):
if c in PLAYBACK_CLASSES:
playable_counts.setdefault(c, 0)
return {
"genres": [
{"value": g, "count": c} for g, c in sorted(genre_counts.items(), key=lambda kv: kv[0])
@@ -488,6 +524,12 @@ def facets(
{"value": s, "count": c}
for s, c in sorted(studio_counts.items(), key=lambda kv: (-kv[1], kv[0]))
],
# Fixed good→heavy order (not by count), so the class list reads as a severity scale.
"playable": [
{"value": c, "count": playable_counts[c]}
for c in PLAYBACK_CLASSES
if c in playable_counts
],
"year_min": min(years) if years else None,
"year_max": max(years) if years else None,
"rating_max": max(ratings) if ratings else None,
+4
View File
@@ -411,6 +411,10 @@ def item_detail(
"year": it.year,
"duration_seconds": it.duration_s,
"playable": it.playable,
# Playback-class detail: HW/SW transcode split + the codec/bit-depth the info-page chip shows.
"transcode_hw": it.transcode_hw,
"codec_video": it.codec_video,
"video_bit_depth": it.video_bit_depth,
"thumb": f"/api/plex/image/{it.rating_key}",
"art": f"/api/plex/image/{it.rating_key}?variant=art",
"library": _lib_key(db, it.library_id),
+12 -8
View File
@@ -532,15 +532,13 @@ def stream_session(
db: Session = Depends(get_db),
) -> dict:
"""Prepare playback from `start` seconds. Direct-playable files are served raw; remux files
(re)start an HLS session at the offset (seek-restart); transcode (HEVC/VP9) is P3. Selecting a
non-default audio track (`audio` = stream ordinal) forces the HLS path so the chosen audio can be
muxed in. Subtitles are independent WebVTT tracks (GET /subtitle) — no session restart."""
(re)start an HLS session at the offset (seek-restart, video stream-copied); transcode files (non-h264:
HEVC/VP9/AV1/…) take the same HLS path but with the video re-encoded to H.264 (VAAPI or libx264).
Selecting a non-default audio track (`audio` = stream ordinal) forces the HLS path so the chosen audio
can be muxed in. Subtitles are independent WebVTT tracks (GET /subtitle) — no session restart."""
plex_stream.reap_idle() # opportunistic cleanup of idle sessions on each new playback
it = item_or_404(db, rating_key)
if it.playable == "transcode":
raise HTTPException(
status_code=501, detail="This format needs transcoding (a later phase)."
)
transcode = it.playable == "transcode"
# `multi` (item has ≥2 audio tracks) forces the HLS path so ALL audio tracks ship as renditions and
# 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:
@@ -551,15 +549,21 @@ def stream_session(
}
if plex_paths.local_media_path(db, it.file_path) is None:
raise HTTPException(status_code=404, detail="Media file not found on disk")
s = plex_stream.start_session(db, it, start, audio, aoff, multi)
s = plex_stream.start_session(db, it, start, audio, aoff, multi, transcode=transcode)
if s is None:
raise HTTPException(status_code=404, detail="Media file not found on disk")
# 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.
# `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
# segment streams are otherwise identical.
return {
"mode": "hls",
"start_s": s.media_start_s,
"url": f"/api/plex/stream/{it.rating_key}/hls/{s.entry}",
"transcode": s.transcode,
"hw": s.hw,
"tonemap": s.tonemap,
}
+18 -2
View File
@@ -122,9 +122,25 @@ def watch_file(token: str, g: str | None = None, db: Session = Depends(get_db)):
raise HTTPException(status_code=404, detail="This video is no longer available.")
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
# allow-list (storage.served_media_type) — NEVER guessed from the file's own extension. A generic
# 'file' (and any media whose container we don't positively recognise, e.g. a media/HTML polyglot
# named "evil.html") returns None → a neutral force-download attachment, so arbitrary uploaded
# HTML/SVG can't execute inline on this login-free public origin. A recognised media container is
# served with its real audio/video type, inline unless the link forces a download so it can play
# in the watch page. Both are range-aware (FileResponse → 206) for seeking/resume.
media_type = storage.served_media_type(asset.media_kind, asset.container)
if media_type is None:
return FileResponse(
path,
filename=filename,
media_type="application/octet-stream",
content_disposition_type="attachment",
)
disposition = "attachment" if link.allow_download else "inline"
# Starlette's FileResponse honours the Range header (206) for seeking/scrubbing.
return FileResponse(path, filename=filename, content_disposition_type=disposition)
return FileResponse(
path, filename=filename, media_type=media_type, content_disposition_type=disposition
)
@router.get("/watch/{token}/poster.jpg")
+5 -1
View File
@@ -14,6 +14,7 @@ from sqlalchemy import select
from sqlalchemy.orm import Session
from app import email as email_mod
from app import mailtext
from app import state, sysconfig
from app.auth import PASSWORD_MIN_LEN
from app.db import get_db
@@ -103,6 +104,9 @@ def setup_config(settings_map: dict[str, Any], db: Session = Depends(require_set
class SetupTestEmailIn(BaseModel):
to: str = ""
# The wizard's page language. No account exists yet on a fresh instance, so this is the only
# thing that can say what language to write in; anything unsupported falls back to English.
lang: str | None = None
@router.post("/test-email")
@@ -111,7 +115,7 @@ def setup_test_email(body: SetupTestEmailIn, db: Session = Depends(require_setup
to = body.to.strip()
if "@" not in to:
raise HTTPException(status_code=400, detail="Enter a valid email address.")
if not email_mod.send_test(to):
if not email_mod.send_test(to, mailtext.normalize(body.lang)):
raise HTTPException(status_code=400, detail="Couldn't send — check the SMTP settings.")
return {"ok": True}
+12
View File
@@ -127,6 +127,10 @@ SPECS: tuple[ConfigSpec, ...] = (
min=1,
max=100_000,
),
# min=1, NOT 0: the per-file cap is enforced as `if cap and size > cap`, so a stored 0 would read
# as falsy and SILENTLY disable the limit (an unbounded streamed write to the volume). Floor it at
# 1 so the cap is always active; the overall footprint is separately bounded by the byte quota.
ConfigSpec("download_upload_max_bytes", "int", "downloads", "download_upload_max_bytes", min=1),
ConfigSpec(
"download_retention_days", "int", "downloads", "download_retention_days", min=0, max=3_650
),
@@ -155,6 +159,14 @@ SPECS: tuple[ConfigSpec, ...] = (
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.)
ConfigSpec("plex_max_transcodes", "int", "plex", "plex_max_transcodes", min=0, max=16),
# Transcode re-encode tuning (E6 S1). hw = "auto"|"on"|"off" (free-text, like search_source); the
# libx264 knobs apply only to the software fallback. crf 0–51 (x264 range); max_height 0 = source.
ConfigSpec("plex_transcode_hw", "str", "plex", "plex_transcode_hw"),
ConfigSpec("plex_transcode_preset", "str", "plex", "plex_transcode_preset"),
ConfigSpec("plex_transcode_crf", "int", "plex", "plex_transcode_crf", min=0, max=51),
ConfigSpec(
"plex_transcode_max_height", "int", "plex", "plex_transcode_max_height", min=0, max=4320
),
# NOTE: plex_watch_sync_interval_min is a config.py default read by the watch-sync scheduler job
# (scheduler.py); it is intentionally NOT a runtime admin knob, so it stays out of SPECS.
# --- TMDB enrichment (E5 S2; optional) — the API key is the only secret ---
+33
View File
@@ -22,6 +22,39 @@ def valid_email(value: str | None) -> bool:
return bool(value and _EMAIL_RE.match(value))
# Gmail (and its googlemail.com alias) delivers to one mailbox regardless of dots in the local part
# and of any `+tag`; nobody else is guaranteed to. So the canonical form below strips those for
# Gmail only, and merely lowercases everything else.
_GMAIL_DOMAINS = {"gmail.com", "googlemail.com"}
def canonical_email(value: str | None) -> str:
"""The form used to decide whether two addresses are the SAME MAILBOX. Never used for sending —
mail always goes to the address as the user typed it.
Why this exists (observed on prod, 2026-09-03): someone registered as `npeter83.temu@gmail.com`
and was approved, but Google's OAuth `email` claim came back as the account's stored primary,
`npeter83temu@gmail.com`. The invite gate only lowercased, found no approved row for that literal
string, and turned an approved user's sign-in into a silent SECOND access request — plus a second
User row. The `+tag` case is worse and fails every time: a Google account cannot contain `+`, so
OAuth can only ever return the base address, meaning anyone who registers with
`user+siftlode@gmail.com` (a common throwaway habit) is guaranteed to be denied after approval.
Scope is deliberately narrow. The dot rule does NOT hold for Google Workspace custom domains
unless the admin turned it on, and dots are significant on most providers, so anything outside
gmail.com/googlemail.com is only lowercased. Approving one Gmail spelling always did mean
approving that mailbox, so collapsing them is the honest reading of an invite, not a widening
of it.
"""
email = (value or "").strip().lower()
local, sep, domain = email.partition("@")
if not sep or domain not in _GMAIL_DOMAINS:
return email
local = local.partition("+")[0].replace(".", "")
# Normalise the alias domain too, so googlemail and gmail spellings meet.
return f"{local}@gmail.com" if local else email
def now_utc() -> datetime:
"""Current time as a timezone-aware UTC datetime."""
return datetime.now(timezone.utc)
+1
View File
@@ -43,6 +43,7 @@ pydantic-settings==2.15.0
pydantic==2.13.4
pydantic_core==2.46.4
python-dotenv==1.2.3
python-multipart==0.0.32
requests==2.34.2
starlette==1.6.0
typing-inspection==0.4.4
+3
View File
@@ -1,4 +1,7 @@
fastapi>=0.115,<1.0
# Multipart form parsing — required by Starlette/FastAPI to accept file uploads (UploadFile/Form).
# The download center's local-file Upload (E7) is the only multipart endpoint in the app.
python-multipart>=0.0.9,<1.0
uvicorn[standard]>=0.30,<1.0
sqlalchemy>=2.0,<2.1
psycopg[binary]>=3.2,<4.0
+21
View File
@@ -189,3 +189,24 @@ def as_user(db, override_dep, _configured):
return TestClient(app, headers={"x-test-user": str(user.id)})
return _as
@pytest.fixture(autouse=True)
def _reset_rate_limiters():
"""Every RateLimiter is a module-level singleton keyed by client IP, and TestClient always
presents the same host — so without this the whole suite's login/register attempts pool into one
300s window and, past the cap, some unrelated test starts failing with a 429 depending on order.
Cleared before each test; a test that wants to exercise throttling can still exhaust one itself.
Sweeps every loaded `app.*` module rather than a hand-kept list, so a limiter added in a new
module is covered the day it lands instead of silently pooling until something fails."""
import sys
from app.ratelimit import RateLimiter
for name, mod in list(sys.modules.items()):
if name != "app" and not name.startswith("app."):
continue
for obj in vars(mod).values():
if isinstance(obj, RateLimiter):
obj.reset()
+170
View File
@@ -0,0 +1,170 @@
"""Deleting a whitelist / access-request entry (admin).
Deny records an auditable decision and keeps the row; delete forgets the address outright, for an
approval added by mistake or one that is no longer wanted. Neither stops the person asking again —
`upsert_pending_invite` reopens a denied row as pending, and a deleted one comes back as a fresh
request. The one hard rule is that an entry backed by an account is NOT deletable here: this row is
that account's approval gate, and orphaning it would strand someone who could then neither sign in
nor be approved.
"""
from sqlalchemy import select
from app.models import Invite, User
def _invites(client):
r = client.get("/api/admin/invites")
assert r.status_code == 200
return {i["email"]: i for i in r.json()}
def test_admin_deletes_a_whitelist_entry_that_never_became_an_account(db, make_user, as_user):
admin = make_user(email="admin@example.com", role="admin")
db.add(Invite(email="mistake@example.com", status="approved", decided_by=admin.email))
db.commit()
client = as_user(admin)
listed = _invites(client)["mistake@example.com"]
assert listed["has_account"] is False # drives the delete affordance in the UI
r = client.delete(f"/api/admin/invites/{listed['id']}")
assert r.status_code == 200 and r.json()["deleted"] == listed["id"]
db.expire_all()
assert (
db.execute(select(Invite).where(Invite.email == "mistake@example.com")).scalar_one_or_none()
is None
)
def test_deleting_an_entry_that_has_an_account_is_refused(db, make_user, as_user):
# The guard that matters: this row is what is_allowed() reads, so deleting it would revoke the
# user's access without touching the account or saying so.
admin = make_user(email="admin@example.com", role="admin")
member = make_user(email="member@example.com")
db.add(Invite(email=member.email, status="approved", decided_by=admin.email))
db.commit()
client = as_user(admin)
listed = _invites(client)[member.email]
assert listed["has_account"] is True
r = client.delete(f"/api/admin/invites/{listed['id']}")
assert r.status_code == 409
db.expire_all()
assert (
db.execute(select(Invite).where(Invite.email == member.email)).scalar_one_or_none()
is not None
)
def test_a_pending_request_from_a_registration_is_not_deletable(db, make_user, as_user):
# The shape the ordinary sign-up produces: _register_account writes the User (inactive) AND the
# pending Invite together. The invite is that account's approval gate, so deleting it would
# strand the person — they could neither sign in nor be approved, and re-registering is a silent
# no-op. Managed from Users & roles instead, which is what the UI and the 409 both say.
admin = make_user(email="admin@example.com", role="admin")
make_user(email="signup@example.com", is_active=False, email_verified=False)
db.add(Invite(email="signup@example.com", status="pending"))
db.commit()
client = as_user(admin)
listed = _invites(client)["signup@example.com"]
assert listed["has_account"] is True # the UI shows a disabled control, not a missing one
r = client.delete(f"/api/admin/invites/{listed['id']}")
assert r.status_code == 409
db.expire_all()
assert (
db.execute(select(Invite).where(Invite.email == "signup@example.com")).scalar_one_or_none()
is not None
)
def test_deleting_an_env_allow_listed_address_reports_that_it_kept_access(
db, make_user, as_user, monkeypatch
):
# Removing the row is NOT always a revoke: is_allowed falls back to the env bootstrap lists once
# no invite exists. The admin has to be told, or they think they removed access and did not.
from app.config import settings
# Patch the raw env field, not the derived property: that keeps the split/strip/lower parsing
# under test, and matches how the rest of the suite stubs settings.
monkeypatch.setattr(settings, "allowed_emails", " OPS@Example.com , someone@else.test ")
admin = make_user(email="admin@example.com", role="admin")
db.add(Invite(email="ops@example.com", status="approved", decided_by=admin.email))
db.commit()
client = as_user(admin)
listed = _invites(client)["ops@example.com"]
# The LIST carries it too, so the confirm dialog can warn before the delete happens.
assert listed["still_allowed_by_env"] is True
r = client.delete(f"/api/admin/invites/{listed['id']}")
assert r.status_code == 200
assert r.json()["still_allowed_by_env"] is True
def test_an_admin_emails_address_is_also_reported(db, make_user, as_user, monkeypatch):
# ADMIN_EMAILS is the sharper case: deleting the row not only keeps access, the next Google
# sign-in re-promotes the address to admin.
from app.config import settings
monkeypatch.setattr(settings, "admin_emails", "boss@example.com")
admin = make_user(email="admin@example.com", role="admin")
db.add(Invite(email="boss@example.com", status="approved", decided_by=admin.email))
db.commit()
client = as_user(admin)
assert _invites(client)["boss@example.com"]["still_allowed_by_env"] is True
def test_deleting_a_pending_request_forgets_it(db, make_user, as_user):
# A pending row is deletable too — "drop this without recording a decision" is a different
# choice from Deny, which stores a decision (status, decided_by) and mails the requester.
admin = make_user(email="admin@example.com", role="admin")
db.add(Invite(email="asker@example.com", status="pending"))
db.commit()
client = as_user(admin)
r = client.delete(f"/api/admin/invites/{_invites(client)['asker@example.com']['id']}")
assert r.status_code == 200
assert r.json()["still_allowed_by_env"] is False # nothing in the env allow-lists
assert "asker@example.com" not in _invites(client)
def test_deleting_a_missing_invite_is_404(db, make_user, as_user):
admin = make_user(email="admin@example.com", role="admin")
assert as_user(admin).delete("/api/admin/invites/9999").status_code == 404
def test_a_regular_user_cannot_delete_an_invite(db, make_user, as_user):
make_user(email="admin@example.com", role="admin")
plain = make_user(email="plain@example.com")
db.add(Invite(email="mistake@example.com", status="approved"))
db.commit()
inv_id = db.execute(select(Invite.id).where(Invite.email == "mistake@example.com")).scalar_one()
assert as_user(plain).delete(f"/api/admin/invites/{inv_id}").status_code == 403
db.expire_all()
assert db.get(Invite, inv_id) is not None
def test_deleting_the_user_also_clears_the_way_to_re_invite(db, make_user, as_user):
# The refusal above tells the admin to delete the account instead; purge_user takes the invite
# with it, so the address really is forgotten and a fresh request starts over as pending.
admin = make_user(email="admin@example.com", role="admin")
member = make_user(email="member@example.com")
db.add(Invite(email=member.email, status="approved", decided_by=admin.email))
db.commit()
client = as_user(admin)
assert client.delete(f"/api/admin/users/{member.id}").status_code == 200
db.expire_all()
assert db.execute(select(User).where(User.email == member.email)).scalar_one_or_none() is None
assert (
db.execute(select(Invite).where(Invite.email == member.email)).scalar_one_or_none() is None
)
+203
View File
@@ -0,0 +1,203 @@
"""The admin lifeline for a sign-up that is stuck outside (E9.5 S5, roadmap UX-25), DB-lane.
Approve an address BEFORE the person registers and their sign-up produces no queue row at all —
`_decide` only takes a still-pending invite — so the Access-requests screen stayed empty while they
sat locked out, and the badge read zero. The only working move was re-typing the address into the
whitelist box, which nobody would guess.
Two earlier attempts at this were reverted after review, and both moved AUTHORITY rather than
visibility: auto-activating on registration, and reopening the approved invite to pending. So the
tests here pin the boundary as much as the feature — an admin click, gated on an address that is
already allowed, and nothing an unauthenticated caller can reach.
"""
from sqlalchemy import select
from app.models import AuditLog, AuthToken, Invite, User
_PW = "correct horse battery"
def _pending_access(client):
r = client.get("/api/admin/pending-access")
assert r.status_code == 200
return {u["email"]: u for u in r.json()}
def _stuck_registrant(db, make_user, admin):
"""The UX-25 shape: the admin approved the address first, the account arrived afterwards."""
db.add(Invite(email="later@example.com", status="approved", decided_by=admin.email))
db.commit()
return make_user(email="later@example.com", password=_PW, is_active=False)
def test_a_pre_approved_registrant_is_listed_and_offers_activation(db, make_user, as_user):
admin = make_user(email="admin@example.com", role="admin")
_stuck_registrant(db, make_user, admin)
row = _pending_access(as_user(admin))["later@example.com"]
assert row["is_active"] is False
assert (
row["can_activate"] is True
) # the address is already allowed — the click just finishes it
def test_an_ordinary_registrant_awaiting_a_decision_is_not_listed_twice(db, make_user, as_user):
# They already have a pending invite with Approve on it in the queue above, and their address is
# not allowed YET — the decision is the queue's to own. Caught in UAT on 2026-09-05: an earlier
# filter keyed on "unconfirmed" put every fresh registration in both lists at once.
admin = make_user(email="admin@example.com", role="admin")
db.add(Invite(email="normal@example.com", status="pending"))
db.commit()
make_user(email="normal@example.com", password=_PW, is_active=False)
assert "normal@example.com" not in _pending_access(as_user(admin))
def test_a_fresh_registration_stays_in_the_queue_and_out_of_this_list(db, make_user, as_user):
# The realistic version of the case above: a brand-new sign-up is inactive AND unconfirmed. It
# still belongs only to the queue — the admin's next act is the decision, not a re-send.
admin = make_user(email="admin@example.com", role="admin")
db.add(Invite(email="fresh@example.com", status="pending"))
db.commit()
make_user(email="fresh@example.com", password=_PW, is_active=False, email_verified=False)
assert "fresh@example.com" not in _pending_access(as_user(admin))
def test_activating_lets_them_in_and_is_recorded(db, make_user, as_user):
admin = make_user(email="admin@example.com", role="admin")
stuck = _stuck_registrant(db, make_user, admin)
r = as_user(admin).post(f"/api/admin/users/{stuck.id}/activate")
assert r.status_code == 200 and r.json()["is_active"] is True
db.expire_all()
assert db.get(User, stuck.id).is_active is True
actions = db.execute(select(AuditLog.action)).scalars().all()
assert (
"user_activate" in actions
) # the admin acted on someone's access; it is not a silent read
def test_activating_an_address_that_is_not_allowed_is_refused(db, make_user, as_user):
# The guard that keeps this button from manufacturing an inconsistent account: password sign-in
# only checks is_active and would let them in, while their Google sign-in would be turned away
# and silently reopened as a fresh access request. Whitelisting is the move that says "let them
# in"; this one only finishes a decision already made.
admin = make_user(email="admin@example.com", role="admin")
nobody = make_user(email="nobody@example.com", password=_PW, is_active=False)
r = as_user(admin).post(f"/api/admin/users/{nobody.id}/activate")
assert r.status_code == 409
assert r.json()["detail"]["reason"] == "not_allowed"
db.expire_all()
assert db.get(User, nobody.id).is_active is False
def test_activating_twice_is_not_an_error(db, make_user, as_user):
admin = make_user(email="admin@example.com", role="admin")
stuck = _stuck_registrant(db, make_user, admin)
client = as_user(admin)
assert client.post(f"/api/admin/users/{stuck.id}/activate").status_code == 200
assert client.post(f"/api/admin/users/{stuck.id}/activate").status_code == 200
def test_a_suspended_account_is_not_offered_as_stuck(db, make_user, as_user):
# It cannot sign in either, but that is a decision already made — not a person waiting on one.
admin = make_user(email="admin@example.com", role="admin")
db.add(Invite(email="banned@example.com", status="approved", decided_by=admin.email))
db.commit()
make_user(email="banned@example.com", password=_PW, is_active=False, is_suspended=True)
assert "banned@example.com" not in _pending_access(as_user(admin))
# --- the confirmation resend ---------------------------------------------------------------------
def test_an_unconfirmed_account_is_listed_and_can_be_sent_a_fresh_link(
db, make_user, as_user, monkeypatch
):
from app.routes import admin as admin_routes
from app.routes import auth as auth_routes
monkeypatch.setattr(admin_routes.email_mod, "email_enabled", lambda db=None: True)
sent: list[tuple[str, str]] = []
monkeypatch.setattr(
auth_routes.email_mod,
"send_verify_email",
lambda to, lang, link, **kw: sent.append((to, link)) is None,
)
admin = make_user(email="admin@example.com", role="admin")
# Active but unconfirmed — a real state since E9.5 S1 made the two gates order-independent. It is
# not the admin's to decide, but the mail may simply never have arrived, so it is theirs to help
# with. The approved invite is what puts them on this screen at all: the list is "the address may
# sign in, this account still can't", so an undecided request stays with the queue that owns it.
db.add(Invite(email="unconfirmed@example.com", status="approved", decided_by=admin.email))
db.commit()
u = make_user(email="unconfirmed@example.com", password=_PW, email_verified=False)
assert "unconfirmed@example.com" in _pending_access(as_user(admin))
r = as_user(admin).post(f"/api/admin/users/{u.id}/resend-verification")
assert r.status_code == 200 and r.json()["email"] == "unconfirmed@example.com"
assert [to for to, _ in sent] == ["unconfirmed@example.com"]
tokens = db.execute(select(AuthToken).where(AuthToken.user_id == u.id)).scalars().all()
assert [t.kind for t in tokens] == ["verify"]
def test_the_resend_says_so_when_the_instance_cannot_send_mail(db, make_user, as_user, monkeypatch):
from app.routes import admin as admin_routes
monkeypatch.setattr(admin_routes.email_mod, "email_enabled", lambda db=None: False)
admin = make_user(email="admin@example.com", role="admin")
u = make_user(email="unconfirmed@example.com", password=_PW, email_verified=False)
r = as_user(admin).post(f"/api/admin/users/{u.id}/resend-verification")
# Mail is fail-soft everywhere else, which is exactly what left the 0.78.5 sign-ups with nothing
# to fall back on. An admin pressing a button gets the failure, not a success toast.
assert r.status_code == 409 and r.json()["detail"]["reason"] == "email_off"
def test_resending_to_an_already_confirmed_account_is_refused(db, make_user, as_user):
admin = make_user(email="admin@example.com", role="admin")
u = make_user(email="fine@example.com", password=_PW)
r = as_user(admin).post(f"/api/admin/users/{u.id}/resend-verification")
assert r.status_code == 409 and r.json()["detail"]["reason"] == "not_applicable"
# --- the badge -----------------------------------------------------------------------------------
def test_the_badge_counts_stuck_sign_ups_but_not_ones_the_queue_already_shows(
db, make_user, client
):
# Signed in for real, not via the as_user override: /api/me resolves its user through
# optional_current_user, which that override does not cover (see test_me_email_flag).
admin = make_user(email="admin@example.com", password=_PW, role="admin")
_stuck_registrant(db, make_user, admin) # approved first → counts
db.add(Invite(email="normal@example.com", status="pending")) # already in the queue → doesn't
db.commit()
make_user(email="normal@example.com", password=_PW, is_active=False)
make_user(email="unconfirmed@example.com", password=_PW, email_verified=False) # not ours
assert (
client.post(
"/auth/password-login", json={"email": admin.email, "password": _PW}
).status_code
== 200
)
me = client.get("/api/me").json()
assert me["authenticated"] is True
# An unconfirmed account waits on its OWNER, not on the admin — listing it is help, badging it
# would be nagging about something the admin did not cause.
assert me["pending_activations"] == 1
assert me["pending_invites"] == 1 # unchanged meaning: still just the undecided requests
+442 -14
View File
@@ -97,28 +97,85 @@ def test_reset_confirm_is_single_use_and_kills_existing_sessions(client, make_us
assert replay.status_code == 400
def test_activating_an_unverified_account_drops_its_preset_password(db, make_user):
# SEC-10 (account pre-emption): an attacker registers victim@x with a password before the approved
# owner arrives. The address was never verified, so admin approval must NOT hand that pre-set
# password a working login — it is dropped and any session it minted is revoked.
def test_approving_an_unverified_account_keeps_its_password(db, make_user):
# E9.5 S1, inverting an earlier rule. Approval used to drop a password set while the email was
# unverified (SEC-10, account pre-emption). It was dropped because it destroyed the password a
# legitimate registrant had just chosen, in what is the NORMAL ordering — the admin decides
# before the user reads their mail — making "your password was cleared for security" the default
# experience.
#
# S1 ALSO argued the revoke was redundant, because password_login refuses an unverified account
# before it reads is_active. The epic's closing review refuted that: the gate holds only while
# the address is unverified, and S6's approval mail exists to get the real owner to end that
# state. Do not restore that reasoning from this comment — the residual is SEC-16, and round 2
# of the review tried and reverted a guard for it (see test_review_round.py).
from app.routes.admin import _activate_user_by_email
victim = make_user(
email="victim@example.com",
password="attacker-chosen",
user = make_user(
email="approved-first@example.com",
password="chosen at registration",
email_verified=False,
is_active=False,
)
epoch0 = victim.session_epoch or 0
_activate_user_by_email(db, "victim@example.com")
assert victim.is_active is True
assert victim.password_hash is None
assert (victim.session_epoch or 0) == epoch0 + 1
epoch0 = user.session_epoch or 0
_activate_user_by_email(db, "approved-first@example.com")
assert user.is_active is True
assert user.password_hash is not None
assert user.password_revoked_at is None
assert (user.session_epoch or 0) == epoch0 # nothing to revoke, so no epoch bump either
def test_an_approved_but_unverified_account_still_cannot_sign_in(client, make_user):
# The gate that stops a pre-emptor BEFORE the address is confirmed, whatever the admin decided.
# It is not the whole story — confirming the address opens it, which is SEC-16 — but it is the
# invariant this test owns, and a change that let an unverified account sign in would be a
# straightforwardly worse bug than the residual.
make_user(
email="notyet@example.com",
password="attacker-chosen",
email_verified=False,
is_active=True,
)
r = _login(client, "notyet@example.com", "attacker-chosen")
assert r.status_code == 403
assert r.json()["detail"]["reason"] == "email_unverified"
def test_verifying_afterwards_lets_the_original_password_in(client, db, make_user):
# The payoff for the legitimate registrant: the password chosen at registration still works once
# the mailbox is proven — no "your password was cleared for security" detour, no reset round-trip.
from app.routes.auth import _issue_token
user = make_user(
email="ordering@example.com",
password="chosen at registration",
email_verified=False,
is_active=False,
)
from app.routes.admin import _activate_user_by_email
_activate_user_by_email(db, "ordering@example.com") # admin decides FIRST — the common ordering
raw = _issue_token(db, user, "verify", timedelta(hours=1))
db.commit()
verified = client.post("/auth/verify", json={"token": raw})
assert verified.status_code == 200
assert verified.json() == {
"ok": True,
"password_reset_required": False,
"email": "ordering@example.com",
}
assert _login(client, "ordering@example.com", "chosen at registration").status_code == 200
def test_activating_a_verified_account_keeps_its_password(db, make_user):
# A legit account whose email IS verified (e.g. a no-SMTP registration) keeps its password on
# approval — the guard must not break the normal flow.
# The no-SMTP shape: registration marks such an account verified up front, so it was never in
# scope for the SEC-10 guard even before E9.5 S1. Kept as the other half of the pair.
from app.routes.admin import _activate_user_by_email
user = make_user(
@@ -127,3 +184,374 @@ def test_activating_a_verified_account_keeps_its_password(db, make_user):
_activate_user_by_email(db, "legit@example.com")
assert user.is_active is True
assert user.password_hash is not None
def _register_and_fetch(db, email: str):
"""Run the /register background worker, then read the users/invites rows back through a FRESH
query. The worker owns its own session, so anything it failed to commit is simply not there."""
from sqlalchemy import select as _select
from app.models import Invite, User
from app.routes.auth import _register_account
_register_account(email, _PW)
db.expire_all()
user = db.execute(_select(User).where(User.email == email)).scalar_one_or_none()
invite = db.execute(_select(Invite).where(Invite.email == email)).scalar_one_or_none()
return user, invite
def _no_smtp(monkeypatch):
"""Put the worker on the no-SMTP path and capture the admin notice instead of sending it."""
from app.routes import auth as auth_mod
notified: list[str] = []
monkeypatch.setattr(auth_mod.email_mod, "email_enabled", lambda: False)
monkeypatch.setattr(
auth_mod.email_mod,
"send_admin_new_request",
lambda admins, lang, to: notified.append(to) or True,
)
return notified
def test_registration_of_a_pre_approved_email_persists_the_account_without_smtp(
db, make_user, monkeypatch
):
# THE regression. An admin whitelists an address, the person registers, and the account silently
# never existed: upsert_pending_invite leaves an already-approved row alone and returns WITHOUT
# committing, and the only other commit on this path (_issue_token) is gated on SMTP — so with no
# working mail the flushed user was rolled back at session close. The sign-up answered "ok",
# created nothing, and re-registering did the same again.
from app.models import Invite
notified = _no_smtp(monkeypatch)
make_user(email="admin@example.com", role="admin")
db.add(Invite(email="roland@example.com", status="approved", decided_by="admin@example.com"))
db.commit()
user, invite = _register_and_fetch(db, "roland@example.com")
assert user is not None, "pre-approved registration was rolled back — the account is gone"
assert user.password_hash is not None
assert user.email_verified is True # no SMTP => ownership can't be proven by link
assert (
user.is_active is False
) # approval is bound to the account by an admin, not by signing up
# The admin's standing decision is left exactly as it was — is_allowed() still reads "approved",
# so their Google sign-in keeps working and an anonymous sign-up cannot revoke access.
assert invite.status == "approved"
assert invite.decided_by == "admin@example.com"
# The notify path still fires for an already-approved address — nothing lands in the queue, so
# it is the only announcement there is. (Whether it is DELIVERED is separate: on a real no-SMTP
# instance `_send` drops it, which is the open half of the problem, roadmap UX-25.)
assert notified == ["roland@example.com"]
def test_registration_with_an_already_pending_request_persists_the_account(
db, make_user, monkeypatch
):
# The same rollback, reached the other way: someone uses Request access, then registers before
# the admin has decided. upsert_pending_invite leaves the pending row alone and does not commit.
from app.models import Invite
notified = _no_smtp(monkeypatch)
make_user(email="admin@example.com", role="admin")
db.add(Invite(email="pia@example.com", status="pending"))
db.commit()
user, invite = _register_and_fetch(db, "pia@example.com")
assert user is not None, "registration was rolled back — the account is gone"
assert user.is_active is False
assert invite.status == "pending"
assert notified == ["pia@example.com"]
def test_an_admin_can_activate_a_pre_approved_registrant(db, monkeypatch):
# The recovery path for the account above, pinned so it cannot quietly break: re-entering the
# address in the Access-requests whitelist box (POST /api/admin/invites) runs
# _activate_user_by_email.
from app.models import Invite
from app.routes.admin import _activate_user_by_email
_no_smtp(monkeypatch)
db.add(Invite(email="roland@example.com", status="approved", decided_by="admin@example.com"))
db.commit()
user, _ = _register_and_fetch(db, "roland@example.com")
_activate_user_by_email(db, "roland@example.com")
db.expire_all()
assert user.is_active is True
assert user.password_hash is not None # approval never touches the password (E9.5 S1)
def test_registration_of_an_unknown_email_still_persists_account_and_pending_invite(
db, make_user, monkeypatch
):
# Guard, not a regression test: this branch always worked (upsert INSERTs the invite and commits
# the same transaction). It must keep working — and it is the case that DOES notify the admin.
notified = _no_smtp(monkeypatch)
make_user(email="admin@example.com", role="admin")
user, invite = _register_and_fetch(db, "stranger@example.com")
assert user is not None
assert user.is_active is False
assert invite is not None and invite.status == "pending"
assert notified == ["stranger@example.com"]
def test_registration_of_a_previously_denied_email_revives_the_request(db, make_user, monkeypatch):
# Guard: a denied person may ask again — the invite flips back to pending (and commits) so the
# admin sees a fresh request. The stale decision must not leak into the new account's state.
from app.models import Invite
notified = _no_smtp(monkeypatch)
make_user(email="admin@example.com", role="admin")
db.add(Invite(email="denis@example.com", status="denied", decided_by="admin@example.com"))
db.commit()
user, invite = _register_and_fetch(db, "denis@example.com")
assert user is not None
assert user.is_active is False
assert invite.status == "pending"
assert invite.decided_at is None and invite.decided_by is None
assert notified == ["denis@example.com"]
def test_registration_of_a_pre_approved_email_persists_the_account_with_smtp(db, monkeypatch):
# Guard, not a regression test: with SMTP on, _issue_token's own commit already persisted the
# user, so this branch was never broken and must stay unbroken.
from app.models import Invite
from app.routes import auth as auth_mod
sent: list[str] = []
monkeypatch.setattr(auth_mod.email_mod, "email_enabled", lambda: True)
monkeypatch.setattr(
auth_mod.email_mod, "send_verify_email", lambda to, lang, link, **kw: sent.append(to)
)
monkeypatch.setattr(auth_mod.email_mod, "send_admin_new_request", lambda admins, lang, to: True)
db.add(Invite(email="rita@example.com", status="approved", decided_by="admin@example.com"))
db.commit()
user, invite = _register_and_fetch(db, "rita@example.com")
assert user is not None
assert user.email_verified is False # must confirm the address
assert user.is_active is False
assert invite.status == "approved"
assert sent == ["rita@example.com"]
def test_verifying_an_email_never_grants_access_on_its_own(client, db, make_user):
# Verification attests the address; it does not decide access. The two gates stay separate.
from app.models import Invite
from app.routes.auth import _issue_token
user = make_user(email="rita@example.com", password=_PW, email_verified=False, is_active=False)
db.add(Invite(email="rita@example.com", status="approved", decided_by="admin@example.com"))
db.commit()
raw = _issue_token(db, user, "verify", timedelta(hours=1))
assert client.post("/auth/verify", json={"token": raw}).status_code == 200
db.expire_all()
assert user.email_verified is True
assert user.is_active is False
assert _login(client, "rita@example.com", _PW).status_code == 403
def test_register_route_creates_the_account_end_to_end(client, db, monkeypatch):
# The tests above call _register_account directly, so nothing covered the ROUTE: its
# allow_registration gate, its rate limiter and the background.add_task wiring that makes the
# whole flow reachable at all. TestClient runs BackgroundTasks after the response, so a single
# POST exercises the real path end to end.
from sqlalchemy import select as _select
from app.models import User
_no_smtp(monkeypatch)
r = client.post("/auth/register", json={"email": "newbie@example.com", "password": _PW})
assert r.status_code == 200 and r.json() == {"status": "ok"}
db.expire_all()
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.password_hash is not None
def test_register_route_is_refused_when_registration_is_closed(client, db, monkeypatch):
# The gate is an explicit 403 with nothing created — not a uniform "ok" that silently drops it.
from sqlalchemy import select as _select
from app import sysconfig
from app.models import User
_no_smtp(monkeypatch)
sysconfig.set_value(db, "allow_registration", False)
r = client.post("/auth/register", json={"email": "closed@example.com", "password": _PW})
assert r.status_code == 403
db.expire_all()
assert (
db.execute(_select(User).where(User.email == "closed@example.com")).scalar_one_or_none()
is None
)
# --- SEC-10 lockout: a revoked preset password must stay RECOVERABLE ---------------------------
#
# Regression pack for the prod incident of 2026-09-02. An account registered with a password, then
# stripped of it by the SEC-10 guard, ended up with password_hash = NULL. That is also what a
# Google-only account looks like, and reset eligibility keyed off `password_hash` alone — so the
# account could neither sign in (no password) nor obtain a reset link, and every response on the way
# was a deliberately uniform "ok"/401. `password_revoked_at` is the trace that tells the two apart;
# these pin each step of the way back in.
#
# E9.5 S1 removed the admin-approval caller, so Google adoption is the only path that still produces
# this state — plus every account stripped on prod before that change, which is why the whole recovery
# route below must keep working. These tests manufacture the state through the surviving producer.
def _revoke_as_google_adoption(db, user) -> None:
"""Put an account into the SEC-10-revoked state the way the ONLY remaining producer does: the
Google callback adopting an account whose email was never verified."""
from app.routes.auth import revoke_preset_password_if_unverified
revoke_preset_password_if_unverified(user)
db.commit()
def _capture_reset_links(monkeypatch) -> list[str]:
"""Patch the reset mailer and collect the recipients it is asked to write to."""
from app.routes import auth as auth_mod
sent: list[str] = []
monkeypatch.setattr(
auth_mod.email_mod, "send_password_reset", lambda to, lang, link: sent.append(to)
)
return sent
def test_the_google_adoption_revoke_stamps_password_revoked_at(db, make_user):
# The guard still fires where it earns its keep: the real owner has just proven ownership through
# Google, so a password set on the address while it was unverified must not survive.
victim = make_user(
email="stamped@example.com",
password="attacker-chosen",
email_verified=False,
is_active=False,
)
assert victim.password_revoked_at is None
epoch0 = victim.session_epoch or 0
_revoke_as_google_adoption(db, victim)
assert victim.password_hash is None
assert victim.password_revoked_at is not None # …so the reset flow can tell it HAD a password
assert (victim.session_epoch or 0) == epoch0 + 1 # any session that password minted is killed
def test_an_account_whose_password_was_revoked_can_still_get_a_reset_link(
db, make_user, monkeypatch
):
# THE lockout bug: without the stamp this sent nothing, and the account was unrecoverable.
from app.routes.auth import _send_reset_if_eligible
victim = make_user(
email="lockedout@example.com",
password="chosen at registration",
email_verified=False,
is_active=False,
)
_revoke_as_google_adoption(db, victim)
sent = _capture_reset_links(monkeypatch)
_send_reset_if_eligible("lockedout@example.com")
assert sent == ["lockedout@example.com"]
def test_a_google_only_account_is_still_not_reset_eligible(db, make_user, monkeypatch):
# The other side of the rule: an account that NEVER had a password gets no reset link — its owner
# adds one from Settings while signed in (/auth/set-password), which proves identity by session.
from app.routes.auth import _send_reset_if_eligible
make_user(email="googler@example.com", password=None, google_sub="sub-123")
sent = _capture_reset_links(monkeypatch)
_send_reset_if_eligible("googler@example.com")
assert sent == []
def test_completing_a_reset_clears_the_revocation_stamp(client, db, make_user):
from app.routes.auth import _issue_token
user = make_user(
email="recovered@example.com",
password="chosen at registration",
email_verified=False,
is_active=True, # already approved; reset-confirm verifies the email but never activates
)
_revoke_as_google_adoption(db, user)
raw = _issue_token(db, user, "reset", timedelta(hours=1))
db.commit()
ok = client.post(
"/auth/password-reset/confirm", json={"token": raw, "password": "a brand new one"}
)
assert ok.status_code == 200
db.expire_all()
assert user.password_hash is not None
assert user.password_revoked_at is None # spent — not a permanently reset-eligible account
assert _login(client, "recovered@example.com", "a brand new one").status_code == 200
def test_verify_tells_a_revoked_account_that_it_needs_a_new_password(client, db, make_user):
# The banner's input. Saying so leaks nothing: the caller just proved mailbox control by holding a
# valid single-use verify token. Without it the user meets only the uniform "Invalid email or
# password" and has no way to learn why the password they chose stopped working.
from app.routes.auth import _issue_token
user = make_user(
email="needspw@example.com",
password="chosen at registration",
email_verified=False,
is_active=False,
)
_revoke_as_google_adoption(db, user)
raw = _issue_token(db, user, "verify", timedelta(hours=1))
db.commit()
r = client.post("/auth/verify", json={"token": raw})
assert r.status_code == 200
assert r.json() == {
"ok": True,
"password_reset_required": True,
"email": "needspw@example.com",
}
def test_verify_reports_no_reset_needed_for_an_intact_account(client, db, make_user):
from app.routes.auth import _issue_token
user = make_user(email="intact@example.com", email_verified=False, is_active=False)
raw = _issue_token(db, user, "verify", timedelta(hours=1))
db.commit()
r = client.post("/auth/verify", json={"token": raw})
assert r.status_code == 200
assert r.json() == {
"ok": True,
"password_reset_required": False,
"email": "intact@example.com",
}
+58 -21
View File
@@ -6,7 +6,7 @@ with "PosixPath('.') has an empty name" instead of downloading."""
import pytest
from app.downloads.edit import _concat_quote, parse_probe_streams
from app.downloads.edit import _codecs_from_streams, _concat_quote, run_ffprobe_json
from app.downloads.pipeline import _media_info, _produced_file
from pathlib import Path, PurePosixPath
@@ -105,39 +105,76 @@ class TestMediaInfo:
assert _media_info(info)["id"] == "vid"
class TestParseProbeStreams:
class TestCodecsFromStreams:
# ffprobe emits codec_name BEFORE codec_type, so the old line parser shifted every name onto
# the previous stream and an H.264+AAC mp4 came back as (aac, None) → a needless re-encode.
# _codecs_from_streams reads each stream dict by its own codec_type, immune to that ordering.
def test_reads_each_stream_by_its_own_type(self):
payload = '{"streams":[{"codec_name":"h264","codec_type":"video"},{"codec_name":"aac","codec_type":"audio"}]}'
assert parse_probe_streams(payload) == ("h264", "aac")
streams = [
{"codec_name": "h264", "codec_type": "video"},
{"codec_name": "aac", "codec_type": "audio"},
]
assert _codecs_from_streams(streams) == ("h264", "aac")
def test_audio_first_ordering_is_handled(self):
payload = '{"streams":[{"codec_name":"AAC","codec_type":"audio"},{"codec_name":"H264","codec_type":"video"}]}'
assert parse_probe_streams(payload) == ("h264", "aac")
streams = [
{"codec_name": "AAC", "codec_type": "audio"},
{"codec_name": "H264", "codec_type": "video"},
]
assert _codecs_from_streams(streams) == ("h264", "aac")
def test_extra_streams_do_not_win_over_the_first_of_their_kind(self):
payload = (
'{"streams":[{"codec_name":"h264","codec_type":"video"},'
'{"codec_name":"vp9","codec_type":"video"},'
'{"codec_name":"mov_text","codec_type":"subtitle"},'
'{"codec_name":"opus","codec_type":"audio"}]}'
)
assert parse_probe_streams(payload) == ("h264", "opus")
streams = [
{"codec_name": "h264", "codec_type": "video"},
{"codec_name": "vp9", "codec_type": "video"},
{"codec_name": "mov_text", "codec_type": "subtitle"},
{"codec_name": "opus", "codec_type": "audio"},
]
assert _codecs_from_streams(streams) == ("h264", "opus")
def test_audio_only_and_video_only(self):
assert parse_probe_streams('{"streams":[{"codec_name":"opus","codec_type":"audio"}]}') == (
assert _codecs_from_streams([{"codec_name": "opus", "codec_type": "audio"}]) == (
None,
"opus",
)
assert parse_probe_streams('{"streams":[{"codec_name":"av1","codec_type":"video"}]}') == (
"av1",
None,
)
assert _codecs_from_streams([{"codec_name": "av1", "codec_type": "video"}]) == ("av1", None)
def test_garbage_is_not_a_crash(self):
assert parse_probe_streams("") == (None, None)
assert parse_probe_streams("not json") == (None, None)
def test_empty_stream_list(self):
assert _codecs_from_streams([]) == (None, None)
class TestRunFfprobeJson:
"""The single ffprobe-JSON invocation point (shared by probe_codecs + probe.probe_media). A failed
probe or unparseable output must degrade to None, not raise — the malformed-input safety the old
parse_probe_streams string parser used to own now lives here."""
def test_unparseable_output_returns_none(self, monkeypatch):
import subprocess
class _R:
stdout = "not json"
monkeypatch.setattr(subprocess, "run", lambda *a, **k: _R())
assert run_ffprobe_json(Path("x")) is None
def test_ffprobe_failure_returns_none(self, monkeypatch):
import subprocess
def _boom(*a, **k):
raise subprocess.CalledProcessError(1, "ffprobe")
monkeypatch.setattr(subprocess, "run", _boom)
assert run_ffprobe_json(Path("x")) is None
def test_parses_streams_from_valid_json(self, monkeypatch):
import subprocess
class _R:
stdout = '{"streams":[{"codec_name":"h264","codec_type":"video"}]}'
monkeypatch.setattr(subprocess, "run", lambda *a, **k: _R())
data = run_ffprobe_json(Path("x"), entries="stream=codec_type,codec_name")
assert _codecs_from_streams(data["streams"]) == ("h264", None)
class TestConcatQuote:
+149
View File
@@ -0,0 +1,149 @@
"""An undelivered email leaves a trace in the admins' inbox.
Sending is deliberately fail-soft — a missing SMTP setting or a rejecting relay logs and returns
False so nothing user-facing breaks. The cost of that used to be total silence: an instance whose
SMTP was never filled in swallowed verification links, password resets and access-request notices,
and the operator's first clue was somebody saying nothing arrived.
"""
import smtplib
import pytest
from sqlalchemy import select
from app import email as email_mod
from app.models import Notification
@pytest.fixture(autouse=True)
def _no_smtp_config(monkeypatch):
"""Every test here drives the real `_send`, so make sure it never reaches a socket."""
monkeypatch.setattr(
email_mod,
"_smtp",
lambda: {"host": "", "port": 587, "user": "", "password": "", "from": ""},
)
def _notifications(db, user_id: int) -> list[Notification]:
return list(
db.execute(
select(Notification)
.where(Notification.user_id == user_id, Notification.type == "email_undelivered")
.order_by(Notification.id)
)
.scalars()
.all()
)
def test_a_skipped_email_notifies_every_admin(db, make_user):
admin_a = make_user(email="admin-a@example.com", role="admin")
admin_b = make_user(email="admin-b@example.com", role="admin")
plain = make_user(email="plain@example.com")
assert email_mod.send_test("someone@example.com", "en") is False # fail-soft, as before
db.expire_all()
for admin in (admin_a, admin_b):
(n,) = _notifications(db, admin.id)
assert n.data["reason"] == "not_configured"
assert n.data["subject"] == "Siftlode SMTP test"
assert n.data["recipients"] == ["someone@example.com"]
assert _notifications(db, plain.id) == [] # not an operator's problem to see
def test_the_notice_is_throttled_so_a_dead_relay_cannot_flood_the_inbox(db, make_user):
admin = make_user(email="admin@example.com", role="admin")
for _ in range(5):
email_mod.send_test("someone@example.com", "en")
db.expire_all()
assert len(_notifications(db, admin.id)) == 1
def test_a_send_failure_is_reported_separately_from_a_missing_config(db, make_user, monkeypatch):
# A configured-but-rejecting relay is a different operator problem from an empty config, and the
# inbox copy differs — so the reason has to survive to the notification.
admin = make_user(email="admin@example.com", role="admin")
monkeypatch.setattr(
email_mod,
"_smtp",
lambda: {
"host": "smtp.example.com",
"port": 587,
"user": "u",
"password": "p",
"from": "Siftlode <u@example.com>",
},
)
def _boom(*a, **kw):
raise smtplib.SMTPException("nope")
monkeypatch.setattr(email_mod.smtplib, "SMTP", _boom)
assert email_mod.send_test("someone@example.com", "en") is False
db.expire_all()
(n,) = _notifications(db, admin.id)
assert n.data["reason"] == "send_failed"
def test_no_admins_means_no_notification_and_no_error(db, make_user):
# A fresh instance can have no admin yet (the wizard has not run). The alert must stay silent
# rather than blowing up inside the fail-soft path it is supposed to make visible.
plain = make_user(email="plain@example.com")
assert email_mod.send_test("someone@example.com", "en") is False
db.expire_all()
assert _notifications(db, plain.id) == []
def test_a_drop_with_nobody_to_tell_does_not_burn_the_hour(db, make_user):
# The throttle window must only be spent on a notice that was actually written. Otherwise a drop
# during setup — before any admin exists — silences the next hour for the admin created a minute
# later, which is exactly the blind spot this alert exists to remove.
email_mod.send_test("someone@example.com", "en") # no admins yet
admin = make_user(email="admin@example.com", role="admin")
assert email_mod.send_test("someone@example.com", "en") is False
db.expire_all()
assert len(_notifications(db, admin.id)) == 1
def test_a_failed_alert_does_not_burn_the_hour_either(db, make_user, monkeypatch):
admin = make_user(email="admin@example.com", role="admin")
boom = {"on": True}
def _maybe_boom(*a, **kw):
if boom["on"]:
raise RuntimeError("notification store is down")
from app.notifications import create_notification as real
return real(*a, **kw)
monkeypatch.setattr(email_mod, "create_notification", _maybe_boom)
email_mod.send_test("someone@example.com", "en") # the write blows up; nothing recorded
boom["on"] = False
assert email_mod.send_test("someone@example.com", "en") is False
db.expire_all()
assert len(_notifications(db, admin.id)) == 1
def test_a_broken_alert_never_breaks_the_send(db, make_user, monkeypatch):
# The alert is the fallback path; if IT fails, the caller must still get the ordinary fail-soft
# False rather than an exception surfacing from a background task.
make_user(email="admin@example.com", role="admin")
def _boom(*a, **kw):
raise RuntimeError("notification store is down")
monkeypatch.setattr(email_mod, "create_notification", _boom)
assert email_mod.send_test("someone@example.com", "en") is False
+347
View File
@@ -0,0 +1,347 @@
"""One mailbox, many spellings — the canonical-email identity rule (E9.5 S3).
Gmail delivers `x.y@gmail.com`, `xy@gmail.com` and `xy+tag@gmail.com` to ONE inbox. The invite gate
used to compare literal strings, so on 2026-09-03 an approved user's Google sign-in became a silent
SECOND access request: they had registered as `npeter83.temu@gmail.com`, and OAuth returned the
account's stored primary `npeter83temu@gmail.com`. The `+tag` case fails every time — a Google account
cannot contain `+`, so OAuth can only ever answer with the base address.
The rule is narrow on purpose: dots are significant on most providers, and the Gmail dot rule does not
hold for Google Workspace custom domains unless their admin enabled it.
"""
import pytest
from sqlalchemy import select, text
_UAT_PW = "correct horse battery"
from app.auth import is_allowed
from app.models import Invite, User
from app.utils import canonical_email
# (input, expected canonical) — the matrix the sprint promised, plus the shapes that must NOT fold.
CASES = [
# Gmail: dots and +tags are noise…
("npeter83.temu@gmail.com", "npeter83temu@gmail.com"),
("npeter83temu@gmail.com", "npeter83temu@gmail.com"),
("n.p.e.t.e.r@gmail.com", "npeter@gmail.com"),
("user+siftlode@gmail.com", "user@gmail.com"),
("u.ser+a+b@gmail.com", "user@gmail.com"),
("USer.Name@GMail.COM", "username@gmail.com"),
# …and googlemail is the same mailbox as gmail.
("user.name@googlemail.com", "username@gmail.com"),
# Everyone else: lowercase ONLY. Dots stay significant.
("first.last@example.com", "first.last@example.com"),
("First.Last@Example.COM", "first.last@example.com"),
("first+tag@example.com", "first+tag@example.com"),
# A Workspace domain is not Gmail, however Gmail-ish it looks.
("first.last@gmail.company.com", "first.last@gmail.company.com"),
# Degenerate input must not explode or invent an address.
("", ""),
(None, ""),
("not-an-email", "not-an-email"),
(".@gmail.com", ".@gmail.com"),
]
@pytest.mark.parametrize("raw,expected", CASES)
def test_canonical_email(raw, expected):
assert canonical_email(raw) == expected
def test_the_migration_sql_agrees_with_the_helper(db):
"""The 0079 backfill reimplements canonical_email in SQL (a migration must not import app code
that keeps changing). That duplication is only safe while the two agree, so pin them together —
a drift here means rows backfilled into a form the application will never look up."""
import importlib.util
from pathlib import Path
path = Path(__file__).resolve().parents[1] / "alembic" / "versions" / "0079_email_canonical.py"
spec = importlib.util.spec_from_file_location("m0079", path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
sql = module._CANON_SQL.format(col=":addr")
for raw, expected in CASES:
if raw is None:
continue # the column is NOT NULL; the backfill skips NULL emails explicitly
got = db.execute(text(f"SELECT {sql}"), {"addr": raw}).scalar_one()
assert got == expected, f"SQL and helper disagree on {raw!r}: {got!r} != {expected!r}"
def test_the_column_is_kept_in_sync_by_writing_the_email(db):
"""No write site has to remember: the model validator derives it. If this breaks, every lookup
below silently stops matching."""
user = User(email="Dotted.Name+tag@GoogleMail.com")
db.add(user)
db.commit()
assert user.email == "Dotted.Name+tag@GoogleMail.com" # stored AS TYPED — mail goes here
assert user.email_canonical == "dottedname@gmail.com" # …matched on this
user.email = "someone.else@example.com"
db.commit()
assert user.email_canonical == "someone.else@example.com"
def test_an_approval_covers_every_spelling_of_the_same_gmail_mailbox(db):
# THE 2026-09-03 lockout, in one assertion: approved under the dotted spelling, signing in with
# the primary Google returns.
db.add(Invite(email="npeter83.temu@gmail.com", status="approved"))
db.commit()
assert is_allowed(db, "npeter83temu@gmail.com") is True
assert is_allowed(db, "npeter83.temu@gmail.com") is True
assert is_allowed(db, "npeter83.temu+throwaway@gmail.com") is True
def test_a_plus_tagged_registration_is_not_locked_out_after_approval(db):
# The case that ALWAYS broke: a Google account cannot contain `+`, so OAuth can only return the
# base address, and a literal match could never find the invite.
db.add(Invite(email="someone+siftlode@gmail.com", status="approved"))
db.commit()
assert is_allowed(db, "someone@gmail.com") is True
def test_dots_stay_significant_outside_gmail(db):
db.add(Invite(email="first.last@example.com", status="approved"))
db.commit()
assert is_allowed(db, "first.last@example.com") is True
assert is_allowed(db, "firstlast@example.com") is False # a DIFFERENT mailbox elsewhere
def test_an_approved_row_wins_over_a_denied_sibling(db):
# Prod carries approved pairs for one mailbox (oldest 2026-06-16). The API cannot flip an approved
# invite to denied, so approved-beside-denied means the admin approved that mailbox under another
# spelling — not that they revoked it. Revoking is deleting the row or suspending the account.
db.add(Invite(email="two.spellings@gmail.com", status="denied"))
db.add(Invite(email="twospellings@gmail.com", status="approved"))
db.commit()
assert is_allowed(db, "twospellings@gmail.com") is True
def test_a_denied_mailbox_stays_denied(db):
db.add(Invite(email="no.thanks@gmail.com", status="denied"))
db.commit()
assert is_allowed(db, "nothanks@gmail.com") is False
def test_a_second_spelling_does_not_open_a_second_access_request(db):
"""The other half of the 2026-09-03 report: the admin queue got a duplicate request for a user
who was already approved."""
from app.routes.auth import upsert_pending_invite
db.add(Invite(email="already.approved@gmail.com", status="approved"))
db.commit()
req = upsert_pending_invite(db, "alreadyapproved@gmail.com")
assert req.is_new is False # nothing new to notify an admin about
assert req.invite is not None and req.invite.email == "already.approved@gmail.com"
assert len(db.execute(select(Invite)).scalars().all()) == 1
def test_manually_whitelisting_another_spelling_reuses_the_row(db):
"""How prod ended up with hartmann.peter1986@ AND hartmannpeter1986@ both approved on the same
day: the admin typed the other spelling and got a second row instead of a no-op."""
from app.routes.admin import add_invite, AddInviteIn
admin = User(email="admin@example.com", role="admin", is_active=True, email_verified=True)
db.add(admin)
db.add(Invite(email="hartmann.peter1986@gmail.com", status="approved"))
db.commit()
add_invite(AddInviteIn(email="hartmannpeter1986@gmail.com"), admin=admin, db=db)
rows = db.execute(select(Invite)).scalars().all()
assert len(rows) == 1
assert rows[0].email == "hartmann.peter1986@gmail.com" # the original spelling is preserved
def _capture_reset_links(monkeypatch) -> list[str]:
"""Patch the reset mailer and collect the recipients it is asked to write to."""
from app.routes import auth as auth_mod
sent: list[str] = []
monkeypatch.setattr(
auth_mod.email_mod, "send_password_reset", lambda to, lang, link: sent.append(to)
)
return sent
# --- signing in and recovering an account, whichever spelling of it the person types -------------
#
# The three flows that look an account up BY TYPED ADDRESS used to match literally, so a Gmail user
# who registered `x.y@` and typed `xy@` met "Invalid email or password" at sign-in — and, worse,
# total silence from password-reset and verify-resend, which answer uniformly by design.
def test_the_password_works_with_any_spelling_of_the_same_mailbox(db):
from app.auth import find_user_by_email
make = User(
email="t.e.st+tag@gmail.com", email_verified=True, is_active=True, password_hash="x"
)
db.add(make)
db.commit()
for typed in (
"t.e.st+tag@gmail.com", # as registered
"test@gmail.com", # the mailbox itself
"t.e.st@gmail.com", # dots only
"TEST@googlemail.com", # the alias domain
):
assert find_user_by_email(db, typed) is make, typed
def test_an_unrelated_address_still_finds_nothing(db):
from app.auth import find_user_by_email
db.add(User(email="someone@gmail.com", email_verified=True, is_active=True))
db.commit()
assert find_user_by_email(db, "someoneelse@gmail.com") is None
assert find_user_by_email(db, "some.one@example.com") is None # dots matter off Gmail
def test_the_exact_spelling_wins_when_two_accounts_share_a_mailbox(db):
# Prod carries such a pair (ids 13 and 14). Whoever types their own address in full must land on
# their own account, not on whichever row happens to come first.
from app.auth import find_user_by_email
dotted = User(email="npeter83.temu@gmail.com", email_verified=True, is_active=True)
plain = User(email="npeter83temu@gmail.com", email_verified=True, is_active=True)
db.add_all([dotted, plain])
db.commit()
assert find_user_by_email(db, "npeter83.temu@gmail.com") is dotted
assert find_user_by_email(db, "npeter83temu@gmail.com") is plain
def test_an_ambiguous_spelling_refuses_rather_than_guessing(db):
# Neither spelling is exact, and both are the same mailbox. Handing a password sign-in one of
# them at random would let someone into the wrong account, so this answers "no such account" —
# the same uniform reply the caller already gives.
from app.auth import find_user_by_email
db.add(User(email="npeter83.temu@gmail.com", email_verified=True, is_active=True))
db.add(User(email="npeter83temu@gmail.com", email_verified=True, is_active=True))
db.commit()
assert find_user_by_email(db, "n.peter83.temu+x@gmail.com") is None
def test_a_reset_link_goes_to_the_address_on_the_account(db, monkeypatch):
"""Typing another spelling must still reach the owner — and the mail must go to the address they
registered with, tag and all, not to the one typed into the form."""
from app.routes.auth import _send_reset_if_eligible
db.add(
User(
email="me+siftlode@gmail.com",
email_verified=True,
is_active=True,
password_hash="x",
)
)
db.commit()
sent = _capture_reset_links(monkeypatch)
_send_reset_if_eligible("me@gmail.com")
assert sent == ["me+siftlode@gmail.com"]
# --- a Google sign-in must not rewrite the address its owner chose ------------------------------
def test_a_google_sign_in_keeps_a_plus_tagged_address(db):
"""People register as `me+siftlode@gmail.com` so a later leak from this site is identifiable.
Google returns the account's primary address, which can never contain `+`, so following it
blindly destroyed the tag on the first Google sign-in — and, since sign-in matches the stored
address first, stopped them signing in with the address they had chosen."""
from app.routes.auth import _sync_google_email
user = User(email="me+siftlode@gmail.com", email_verified=True, is_active=True)
db.add(user)
db.commit()
_sync_google_email(db, user, "me@gmail.com")
assert user.email == "me+siftlode@gmail.com"
def test_a_google_sign_in_follows_a_real_mailbox_change(db):
# The case the sync exists for: the Google account genuinely moved to a different mailbox.
from app.routes.auth import _sync_google_email
user = User(email="old.address@gmail.com", email_verified=True, is_active=True)
db.add(user)
db.commit()
_sync_google_email(db, user, "brand-new@example.com")
assert user.email == "brand-new@example.com"
def test_a_mailbox_change_is_refused_when_another_account_owns_it(db):
from app.routes.auth import _sync_google_email
db.add(User(email="taken@example.com", email_verified=True, is_active=True))
user = User(email="mover@gmail.com", email_verified=True, is_active=True)
db.add(user)
db.commit()
_sync_google_email(db, user, "taken@example.com")
assert user.email == "mover@gmail.com" # unique constraint would have 500'd the sign-in
# --- showing BOTH addresses, once there are two -------------------------------------------------
def test_the_google_address_is_recorded_beside_the_registered_one(db):
"""Since the sign-in no longer overwrites the registered address, the account has two true
addresses and only one was stored. This is the other half, for the UI to name."""
from app.routes.auth import _sync_google_email
user = User(email="me+siftlode@gmail.com", email_verified=True, is_active=True)
db.add(user)
db.commit()
user.google_email = "me@gmail.com"
_sync_google_email(db, user, "me@gmail.com")
db.commit()
assert user.email == "me+siftlode@gmail.com" # still theirs
assert user.google_email == "me@gmail.com" # …and now we know the other one
def test_me_reports_the_google_address_only_when_it_differs(client, db, make_user):
"""The comparison lives in one place so no surface has to repeat it — and so the account menu
stays exactly as it is for the majority, whose two addresses are the same string."""
same = make_user(email="plain@gmail.com", password=_UAT_PW)
same.google_email = "plain@gmail.com"
db.commit()
assert (
client.post(
"/auth/password-login", json={"email": "plain@gmail.com", "password": _UAT_PW}
).status_code
== 200
)
assert client.get("/api/me").json()["google_email"] is None
client.post("/auth/logout")
tagged = make_user(email="me+siftlode@gmail.com", password=_UAT_PW)
tagged.google_email = "me@gmail.com"
db.commit()
assert (
client.post(
"/auth/password-login", json={"email": "me+siftlode@gmail.com", "password": _UAT_PW}
).status_code
== 200
)
assert client.get("/api/me").json()["google_email"] == "me@gmail.com"
+121
View File
@@ -0,0 +1,121 @@
"""Every outbound mail carries an HTML alternative, so its link is actually clickable.
The bodies are plain text and stay that way — but a bare URL in a text/plain mail is only a link if
the CLIENT chooses to linkify it. Gmail/Outlook/Apple Mail do; lighter web clients render the body
verbatim and leave the verification or password-reset link as dead text, which is the entire point of
those two messages. So `_send` now attaches an HTML part generated from the same plain body.
These are pure-logic tests except the last one, which drives the real `_send` against a fake SMTP so
the wire format itself is pinned, not just the helper.
"""
import pytest
from app import email as email_mod
_SMTP_OK = {
"host": "smtp.example.com",
"port": 587,
"user": "u",
"password": "p",
"from": "Siftlode <u@example.com>",
}
def test_a_bare_url_becomes_a_real_link():
# THE regression: this is what a client that does not linkify plain text was leaving as dead text.
out = email_mod._html_body(
"Confirm your email:\n\nhttps://siftlode.example/#verify=tok123\n\n— Siftlode"
)
assert '<a href="https://siftlode.example/#verify=tok123">' in out
def test_interpolated_content_cannot_inject_markup():
# Bodies carry untrusted values — a requester's address in the admin notice, the operator contact.
# Escaping must run BEFORE linkification, or a crafted address becomes markup in every admin's mail.
out = email_mod._html_body('A request from <script>alert("x")</script> & co')
assert "<script>" not in out
assert "&lt;script&gt;" in out
assert "&amp; co" in out
def test_a_query_string_keeps_its_escaped_ampersand_inside_the_href():
# `&` is escaped to `&amp;` before the linkifier sees it, which is also what an HTML attribute
# wants — so the href must carry the escaped form and nothing may be trimmed off it.
out = email_mod._html_body("https://siftlode.example/x?a=1&b=2")
assert (
'<a href="https://siftlode.example/x?a=1&amp;b=2">https://siftlode.example/x?a=1&amp;b=2</a>'
in out
)
@pytest.mark.parametrize(
"body,href,trail",
[
("See https://siftlode.example/x.", "https://siftlode.example/x", "."),
("See https://siftlode.example/x, then sign in", "https://siftlode.example/x", ","),
("See (https://siftlode.example/x)", "https://siftlode.example/x", ")"),
],
)
def test_sentence_punctuation_stays_out_of_the_href(body, href, trail):
out = email_mod._html_body(body)
assert f'<a href="{href}">{href}</a>{trail}' in out
def test_a_token_ending_in_a_url_safe_character_is_not_trimmed():
# token_urlsafe output can end in `-` or `_`; trimming either would hand the user a dead link.
for tok in ("abc-", "abc_"):
out = email_mod._html_body(f"https://siftlode.example/#reset={tok}")
assert f'href="https://siftlode.example/#reset={tok}"' in out
def test_blank_lines_become_paragraphs_and_single_breaks_stay_inline():
out = email_mod._html_body("Hi,\n\nline one\nline two\n\n— Siftlode")
assert out.count("<p>") == 3
assert "<p>line one<br>line two</p>" in out
def test_the_sent_message_is_multipart_with_the_plain_body_intact(monkeypatch):
# The wire format, not just the helper: text/plain stays the primary part and the source of
# truth, with text/html alongside it.
monkeypatch.setattr(email_mod, "_smtp", lambda: _SMTP_OK)
sent = []
class _FakeSMTP:
def __init__(self, *a, **kw):
pass
def __enter__(self):
return self
def __exit__(self, *a):
return False
def starttls(self, *a, **kw):
pass
def login(self, *a, **kw):
pass
def send_message(self, msg):
sent.append(msg)
monkeypatch.setattr(email_mod.smtplib, "SMTP", _FakeSMTP)
assert (
email_mod.send_verify_email(
"someone@example.com", "en", "https://siftlode.example/#verify=tok"
)
is True
)
(msg,) = sent
assert msg.get_content_type() == "multipart/alternative"
plain = msg.get_body(preferencelist=("plain",))
rich = msg.get_body(preferencelist=("html",))
assert plain is not None and rich is not None
# The plain part must be byte-for-byte the template's own body — the HTML part is derived from
# it, never a second copy that could drift.
assert "https://siftlode.example/#verify=tok" in plain.get_content()
assert "<a href=" not in plain.get_content()
assert '<a href="https://siftlode.example/#verify=tok">' in rich.get_content()
+32 -10
View File
@@ -1,6 +1,7 @@
"""build_ydl_opts: the mp4 compat profile must SELECT H.264 (not merely prefer it), so shared
downloads play on iOS/Safari, while custom vcodec / non-mp4 profiles opt out. See the iPad AV1
broken-player fix."""
"""build_ydl_opts: EVERY download is clamped to the mp4/H.264 compat profile (E7 browser-
playability guarantee) and must SELECT H.264 (not merely prefer it), so shared downloads play in
every browser incl. iOS/Safari. Non-mp4 containers and non-H.264 vcodecs are coerced, not honoured.
See the iPad AV1 broken-player fix + E7 format narrowing."""
from app.downloads import edit as editmod
from app.downloads import formats
@@ -56,16 +57,37 @@ def test_max_height_caps_every_branch():
assert fmt.count("[height<=?720]") >= 3 # applied to each avc1/fallback branch
def test_explicit_vcodec_opts_out_of_avc1_selector():
def test_explicit_vcodec_is_clamped_to_the_compat_selector():
# E7 browser-playability guarantee: a non-H.264 vcodec no longer opts out — normalize() coerces
# vcodec→None, so the avc1 selector (and mp4 merge) is used regardless of what was requested.
opts = _opts({"mode": "av", "container": "mp4", "vcodec": "vp9"})
assert "vcodec^=avc1" not in opts["format"]
assert opts["format"] == "bestvideo+bestaudio/best"
assert "vcodec:vp9" in opts["format_sort"]
assert opts["format"].startswith("bestvideo[vcodec^=avc1]")
assert opts["merge_output_format"] == "mp4"
assert "vcodec:vp9" not in opts["format_sort"]
def test_non_mp4_container_opts_out():
fmt = _opts({"mode": "av", "container": "webm"})["format"]
assert "vcodec^=avc1" not in fmt
def test_non_mp4_container_is_clamped_to_mp4():
# A webm/mkv request is coerced to the mp4/H.264 compat profile (nothing plays mkv in-browser;
# webm/VP9 fails iOS), so the avc1 selector is used and the merge target is mp4.
opts = _opts({"mode": "av", "container": "webm"})
assert opts["format"].startswith("bestvideo[vcodec^=avc1]")
assert opts["merge_output_format"] == "mp4"
def test_normalize_clamps_to_browser_playable():
n = formats.normalize({"container": "mkv", "vcodec": "av1", "audio_format": "opus"})
assert n["container"] == "mp4"
assert n["vcodec"] is None
assert n["audio_format"] == "m4a"
# A playable audio choice is preserved.
assert formats.normalize({"audio_format": "mp3"})["audio_format"] == "mp3"
def test_non_playable_specs_dedup_to_the_compat_identity():
# Two requests that differ only in (now-clamped) non-playable fields share ONE cache asset.
a = formats.format_sig({"mode": "av", "max_height": 720, "container": "webm", "vcodec": "av1"})
b = formats.format_sig({"mode": "av", "max_height": 720, "container": "mp4"})
assert a == b
def test_audio_only_unaffected():
+28 -5
View File
@@ -65,6 +65,9 @@ def _asset(**over):
height=720,
vcodec="h264",
acodec="aac",
media_kind="video",
size_bytes=1000,
container="mp4",
source_webpage_url=None,
source_kind="url",
)
@@ -74,12 +77,13 @@ def _asset(**over):
class TestPublicMetaAudioFlag:
"""The watch page picks <audio> vs <video> from this flag; WebKit refuses an audio-only mp3 in a
<video>, so an audio-only asset (codec present, no video stream) must report audio=True."""
<video>, so an audio-only asset (media_kind='audio') must report audio=True. The flag now reads
the stored media_kind (the single source), not a re-derived codec check."""
_link = SimpleNamespace(allow_download=True)
def test_audio_only_asset_is_flagged_audio(self):
asset = _asset(vcodec=None, acodec="mp3", width=None, height=None)
asset = _asset(media_kind="audio", vcodec=None, acodec="mp3", width=None, height=None)
meta = public_meta(self._link, None, asset, "/f.mp3")
assert meta["audio"] is True
@@ -87,10 +91,29 @@ class TestPublicMetaAudioFlag:
meta = public_meta(self._link, None, _asset(), "/f.mp4")
assert meta["audio"] is False
def test_a_silent_video_is_not_audio(self):
# Video present but no audio track — still a video player, not the audio bar.
meta = public_meta(self._link, None, _asset(acodec=None), "/f.mp4")
def test_a_generic_file_is_not_audio(self):
# A non-media upload (media_kind='file') has no player at all — never the audio bar.
meta = public_meta(self._link, None, _asset(media_kind="file"), "/f.bin")
assert meta["audio"] is False
assert meta["media_kind"] == "file"
class TestCreateLinkDefault:
"""D1 (E7 S3): a newly minted share link is downloadable by default. The one-click Share relies on
this — it POSTs no `allow_download`, so the API default decides. Forward-only lives here (the API
default), NOT in a migration: the DownloadLink column default stays False, so no existing back-
catalogue link silently flips to downloadable."""
def test_omitted_allow_download_defaults_downloadable(self):
from app.routes.downloads import CreateLinkIn
assert CreateLinkIn().allow_download is True
def test_explicit_false_is_respected(self):
# The owner can still uncheck it to mint a stream-only link.
from app.routes.downloads import CreateLinkIn
assert CreateLinkIn(allow_download=False).allow_download is False
class TestIsExpired:
+107
View File
@@ -0,0 +1,107 @@
"""What a refused sign-in SAYS (E9.5 S4), DB-lane.
The states behind a 403 were true and useless: "Your account is awaiting admin approval." never
revealed WHICH mailbox the account was on. On 2026-09-03 a registration as `npeter83.temu@gmail.com`
met a Google claim of `npeter83temu@gmail.com`, and that missing address hid the mismatch for a day.
So these pin the DETAIL, not just the status code — the address, and both gates, since E9.5 S1 made
the two order-independent and the screen must report whichever is actually outstanding.
The two refusals that answer BEFORE the password is proven carry nothing, and that is the security
half of the same contract: `invalid` must stay byte-identical for a real account and an unknown one.
"""
from app.config import settings
from app.models import Invite
from app.routes.auth import _access_notice_url, upsert_pending_invite
_PW = "correct horse battery"
def _login(client, email, password=_PW):
return client.post("/auth/password-login", json={"email": email, "password": password})
def test_pending_approval_names_the_address_the_account_is_on(client, make_user):
# The typed spelling and the stored one are the same mailbox; the screen must show the STORED
# one, because that is the mismatch a user cannot otherwise see.
make_user(email="np.temu@gmail.com", password=_PW, is_active=False)
detail = _login(client, "nptemu@gmail.com").json()["detail"]
assert detail["reason"] == "pending_approval"
assert detail["email"] == "np.temu@gmail.com"
assert detail["email_verified"] is True and detail["approved"] is False
def test_an_already_approved_account_still_reports_its_missing_verification(client, make_user):
# S1 made the gates order-independent, so this really happens: approved, not yet verified. A
# screen that assumed a sequence would tell this user to wait for an admin who already decided.
make_user(
email="approved.first@example.com", password=_PW, email_verified=False, is_active=True
)
detail = _login(client, "approved.first@example.com").json()["detail"]
assert detail["reason"] == "email_unverified"
assert detail["email_verified"] is False and detail["approved"] is True
def test_a_suspended_account_is_told_who_to_contact(client, make_user, monkeypatch):
monkeypatch.setattr(settings, "admin_emails", "ops@example.com")
make_user(email="banned@example.com", password=_PW, is_suspended=True)
r = _login(client, "banned@example.com")
assert r.status_code == 403
assert r.json()["detail"]["reason"] == "suspended"
assert r.json()["detail"]["operator"] == "ops@example.com"
def test_a_refusal_before_the_password_is_proven_carries_no_account_detail(client, make_user):
make_user(email="real@example.com", password=_PW, is_active=False)
wrong_pw = _login(client, "real@example.com", "not the password").json()["detail"]
unknown = _login(client, "nobody@example.com").json()["detail"]
# No address, no flags — the pending state above must not leak through the wrong-password door.
assert wrong_pw == unknown == {"code": "login", "reason": "invalid"}
# --- the access-request notice -------------------------------------------------------------------
def test_the_access_notice_names_the_address_and_rides_the_fragment():
url = _access_notice_url("requested", "someone@example.com")
# A fragment never reaches a proxy access log or a Referer header — the same reasoning that put
# the verify/reset tokens there (SB3), applied to an address.
assert url == "/#access=requested&email=someone%40example.com"
def test_a_plus_tagged_address_survives_the_round_trip():
# URLSearchParams reads a bare `+` in a fragment as a SPACE, so the encoder must emit %2B — or
# the very address family this epic exists to protect arrives mangled.
url = _access_notice_url("requested", "me+siftlode@gmail.com")
assert "me%2Bsiftlode%40gmail.com" in url and "+siftlode" not in url
def test_a_request_already_on_file_under_another_spelling_says_so(db):
db.add(Invite(email="hartmann.peter@gmail.com", status="pending"))
db.commit()
req = upsert_pending_invite(db, "hartmannpeter@gmail.com")
url = _access_notice_url("requested", "hartmannpeter@gmail.com", req.invite)
assert req.is_new is False # one mailbox, one request — no second row for the admin queue
assert "linked=hartmann.peter%40gmail.com" in url
def test_the_same_spelling_twice_is_just_a_repeat_and_says_nothing_extra(db):
db.add(Invite(email="same@example.com", status="pending"))
db.commit()
req = upsert_pending_invite(db, "same@example.com")
# Nothing to explain: telling this user we "linked" their request to itself would be noise.
assert "linked" not in _access_notice_url("requested", "same@example.com", req.invite)
+289
View File
@@ -0,0 +1,289 @@
"""E9.5 S7 — every outbound mail is written in the reader's language.
Two halves. The first is the catalog invariant: HU/EN parity with identical placeholders, enforced
here because a mail is the one user-facing surface nobody can re-render by switching the UI language
— it arrives once, in whatever language we picked. The second is the resolution chain: which
language a given recipient actually gets, and where that signal was captured, since for an address
with no account it exists only at the moment they asked us.
"""
import re
import string
import pytest
from sqlalchemy import select
from app import mailtext
from app.models import Invite, User
from app.routes import auth as auth_routes
_PW = "correct horse battery"
def _placeholders(template: str) -> set[str]:
return {name for _, name, _, _ in string.Formatter().parse(template) if name}
# --- the catalog itself ---------------------------------------------------------------------------
@pytest.mark.parametrize("key", sorted(mailtext.MAIL_TEXT))
def test_every_mail_string_exists_in_both_languages(key):
assert set(mailtext.MAIL_TEXT[key]) == set(mailtext.SUPPORTED)
@pytest.mark.parametrize("key", sorted(mailtext.MAIL_TEXT))
def test_every_translation_takes_the_same_placeholders(key):
# The parity that actually bites: a translation that drops `{link}` renders a mail with no link
# in it, and a translation that invents one raises KeyError inside a background task nobody is
# watching. Both are silent in production and loud here.
variants = mailtext.MAIL_TEXT[key]
expected = _placeholders(variants[mailtext.DEFAULT])
for lang, template in variants.items():
assert _placeholders(template) == expected, f"{key} [{lang}]"
def _app_sources_without_the_catalog() -> str:
"""Every app source, with the catalog LITERAL cut out of mailtext.py.
Cutting it is the whole point: the dict spells each key as a quoted literal, so a scan that
included it reported every key as used no matter what the catalog held. Cutting the whole FILE
is too much, though — `link_expiry` legitimately asks for `duration.*`, and those keys would
then look orphaned."""
from pathlib import Path
app_dir = Path(__file__).resolve().parents[1] / "app"
out = []
for f in app_dir.rglob("*.py"):
src = f.read_text(encoding="utf-8")
if f.name == "mailtext.py":
src = src[: src.index("MAIL_TEXT: dict")]
out.append(src)
return "\n".join(out)
def test_no_catalog_entry_is_orphaned():
"""Every key is referenced from the app, so a mail that stopped being sent can't leave its text
behind to be 'kept up to date' by the next person who greps for it."""
sources = _app_sources_without_the_catalog()
assert [k for k in mailtext.MAIL_TEXT if f'"{k}"' not in sources] == []
def test_every_key_the_app_asks_for_exists():
"""The other direction, and the one that raises in production: `text()` looks the key up in a
dict, so a renamed or mistyped key is a KeyError inside a background task with no user in sight."""
asked = set(re.findall(r'text\(\s*"([a-z_]+\.[a-z_]+)"', _app_sources_without_the_catalog()))
assert asked, "the scan found no mailtext.text(...) call sites — the pattern has rotted"
assert sorted(asked - set(mailtext.MAIL_TEXT)) == []
def test_an_unknown_language_falls_back_to_english_instead_of_raising():
# `preferences.language` is free-form JSON written by older builds and by locales we may stop
# shipping. A send that raised on one would take out a background task with no user in sight.
assert mailtext.text("test.subject", "de") == mailtext.text("test.subject", "en")
assert mailtext.text("test.subject", None) == mailtext.text("test.subject", "en")
# --- who gets which language ----------------------------------------------------------------------
def test_a_users_own_preference_decides_their_language(make_user):
hu = make_user(email="hu@example.com", preferences={"language": "hu"})
en = make_user(email="en@example.com", preferences={"language": "en"})
none = make_user(email="none@example.com", preferences=None)
assert mailtext.lang_for_user(hu) == "hu"
assert mailtext.lang_for_user(en) == "en"
assert mailtext.lang_for_user(none) == "en"
def test_an_address_with_no_account_falls_back_to_the_language_it_asked_in(db):
db.add(Invite(email="asked@example.com", status="pending", lang="hu"))
db.commit()
assert mailtext.lang_for_email(db, "asked@example.com") == "hu"
def test_the_account_wins_over_the_invite_row(db, make_user):
make_user(email="both@example.com", preferences={"language": "en"})
db.add(Invite(email="both@example.com", status="approved", lang="hu"))
db.commit()
# The invite records how they asked once; the account records what they have since chosen.
assert mailtext.lang_for_email(db, "both@example.com") == "en"
def test_a_row_that_never_recorded_a_language_is_walked_past(db, make_user):
# Prod carries TWO accounts for one Gmail mailbox (the 2026-09-03 pair). Stopping at the first
# row would answer English for a person whose other account says Hungarian.
make_user(email="dup.one@gmail.com", preferences={})
make_user(email="dupone@gmail.com", preferences={"language": "hu"})
assert mailtext.lang_for_email(db, "dup.one@gmail.com") == "hu"
def test_the_lookup_is_canonical_like_every_other_identity_match(db, make_user):
make_user(email="canon.match@gmail.com", preferences={"language": "hu"})
assert mailtext.lang_for_email(db, "canonmatch+tag@gmail.com") == "hu"
# --- capturing the signal at the one moment it exists ---------------------------------------------
def test_registration_records_the_language_of_the_sign_up_page(db, client):
r = client.post(
"/auth/register", json={"email": "signup@example.com", "password": _PW, "lang": "hu"}
)
assert r.status_code == 200
user = db.execute(select(User).where(User.email == "signup@example.com")).scalar_one()
assert (user.preferences or {}).get("language") == "hu"
def test_an_access_request_records_the_language_it_was_made_in(db, client):
assert (
client.post(
"/auth/request-access", json={"email": "asking@example.com", "lang": "hu"}
).status_code
== 200
)
inv = db.execute(select(Invite).where(Invite.email == "asking@example.com")).scalar_one()
assert inv.lang == "hu"
def test_asking_again_from_another_language_updates_the_row(db, client):
client.post("/auth/request-access", json={"email": "asking@example.com", "lang": "en"})
client.post("/auth/request-access", json={"email": "asking@example.com", "lang": "hu"})
db.expire_all()
inv = db.execute(select(Invite).where(Invite.email == "asking@example.com")).scalar_one()
# The newest thing they told us wins — and there is still exactly one row (S3's canonical upsert).
assert inv.lang == "hu"
assert db.execute(select(Invite)).scalars().all() == [inv]
def test_an_unsupported_language_is_ignored_rather_than_stored(db, client):
client.post("/auth/request-access", json={"email": "asking@example.com", "lang": "../../etc"})
inv = db.execute(select(Invite).where(Invite.email == "asking@example.com")).scalar_one()
assert inv.lang is None
# --- the mails themselves -------------------------------------------------------------------------
def _bodies(monkeypatch):
"""Capture what `_send` would have put on the wire, rather than the arguments a caller passed —
the language only matters if it reaches the body."""
from app import email as email_mod
out: list[tuple[list[str], str, str]] = []
monkeypatch.setattr(
email_mod,
"_send",
lambda to, subject, body, reply_to=None: out.append((to, subject, body)) is None,
)
return out
def test_the_confirmation_mail_is_written_in_the_accounts_language(db, client, monkeypatch):
from app import email as email_mod
monkeypatch.setattr(email_mod, "email_enabled", lambda db=None: True)
sent = _bodies(monkeypatch)
client.post("/auth/register", json={"email": "hu@example.com", "password": _PW, "lang": "hu"})
(_, subject, body) = sent[0]
assert subject == mailtext.MAIL_TEXT["verify_email.subject"]["hu"]
assert "#verify=" in body
def test_the_approval_mail_answers_in_the_language_the_request_was_made_in(
db, client, make_user, as_user, monkeypatch
):
from app import email as email_mod
monkeypatch.setattr(email_mod, "email_enabled", lambda db=None: True)
sent = _bodies(monkeypatch)
admin = make_user(email="admin@example.com", role="admin")
client.post("/auth/request-access", json={"email": "asking@example.com", "lang": "hu"})
inv = db.execute(select(Invite).where(Invite.email == "asking@example.com")).scalar_one()
sent.clear() # drop the admin notification; this test is about the reply to the requester
r = as_user(admin).post(f"/api/admin/invites/{inv.id}/approve")
assert r.status_code == 200
(to, subject, _) = sent[0]
assert to == ["asking@example.com"]
assert subject == mailtext.MAIL_TEXT["access_approved.subject"]["hu"]
def test_a_denial_answers_in_that_language_too(db, client, make_user, as_user, monkeypatch):
from app import email as email_mod
monkeypatch.setattr(email_mod, "email_enabled", lambda db=None: True)
sent = _bodies(monkeypatch)
admin = make_user(email="admin@example.com", role="admin")
client.post("/auth/request-access", json={"email": "asking@example.com", "lang": "hu"})
inv = db.execute(select(Invite).where(Invite.email == "asking@example.com")).scalar_one()
sent.clear()
as_user(admin).post(f"/api/admin/invites/{inv.id}/deny")
assert sent[0][1] == mailtext.MAIL_TEXT["access_denied.subject"]["hu"]
def test_the_admins_pick_decides_an_invitation_and_is_remembered(
db, make_user, as_user, monkeypatch
):
from app import email as email_mod
monkeypatch.setattr(email_mod, "email_enabled", lambda db=None: True)
sent = _bodies(monkeypatch)
admin = make_user(email="admin@example.com", role="admin")
# Hand-whitelisted: nobody has ever told us anything about this mailbox, which is exactly why
# the send button asks.
inv = Invite(email="stranger@example.com", status="approved")
db.add(inv)
db.commit()
r = as_user(admin).post(f"/api/admin/invites/{inv.id}/invite", json={"lang": "hu"})
assert r.status_code == 200
assert sent[0][1] == mailtext.MAIL_TEXT["access_invitation.subject"]["hu"]
db.expire_all()
# Recorded, so a later mail to this address matches the one the admin already sent.
assert db.get(Invite, inv.id).lang == "hu"
def test_an_invitation_without_a_pick_still_goes_out(db, make_user, as_user, monkeypatch):
from app import email as email_mod
monkeypatch.setattr(email_mod, "email_enabled", lambda db=None: True)
sent = _bodies(monkeypatch)
admin = make_user(email="admin@example.com", role="admin")
inv = Invite(email="stranger@example.com", status="approved")
db.add(inv)
db.commit()
r = as_user(admin).post(f"/api/admin/invites/{inv.id}/invite")
assert r.status_code == 200
assert sent[0][1] == mailtext.MAIL_TEXT["access_invitation.subject"]["en"]
db.expire_all()
# Nothing was picked, so nothing is remembered: the column means "what this person told us",
# not "what we last defaulted to".
assert db.get(Invite, inv.id).lang is None
def test_admins_are_notified_in_their_own_languages(db, make_user):
make_user(email="hu.admin@example.com", role="admin", preferences={"language": "hu"})
make_user(email="en.admin@example.com", role="admin", preferences={"language": "en"})
groups = auth_routes.admin_notify_groups(db)
# One mail per language, not one blast that has to be wrong for somebody.
assert groups == {"hu": ["hu.admin@example.com"], "en": ["en.admin@example.com"]}
+58
View File
@@ -0,0 +1,58 @@
"""/api/me carries whether this instance can actually send email.
It drives the operator warnings on the Configuration and Access-requests screens. Admin-only,
because it is an operator concern and a regular user has nothing to do with it.
"""
from app import email as email_mod
_PW = "correct horse battery"
def _signed_in(client, make_user, **kw):
"""/api/me resolves its user through optional_current_user, which the as_user override does not
cover — so sign in for real and read the bootstrap payload the SPA actually gets."""
user = make_user(password=_PW, **kw)
assert (
client.post("/auth/password-login", json={"email": user.email, "password": _PW}).status_code
== 200
)
r = client.get("/api/me")
assert r.status_code == 200 and r.json()["authenticated"] is True
return r.json()
def test_an_admin_is_told_when_email_is_not_configured(client, make_user, monkeypatch):
monkeypatch.setattr(email_mod, "email_enabled", lambda db=None: False)
me = _signed_in(client, make_user, email="admin@example.com", role="admin")
assert me["email_configured"] is False
def test_an_admin_sees_nothing_wrong_when_email_works(client, make_user, monkeypatch):
monkeypatch.setattr(email_mod, "email_enabled", lambda db=None: True)
me = _signed_in(client, make_user, email="admin@example.com", role="admin")
assert me["email_configured"] is True
def test_a_regular_user_is_never_warned_about_the_mail_setup(client, make_user, monkeypatch):
# Not their problem, and the flag must not become a way to probe the instance's configuration.
monkeypatch.setattr(email_mod, "email_enabled", lambda db=None: False)
me = _signed_in(client, make_user, email="plain@example.com")
assert me["email_configured"] is True
def test_the_flag_reuses_the_request_session(client, make_user, monkeypatch):
# /api/me is polled once a minute per open tab; reading the SMTP settings decrypts a Fernet
# value, so it must not also check out a second pooled connection each time.
seen: list[object] = []
monkeypatch.setattr(email_mod, "email_enabled", lambda db=None: seen.append(db) or True)
_signed_in(client, make_user, email="admin@example.com", role="admin")
assert seen and seen[0] is not None
+495
View File
@@ -0,0 +1,495 @@
"""The playback-class filter (E7): filter the Plex feed by Direct Play / Direct Stream / Transcode·HW /
Transcode·SW. Covers the shared classifier + Stream parsing (unit), the sync derivation + codec-change
reset, the bit-depth backfill, and the /library filter + /facets counts (DB lane).
The HW/SW split is derived from the stored `transcode_hw` facet (codec at sync, refined by the bit-depth
backfill). These tests pin that a 10-bit/HDR HEVC lands in SW even though its codec is HW-decodable —
the exact case the runtime VAAPI path (plex.stream) also special-cases.
"""
import re
from pathlib import Path
import app.state as state
from app.auth import current_user
from app.db import get_db
from app.main import app
from app.models import PlexItem, PlexLibrary, User
from app.plex import sync
from fastapi.testclient import TestClient
class TestTranscodeHwCapable:
"""The single classifier both the catalog facet and (by codec) the runtime encoder choice key off."""
def test_non_transcode_items_are_none(self):
assert sync.transcode_hw_capable("direct", "h264") is None
assert sync.transcode_hw_capable("remux", "h264") is None
assert sync.transcode_hw_capable(None, "hevc") is None
def test_hw_decodable_8bit_sdr_is_hw(self):
assert sync.transcode_hw_capable("transcode", "hevc") is True
assert sync.transcode_hw_capable("transcode", "vp9", bit_depth=8) is True
def test_non_decodable_codec_is_sw(self):
# AV1 / MPEG-4 ASP (XviD) can't be HW-decoded on Gen 9.5 → software regardless of bit depth.
assert sync.transcode_hw_capable("transcode", "av1") is False
assert sync.transcode_hw_capable("transcode", "mpeg4", bit_depth=8) is False
def test_10bit_or_hdr_hevc_is_sw(self):
# A HW-decodable codec that is 10-bit or HDR still breaks the VAAPI nv12 pipeline → software.
assert sync.transcode_hw_capable("transcode", "hevc", bit_depth=10) is False
assert sync.transcode_hw_capable("transcode", "hevc", bit_depth=8, hdr=True) is False
class TestVideoStreamProps:
def test_reads_bit_depth_and_hdr_from_the_video_stream(self):
meta = {
"Media": [
{
"Part": [
{
"Stream": [
{"streamType": 2, "codec": "eac3"}, # audio — skipped
{"streamType": 1, "bitDepth": 10, "colorTrc": "smpte2084"},
]
}
]
}
]
}
assert sync._video_stream_props(meta) == (10, True)
def test_8bit_sdr_stream(self):
meta = {"Media": [{"Part": [{"Stream": [{"streamType": 1, "bitDepth": 8}]}]}]}
assert sync._video_stream_props(meta) == (8, False)
def test_absent_stream_is_none_false(self):
assert sync._video_stream_props({}) == (None, False)
assert sync._video_stream_props({"Media": [{"Part": [{}]}]}) == (None, False)
class TestSyncDerivation:
"""`_apply_item` sets transcode_hw codec-only at sync (bit depth unknown from the listing), and a
changed video codec resets the probe watermark so a stale bit-depth can't linger."""
def _meta(self, vcodec, *, key="/parts/1/file.mkv", file="/m.mkv"):
return {
"title": "X",
"Media": [{"videoCodec": vcodec, "Part": [{"file": file, "key": key}]}],
}
def test_codec_only_derivation_at_sync(self):
row = PlexItem(rating_key="x", kind="movie")
sync._apply_item(row, lib_id=1, kind="movie", meta=self._meta("hevc"))
assert row.playable == "transcode" and row.transcode_hw is True # decodable, 8-bit assumed
row2 = PlexItem(rating_key="y", kind="movie")
sync._apply_item(row2, lib_id=1, kind="movie", meta=self._meta("av1"))
assert row2.transcode_hw is False # av1 not HW-decodable
def test_probed_value_survives_resync_when_codec_unchanged(self):
row = PlexItem(rating_key="x", kind="movie")
sync._apply_item(row, lib_id=1, kind="movie", meta=self._meta("hevc"))
# Simulate the backfill downgrading this 10-bit HEVC to software.
row.transcode_hw = False
row.video_bit_depth = 10
row.media_probed_at = sync.datetime.now(sync.timezone.utc)
sync._apply_item(row, lib_id=1, kind="movie", meta=self._meta("hevc"))
assert row.transcode_hw is False # NOT re-derived back to True
assert row.video_bit_depth == 10 and row.media_probed_at is not None
def test_codec_change_resets_the_probe(self):
row = PlexItem(rating_key="x", kind="movie")
sync._apply_item(row, lib_id=1, kind="movie", meta=self._meta("hevc"))
row.transcode_hw = False
row.video_bit_depth = 10
row.media_probed_at = sync.datetime.now(sync.timezone.utc)
# The file was replaced with an h264 remux — the stale 10-bit probe must be cleared.
sync._apply_item(row, lib_id=1, kind="movie", meta=self._meta("h264"))
assert row.video_bit_depth is None and row.media_probed_at is None
def test_same_codec_reencode_with_a_new_part_resets_the_probe(self):
# A same-codec bit-depth re-encode (e.g. 8-bit→10-bit HEVC) keeps codec 'hevc' but replaces the
# physical file, so its Plex Part key changes. Codec-only reset would miss this and keep the stale
# 8-bit/HW verdict forever (the backfill only re-probes media_probed_at IS NULL rows) — the
# part-key move must reset the watermark so the new bit depth is re-probed.
row = PlexItem(rating_key="x", kind="movie")
sync._apply_item(row, lib_id=1, kind="movie", meta=self._meta("hevc", key="/parts/1/f.mkv"))
row.transcode_hw = True
row.video_bit_depth = 8
row.media_probed_at = sync.datetime.now(sync.timezone.utc)
# Re-encoded in place: same codec 'hevc', but a new Part key (new file).
sync._apply_item(row, lib_id=1, kind="movie", meta=self._meta("hevc", key="/parts/2/f.mkv"))
assert row.video_bit_depth is None and row.media_probed_at is None # re-probe scheduled
def test_migration_codec_set_matches_hw_decodable_codecs():
# Migration 0077 hardcodes the HW-decodable codec set in raw SQL and its comment mandates it stay
# identical to plex.sync.HW_DECODABLE_CODECS. A comment isn't enforcement (house rule) — pin the
# equality so adding/removing a codec from the frozenset without editing the migration fails here
# (else already-migrated prod rows keep a wrong transcode_hw the runtime derivation wouldn't catch).
mig = (
Path(__file__).resolve().parent.parent
/ "alembic"
/ "versions"
/ "0077_plex_playback_class.py"
)
m = re.search(r"lower\(codec_video\)\s+IN\s+\(([^)]*)\)", mig.read_text())
assert m, "could not find the codec IN(...) list in migration 0077"
codecs = {c.strip().strip("'\"").lower() for c in m.group(1).split(",")}
assert codecs == {c.lower() for c in sync.HW_DECODABLE_CODECS}
class _FakePlex:
"""Stands in for PlexClient.metadata — returns a canned full-metadata dict per rating_key."""
def __init__(self, by_key):
self.by_key = by_key
def metadata(self, rating_key, markers=False):
return self.by_key.get(rating_key)
def test_probe_media_batch_refines_hw_to_sw_for_10bit(db):
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
db.add(lib)
db.flush()
hevc8 = PlexItem(
rating_key="h8",
library_id=lib.id,
kind="movie",
title="8bit",
playable="transcode",
codec_video="hevc",
transcode_hw=True,
)
hevc10 = PlexItem(
rating_key="h10",
library_id=lib.id,
kind="movie",
title="10bit",
playable="transcode",
codec_video="hevc",
transcode_hw=True,
)
xvid = PlexItem(
rating_key="xv",
library_id=lib.id,
kind="movie",
title="xvid",
playable="transcode",
codec_video="mpeg4",
transcode_hw=False,
)
db.add_all([hevc8, hevc10, xvid])
db.commit()
plex = _FakePlex(
{
"h8": {"Media": [{"Part": [{"Stream": [{"streamType": 1, "bitDepth": 8}]}]}]},
"h10": {"Media": [{"Part": [{"Stream": [{"streamType": 1, "bitDepth": 10}]}]}]},
}
)
probed = sync._probe_media_batch(db, plex)
db.refresh(hevc8)
db.refresh(hevc10)
db.refresh(xvid)
# Only the two HW-decodable HEVC items are probed; the XviD is SW-by-codec and never fetched.
assert probed == 2
assert hevc8.video_bit_depth == 8 and hevc8.transcode_hw is True and hevc8.media_probed_at
assert hevc10.video_bit_depth == 10 and hevc10.transcode_hw is False # downgraded to software
assert xvid.media_probed_at is None # not in the work-list
# A second run has nothing left to do (watermarks set).
assert sync._probe_media_batch(db, plex) == 0
def test_probe_stamps_even_when_plex_has_no_stream(db):
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
db.add(lib)
db.flush()
item = PlexItem(
rating_key="ns",
library_id=lib.id,
kind="movie",
title="nostream",
playable="transcode",
codec_video="hevc",
transcode_hw=True,
)
db.add(item)
db.commit()
plex = _FakePlex({"ns": {"Media": [{"Part": [{}]}]}}) # no Stream array
assert sync._probe_media_batch(db, plex) == 1
db.refresh(item)
# Probed (watermark set so it isn't re-fetched forever); bit depth unknown → stays codec-HW.
assert item.media_probed_at is not None and item.video_bit_depth is None
assert item.transcode_hw is True
def test_probe_media_batch_is_bounded_and_oldest_first(db):
# The per-run bound (limit) caps Plex load; oldest-id-first means successive runs cover the whole
# backlog without starving any item. Seed 3 eligible items, probe with limit=2.
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
db.add(lib)
db.flush()
items = [
PlexItem(
rating_key=f"h{i}",
library_id=lib.id,
kind="movie",
title=f"m{i}",
playable="transcode",
codec_video="hevc",
transcode_hw=True,
)
for i in range(3)
]
db.add_all(items) # inserted in order → ascending ids
db.commit()
plex = _FakePlex(
{
f"h{i}": {"Media": [{"Part": [{"Stream": [{"streamType": 1, "bitDepth": 8}]}]}]}
for i in range(3)
}
)
assert sync._probe_media_batch(db, plex, limit=2) == 2 # exactly the bound
for it in items:
db.refresh(it)
# The two LOWEST-id rows were probed; the third waits for the next run.
assert items[0].media_probed_at is not None and items[1].media_probed_at is not None
assert items[2].media_probed_at is None
# The next run continues with the remaining item.
assert sync._probe_media_batch(db, plex, limit=2) == 1
db.refresh(items[2])
assert items[2].media_probed_at is not None
class _RaisingPlex:
"""metadata() raises for `bad_key`, returns a canned dict otherwise — to exercise the transient
fetch-failure branch (leave the watermark NULL so the item retries next run)."""
def __init__(self, by_key, bad_key):
self.by_key = by_key
self.bad_key = bad_key
def metadata(self, rating_key, markers=False):
if rating_key == self.bad_key:
raise RuntimeError("plex timeout")
return self.by_key.get(rating_key)
def test_probe_media_batch_leaves_a_transient_failure_unstamped_to_retry(db):
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
db.add(lib)
db.flush()
good = PlexItem(
rating_key="g",
library_id=lib.id,
kind="movie",
title="good",
playable="transcode",
codec_video="hevc",
transcode_hw=True,
)
bad = PlexItem(
rating_key="b",
library_id=lib.id,
kind="movie",
title="bad",
playable="transcode",
codec_video="hevc",
transcode_hw=True,
)
db.add_all([good, bad])
db.commit()
plex = _RaisingPlex(
{"g": {"Media": [{"Part": [{"Stream": [{"streamType": 1, "bitDepth": 10}]}]}]}}, bad_key="b"
)
# The raise on 'b' must not abort the batch: 'g' is still probed and counted; 'b' stays NULL.
assert sync._probe_media_batch(db, plex) == 1
db.refresh(good)
db.refresh(bad)
assert good.media_probed_at is not None and good.transcode_hw is False # 10-bit → SW
assert bad.media_probed_at is None # transient failure → retried next run, not stamped
def _seed_classes(db):
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
db.add(lib)
db.flush()
db.add_all(
[
PlexItem(
rating_key="d1", library_id=lib.id, kind="movie", title="Direct", playable="direct"
),
PlexItem(
rating_key="r1", library_id=lib.id, kind="movie", title="Remux", playable="remux"
),
PlexItem(
rating_key="thw",
library_id=lib.id,
kind="movie",
title="HW",
playable="transcode",
codec_video="hevc",
transcode_hw=True,
),
PlexItem(
rating_key="tsw",
library_id=lib.id,
kind="movie",
title="SW",
playable="transcode",
codec_video="mpeg4",
transcode_hw=False,
),
]
)
return lib
def _client(db, user, monkeypatch):
monkeypatch.setattr(state, "_configured_cache", True)
app.dependency_overrides[get_db] = lambda: db
app.dependency_overrides[current_user] = lambda: user
return TestClient(app)
def test_library_filters_by_playback_class(db, monkeypatch):
_seed_classes(db)
user = User(email="pclass@example.com")
db.add(user)
db.commit()
client = _client(db, user, monkeypatch)
try:
# Software-only transcodes.
sw = client.get("/api/plex/library?scope=movie&playable=transcode_sw")
assert sw.status_code == 200, sw.text
assert {i["id"] for i in sw.json()["items"]} == {"tsw"}
# HW transcode + Direct Play, OR-combined.
both = client.get("/api/plex/library?scope=movie&playable=transcode_hw,direct")
assert {i["id"] for i in both.json()["items"]} == {"thw", "d1"}
# 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")
assert card["transcode_hw"] is True
# No filter → everything.
allm = client.get("/api/plex/library?scope=movie")
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):
_seed_classes(db)
user = User(email="pclassfacet@example.com")
db.add(user)
db.commit()
client = _client(db, user, monkeypatch)
try:
resp = client.get("/api/plex/facets?scope=movie")
finally:
app.dependency_overrides.clear()
assert resp.status_code == 200, resp.text
playable = resp.json()["playable"]
# Fixed good→heavy order (not by count), one per seeded class.
assert [p["value"] for p in playable] == ["direct", "remux", "transcode_hw", "transcode_sw"]
assert {p["value"]: p["count"] for p in playable} == {
"direct": 1,
"remux": 1,
"transcode_hw": 1,
"transcode_sw": 1,
}
def test_unprobed_transcode_folds_into_sw(db, monkeypatch):
# 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
# software. Pin that fold for BOTH the filter and the facet, so a reorder can't silently drop the
# item from the feed (an honest-state violation).
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
db.add(lib)
db.flush()
db.add(
PlexItem(
rating_key="tnull",
library_id=lib.id,
kind="movie",
title="Unprobed",
playable="transcode",
codec_video="hevc",
transcode_hw=None,
)
)
user = User(email="pnull@example.com")
db.add(user)
db.commit()
client = _client(db, user, monkeypatch)
try:
sw = client.get("/api/plex/library?scope=movie&playable=transcode_sw")
assert {i["id"] for i in sw.json()["items"]} == {"tnull"} # NULL → SW bucket
hw = client.get("/api/plex/library?scope=movie&playable=transcode_hw")
assert hw.json()["items"] == [] # never HW
facets = client.get("/api/plex/facets?scope=movie")
counts = {p["value"]: p["count"] for p in facets.json()["playable"]}
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):
# 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
# 'direct' must still return all four classes at their full, unnarrowed counts.
_seed_classes(db)
user = User(email="pself@example.com")
db.add(user)
db.commit()
client = _client(db, user, monkeypatch)
try:
resp = client.get("/api/plex/facets?scope=movie&playable=direct")
finally:
app.dependency_overrides.clear()
assert resp.status_code == 200, resp.text
counts = {p["value"]: p["count"] for p in resp.json()["playable"]}
assert counts == {"direct": 1, "remux": 1, "transcode_hw": 1, "transcode_sw": 1}
def test_active_playable_narrows_the_other_facets(db, monkeypatch):
# 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
# software-transcode movie (2020); selecting playable=direct must collapse the year facet to 2000.
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
db.add(lib)
db.flush()
db.add_all(
[
PlexItem(
rating_key="d2000",
library_id=lib.id,
kind="movie",
title="Direct2000",
playable="direct",
year=2000,
),
PlexItem(
rating_key="s2020",
library_id=lib.id,
kind="movie",
title="SW2020",
playable="transcode",
codec_video="mpeg4",
transcode_hw=False,
year=2020,
),
]
)
user = User(email="pnarrow@example.com")
db.add(user)
db.commit()
client = _client(db, user, monkeypatch)
try:
wide = client.get("/api/plex/facets?scope=movie").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 (narrow["year_min"], narrow["year_max"]) == (2000, 2000) # narrowed to the Direct subset
+107
View File
@@ -0,0 +1,107 @@
"""The playback-session route's mode routing (E6 S1b).
`POST /stream/{rk}/session` is the single switch that decides how an item plays: a browser-native
file is served raw (`mode:"direct"`), everything else (re)starts an HLS session — and a
`playable="transcode"` item (non-h264: HEVC/VP9/AV1/…) must take that HLS path with the video
RE-ENCODED, not stream-copied. That routing replaced a former `HTTP 501`, and the only thing that
tells `start_session` to re-encode is the `transcode=` flag this route derives from `it.playable`.
The command-builder itself is unit-tested in `test_plex_stream.py`; here we pin the ROUTE decision
(no ffmpeg spawned — `start_session` is stubbed to echo the flag it was handed).
DB lane (`db` fixture) — skipped when no Postgres is reachable.
"""
from types import SimpleNamespace
from fastapi.testclient import TestClient
import app.state as state
from app.auth import current_user
from app.db import get_db
from app.main import app
from app.models import PlexItem, PlexLibrary, User
from app.plex import paths as plex_paths
from app.plex import stream as plex_stream
def _item(db, *, playable, rating_key="m1"):
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
db.add(lib)
db.flush()
it = PlexItem(
rating_key=rating_key,
library_id=lib.id,
kind="movie",
title="A Movie",
file_path="/media/a.mkv",
playable=playable,
)
db.add(it)
db.commit()
return it
def _user(db, email="play@example.com"):
u = User(email=email)
db.add(u)
db.flush()
return u
def _post(db, user, monkeypatch, rating_key, *, captured):
"""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."""
monkeypatch.setattr(state, "_configured_cache", True) # pass the request gate
monkeypatch.setattr(plex_stream, "reap_idle", lambda: None)
monkeypatch.setattr(plex_paths, "local_media_path", lambda db_, fp: "/local/a.mkv")
def fake_start(db_, it, start, audio, aoff, multi, transcode=False):
captured["transcode"] = transcode
return SimpleNamespace(
media_start_s=start, entry="index.m3u8", transcode=transcode, hw=False, tonemap=False
)
monkeypatch.setattr(plex_stream, "start_session", fake_start)
app.dependency_overrides[get_db] = lambda: db
app.dependency_overrides[current_user] = lambda: user
try:
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):
# A non-h264 item (formerly HTTP 501) now plays: mode=hls, transcode=True threaded to start_session.
it = _item(db, playable="transcode")
user = _user(db)
captured: dict = {}
resp = _post(db, user, monkeypatch, it.rating_key, captured=captured)
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["mode"] == "hls"
assert body["transcode"] is True
assert captured["transcode"] is True # start_session was told to re-encode
def test_remux_item_stays_a_stream_copy_session(db, monkeypatch):
# A remux (h264) item takes the SAME HLS path but must NOT re-encode.
it = _item(db, playable="remux", rating_key="m2")
user = _user(db, "remux@example.com")
captured: dict = {}
resp = _post(db, user, monkeypatch, it.rating_key, captured=captured)
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["mode"] == "hls"
assert body["transcode"] is False
assert captured["transcode"] is False
def test_direct_item_never_opens_a_session(db, monkeypatch):
# A browser-native file is served raw — start_session is never reached, so no transcode decision.
it = _item(db, playable="direct", rating_key="m3")
user = _user(db, "direct@example.com")
captured: dict = {}
resp = _post(db, user, monkeypatch, it.rating_key, captured=captured)
assert resp.status_code == 200, resp.text
assert resp.json()["mode"] == "direct"
assert "transcode" not in captured # start_session not called
+647 -4
View File
@@ -8,6 +8,7 @@ Since the sweep stopped being awaited at startup it also runs CONCURRENTLY with
second half of this file pins what keeps it off a LIVE session's segments."""
import os
from pathlib import Path
import pytest
@@ -164,10 +165,308 @@ class _FakeProc:
class _FakeItem:
def __init__(self, rating_key="12345", file_path="/media/movie.mkv"):
def __init__(self, rating_key="12345", file_path="/media/movie.mkv", codec_video="h264"):
self.rating_key = rating_key
self.file_path = file_path
self.playable = "remux"
self.codec_video = codec_video
class _CapturingProc:
"""Records the argv + kwargs each Popen was called with, then behaves like the ffmpeg proc."""
calls: list[tuple] = []
def __init__(self, *args, **kwargs):
_CapturingProc.calls.append((args, kwargs))
def poll(self):
return None
def _venc(cmd):
"""The video codec ffmpeg was told to use (the value after the first `-c:v`)."""
return cmd[cmd.index("-c:v") + 1]
class TestTranscodeCommand:
"""The transcode video re-encode (playable='transcode'): the SAME HLS command as remux with the
video encoder swapped in for `-c:v copy`. The HW path can only be exercised on prod (VAAPI needs
Linux + Intel /dev/dri), so its SHAPE is pinned here; the software path is the one UAT'd locally."""
SRC = Path("/media/movie.mkv")
OUT = Path("/out")
def test_remux_still_stream_copies_the_video(self):
cmd = stream._ffmpeg_cmd(self.SRC, 0.0, self.OUT, None)
assert _venc(cmd) == "copy"
assert "libx264" not in cmd and "h264_vaapi" not in cmd
assert "-hwaccel" not in cmd # no GPU decode on the copy path
assert "-force_key_frames" not in cmd # copy leans on the source's own keyframes
def test_software_transcode_uses_libx264_and_mints_keyframes(self):
cmd = stream._ffmpeg_cmd(self.SRC, 0.0, self.OUT, None, transcode=True, hw=False)
assert _venc(cmd) == "libx264"
assert "copy" not in cmd # the video is no longer stream-copied
# 8-bit High (yuv420p), not High10, so any browser decodes a 10-bit HEVC source.
assert cmd[cmd.index("-pix_fmt") + 1] == "yuv420p"
assert "-threads" in cmd # a core reserved for request handling / playback
assert "-hwaccel" not in cmd # software path: CPU decode, no render node needed
# A forced keyframe at every segment boundary so the HLS muxer cuts independent segments.
assert (
cmd[cmd.index("-force_key_frames") + 1] == f"expr:gte(t,n_forced*{stream._SEG_SECONDS})"
)
def test_hardware_transcode_uses_the_full_vaapi_pipeline(self):
cmd = stream._ffmpeg_cmd(self.SRC, 0.0, self.OUT, None, transcode=True, hw=True)
assert _venc(cmd) == "h264_vaapi"
assert "libx264" not in cmd
# GPU decode options must precede the input so the source is decoded on-device.
assert cmd[cmd.index("-hwaccel") + 1] == "vaapi"
assert "-hwaccel_output_format" in cmd
assert cmd[cmd.index("-vaapi_device") + 1] == stream._VAAPI_DEVICE
assert cmd.index("-hwaccel") < cmd.index("-i")
# scale_vaapi=format=nv12 downconverts a 10-bit source to 8-bit nv12 for h264_vaapi.
assert "scale_vaapi=format=nv12" in cmd
assert (
cmd[cmd.index("-force_key_frames") + 1] == f"expr:gte(t,n_forced*{stream._SEG_SECONDS})"
)
def test_multi_audio_transcode_reencodes_the_video_variant(self):
cmd = stream._ffmpeg_cmd(
self.SRC, 0.0, self.OUT, None, audio_langs=["eng", "hun"], transcode=True, hw=False
)
assert _venc(cmd) == "libx264" # video re-encoded…
assert "-var_stream_map" in cmd # …but still multi-rendition (client-side audio switch)
assert "copy" not in cmd
def test_software_preset_and_crf_come_from_the_knobs(self):
cmd = stream._ffmpeg_cmd(
self.SRC, 0.0, self.OUT, None, transcode=True, hw=False, preset="slow", crf=18
)
assert cmd[cmd.index("-preset") + 1] == "slow"
assert cmd[cmd.index("-crf") + 1] == "18"
def test_software_max_height_caps_with_a_downscale_only_filter(self):
cmd = stream._ffmpeg_cmd(
self.SRC, 0.0, self.OUT, None, transcode=True, hw=False, max_height=720
)
# Downscale-only, aspect-preserving, even width; the comma in min() escaped so it's ONE filter.
assert cmd[cmd.index("-vf") + 1] == "scale=-2:min(ih\\,720)"
def test_software_max_height_zero_means_no_scale_filter(self):
cmd = stream._ffmpeg_cmd(
self.SRC, 0.0, self.OUT, None, transcode=True, hw=False, max_height=0
)
assert "-vf" not in cmd # source resolution kept
def test_hardware_ignores_max_height(self):
# The GPU encode is cheap, so it runs at source resolution; max_height is a software-only knob.
cmd = stream._ffmpeg_cmd(
self.SRC, 0.0, self.OUT, None, transcode=True, hw=True, max_height=720
)
assert cmd[cmd.index("-vf") + 1] == "scale_vaapi=format=nv12" # not a software scale
def test_hdr_tonemap_uses_the_opencl_graph(self):
# The HDR/10-bit HW path: VAAPI decode → OpenCL tonemap → VAAPI encode. Gen 9.5 can't tonemap in
# the VAAPI VPP, so this is the only HW route for a 10-bit/HDR source.
cmd = stream._ffmpeg_cmd(
self.SRC, 0.0, self.OUT, None, transcode=True, hw=True, tonemap=True
)
assert _venc(cmd) == "h264_vaapi"
assert "libx264" not in cmd
# Named devices + an OpenCL device DERIVED from the VAAPI one, all before -i.
assert cmd[cmd.index("-init_hw_device") + 1] == f"vaapi=va:{stream._VAAPI_DEVICE}"
assert "opencl=ocl@va" in cmd
assert cmd[cmd.index("-hwaccel_device") + 1] == "va"
assert cmd.index("-init_hw_device") < cmd.index("-i")
# The filter graph runs on the derived OpenCL device and tone-maps HDR→SDR to 8-bit nv12.
assert cmd[cmd.index("-filter_hw_device") + 1] == "ocl"
vf = cmd[cmd.index("-vf") + 1]
assert "hwmap=derive_device=opencl" in vf
assert "tonemap_opencl=" in vf and "format=nv12" in vf
assert "hwmap=derive_device=vaapi:reverse=1" in vf
assert "scale_vaapi" not in vf # the plain 8-bit path's filter must NOT be used here
assert (
cmd[cmd.index("-force_key_frames") + 1] == f"expr:gte(t,n_forced*{stream._SEG_SECONDS})"
)
class TestWantHw:
"""VAAPI is used only where the render node was passed in AND (in auto) the source is HW-decodable,
with an admin `plex_transcode_hw` policy (auto/on/off) on top. A HW session on a 10-bit/HDR source
tone-maps on the GPU via OpenCL (tonemap=True) when the OpenCL runtime is present, else drops to
software — see the `_select_encode` cases below."""
SRC = Path("/media/movie.mkv")
@staticmethod
def _policy(monkeypatch, mode):
monkeypatch.setattr(stream.sysconfig, "get_str", lambda db, k: mode)
@staticmethod
def _kind(monkeypatch, kind):
# Pin the source classifier so the tests don't shell out to ffprobe. `kind` is one of
# stream._VK_HWSAFE / _VK_HDR / _VK_SDR10.
monkeypatch.setattr(stream, "_probe_video_kind", lambda src: kind)
@staticmethod
def _hwsafe(monkeypatch, safe):
# Back-compat convenience for the hw-gate tests that only care about 8-bit-SDR vs not: True →
# _VK_HWSAFE, False → _VK_SDR10 (a non-HDR non-hwsafe class, so the hw half still resolves).
monkeypatch.setattr(
stream, "_probe_video_kind", lambda src: stream._VK_HWSAFE if safe else stream._VK_SDR10
)
@staticmethod
def _opencl(monkeypatch, present):
# Pin the OpenCL-runtime probe so the routing doesn't depend on the CI box's /etc/OpenCL.
monkeypatch.setattr(stream, "_opencl_present", lambda: present)
def test_no_hw_when_not_transcoding(self, monkeypatch):
self._policy(monkeypatch, "auto")
monkeypatch.setattr(stream, "_render_node_present", lambda: True)
assert (
stream._want_hw(None, _FakeItem(codec_video="hevc"), transcode=False, src=self.SRC)
is False
)
def test_no_hw_without_a_render_node(self, monkeypatch):
self._policy(monkeypatch, "auto")
monkeypatch.setattr(stream, "_render_node_present", lambda: False)
assert (
stream._want_hw(None, _FakeItem(codec_video="hevc"), transcode=True, src=self.SRC)
is False
)
def test_auto_uses_hw_for_a_decodable_8bit_source_with_the_render_node(self, monkeypatch):
self._policy(monkeypatch, "auto")
self._hwsafe(monkeypatch, True)
monkeypatch.setattr(stream, "_render_node_present", lambda: True)
assert (
stream._want_hw(None, _FakeItem(codec_video="hevc"), transcode=True, src=self.SRC)
is True
)
def test_auto_falls_back_to_software_for_a_non_decodable_source(self, monkeypatch):
# AV1 / MPEG-4 ASP (XviD) aren't decodable on Gen 9.5 → the GPU can't feed its own encoder.
self._policy(monkeypatch, "auto")
self._hwsafe(monkeypatch, True) # decodability gate must fire BEFORE the probe
monkeypatch.setattr(stream, "_render_node_present", lambda: True)
assert (
stream._want_hw(None, _FakeItem(codec_video="av1"), transcode=True, src=self.SRC)
is False
)
assert (
stream._want_hw(None, _FakeItem(codec_video="mpeg4"), transcode=True, src=self.SRC)
is False
)
def test_auto_hdr_source_falls_back_to_software_without_opencl(self, monkeypatch):
# A decodable HEVC that is true-HDR (PQ/HLG) can't use scale_vaapi=format=nv12 (Gen 9.5), and with
# no OpenCL runtime to tonemap on the GPU it drops to software libx264 (BUG 1's behaviour).
self._policy(monkeypatch, "auto")
self._kind(monkeypatch, stream._VK_HDR)
self._opencl(monkeypatch, False)
monkeypatch.setattr(stream, "_render_node_present", lambda: True)
assert stream._select_encode(
None, _FakeItem(codec_video="hevc"), transcode=True, src=self.SRC
) == (False, False)
def test_auto_hdr_source_uses_hw_tonemap_with_opencl(self, monkeypatch):
# With the OpenCL runtime present, a true-HDR HEVC tone-maps on the GPU (VAAPI decode → OpenCL
# tonemap → VAAPI encode) instead of dropping to CPU — the HW HDR path.
self._policy(monkeypatch, "auto")
self._kind(monkeypatch, stream._VK_HDR)
self._opencl(monkeypatch, True)
monkeypatch.setattr(stream, "_render_node_present", lambda: True)
assert stream._select_encode(
None, _FakeItem(codec_video="hevc"), transcode=True, src=self.SRC
) == (True, True)
def test_auto_10bit_SDR_source_uses_software_not_tonemap(self, monkeypatch):
# F1 regression: a 10-bit *SDR* (bt709) source is NOT HDR, so it must NOT get the hable HDR tone
# curve even when the OpenCL runtime is present — that would visibly alter an SDR picture. It takes
# software libx264 (an exact 10→8-bit downconvert), never (True, True).
self._policy(monkeypatch, "auto")
self._kind(monkeypatch, stream._VK_SDR10)
self._opencl(monkeypatch, True)
monkeypatch.setattr(stream, "_render_node_present", lambda: True)
assert stream._select_encode(
None, _FakeItem(codec_video="hevc"), transcode=True, src=self.SRC
) == (False, False)
def test_auto_8bit_source_uses_plain_hw_not_tonemap(self, monkeypatch):
# An 8-bit SDR source takes the plain full-GPU scale path — never the tonemap graph — even where
# OpenCL is available.
self._policy(monkeypatch, "auto")
self._kind(monkeypatch, stream._VK_HWSAFE)
self._opencl(monkeypatch, True)
monkeypatch.setattr(stream, "_render_node_present", lambda: True)
assert stream._select_encode(
None, _FakeItem(codec_video="hevc"), transcode=True, src=self.SRC
) == (True, False)
def test_off_forces_software_even_for_a_decodable_source(self, monkeypatch):
self._policy(monkeypatch, "off")
monkeypatch.setattr(stream, "_render_node_present", lambda: True)
assert (
stream._want_hw(None, _FakeItem(codec_video="hevc"), transcode=True, src=self.SRC)
is False
)
def test_on_forces_hw_and_skips_both_source_gates(self, monkeypatch):
# The admin override trusts the GPU; skips the decodability AND the bit-depth gate (the runtime
# HW→SW fallback still backstops it). Render node still required — HW is impossible without it.
self._policy(monkeypatch, "on")
self._hwsafe(monkeypatch, False) # would veto in auto; "on" ignores it
self._opencl(
monkeypatch, False
) # pin: tonemap-half is irrelevant here, keep it deterministic
monkeypatch.setattr(stream, "_render_node_present", lambda: True)
assert (
stream._want_hw(None, _FakeItem(codec_video="av1"), transcode=True, src=self.SRC)
is True
)
monkeypatch.setattr(stream, "_render_node_present", lambda: False)
assert (
stream._want_hw(None, _FakeItem(codec_video="hevc"), transcode=True, src=self.SRC)
is False
)
def test_on_still_picks_the_tonemap_graph_for_an_hdr_source(self, monkeypatch):
# "on" trusts the GPU but must still route a true-HDR source through the tonemap graph (plain
# scale_vaapi would hit the VPP wall) when OpenCL is present.
self._policy(monkeypatch, "on")
self._kind(monkeypatch, stream._VK_HDR)
self._opencl(monkeypatch, True)
monkeypatch.setattr(stream, "_render_node_present", lambda: True)
assert stream._select_encode(
None, _FakeItem(codec_video="hevc"), transcode=True, src=self.SRC
) == (True, True)
def test_on_10bit_SDR_uses_plain_hw_not_tonemap(self, monkeypatch):
# F1 regression under "on": a 10-bit SDR source is NOT HDR, so even with OpenCL present "on" must
# pick plain HW (True, False) — the HDR tone curve must never touch an SDR picture.
self._policy(monkeypatch, "on")
self._kind(monkeypatch, stream._VK_SDR10)
self._opencl(monkeypatch, True)
monkeypatch.setattr(stream, "_render_node_present", lambda: True)
assert stream._select_encode(
None, _FakeItem(codec_video="hevc"), transcode=True, src=self.SRC
) == (True, False)
def test_on_without_opencl_is_plain_hw(self, monkeypatch):
# "on" with no OpenCL runtime → plain HW, tonemap=False, regardless of source class (the runtime
# HW→SW fallback backstops a source the plain path can't actually handle). Pins the tonemap half
# of the "on" tuple, which test_on_forces_hw_and_skips_both_source_gates only checks via _want_hw.
self._policy(monkeypatch, "on")
self._kind(monkeypatch, stream._VK_HDR) # even HDR: no runtime → can't tonemap on the GPU
self._opencl(monkeypatch, False)
monkeypatch.setattr(stream, "_render_node_present", lambda: True)
assert stream._select_encode(
None, _FakeItem(codec_video="hevc"), transcode=True, src=self.SRC
) == (True, False)
class TestColdStartWarmup:
@@ -190,7 +489,7 @@ class TestColdStartWarmup:
def test_warmup_waits_for_the_first_few_segments(self, fake_env, monkeypatch):
waited = []
def spy(path, timeout=20.0):
def spy(path, timeout=20.0, proc=None):
waited.append(path.name)
return True
@@ -203,7 +502,7 @@ class TestColdStartWarmup:
def test_warmup_stops_at_the_first_segment_that_isnt_ready(self, fake_env, monkeypatch):
waited = []
def spy(path, timeout=20.0):
def spy(path, timeout=20.0, proc=None):
waited.append(path.name)
return path.name != "seg_1.ts" # seg_1 never appears → warm-up gives up there
@@ -217,7 +516,7 @@ class TestColdStartWarmup:
# still returned so the client's own segment requests can drive it, as before.
waited = []
def spy(path, timeout=20.0):
def spy(path, timeout=20.0, proc=None):
waited.append(path.name)
return False
@@ -225,3 +524,347 @@ class TestColdStartWarmup:
s = stream.start_session(db=None, item=_FakeItem(), start_s=0.0)
assert s is not None
assert waited == ["seg_0.ts"]
class TestTranscodeStartSession:
"""The seam TestTranscodeCommand can't reach: start_session reading the three admin knobs
(plex_transcode_preset/crf/max_height) and threading them + transcode/hw into the ffmpeg argv and
the HlsSession. A swapped knob here is a SILENT mis-encode (still plays), so pin the whole wiring
end-to-end with a real (captured) Popen argv — not just _ffmpeg_cmd called with literals."""
@pytest.fixture
def capturing_env(self, tmp_path, monkeypatch):
monkeypatch.setattr(stream, "_HLS_ROOT", tmp_path)
monkeypatch.setattr(stream, "_sessions", {})
src = tmp_path / "movie.mkv"
src.write_bytes(b"x")
monkeypatch.setattr(stream.paths, "local_media_path", lambda db, fp: src)
# Distinct sentinels per knob so a crf<->max_height swap (both ints, both via get_int) fails.
monkeypatch.setattr(
stream.sysconfig,
"get_int",
lambda db, k: {
"plex_transcode_crf": 18,
"plex_transcode_max_height": 480,
"plex_max_transcodes": 4,
}[k],
)
monkeypatch.setattr(
stream.sysconfig,
"get_str",
lambda db, k: {"plex_transcode_preset": "slow"}.get(k, "auto"),
)
monkeypatch.setattr(stream, "wait_for", lambda path, timeout=20.0, proc=None: True)
_CapturingProc.calls = []
monkeypatch.setattr(stream.subprocess, "Popen", _CapturingProc)
return tmp_path
def test_software_transcode_threads_the_knobs_into_the_command(
self, capturing_env, monkeypatch
):
monkeypatch.setattr(
stream, "_select_encode", lambda db, item, transcode, src: (False, False)
)
s = stream.start_session(
db=None, 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
(cmd, *_), kwargs = _CapturingProc.calls[0]
assert _venc(cmd) == "libx264"
assert cmd[cmd.index("-preset") + 1] == "slow" # plex_transcode_preset
assert cmd[cmd.index("-crf") + 1] == "18" # plex_transcode_crf, NOT max_height
assert cmd[cmd.index("-vf") + 1] == "scale=-2:min(ih\\,480)" # plex_transcode_max_height
# The CPU-bound child is niced via preexec_fn on POSIX (the API process itself must not be).
if os.name == "posix":
assert kwargs.get("preexec_fn") is stream._sw_child_preexec
def test_hardware_transcode_uses_the_gpu_and_never_nices(self, capturing_env, monkeypatch):
monkeypatch.setattr(
stream, "_select_encode", lambda db, item, transcode, src: (True, False)
)
s = stream.start_session(
db=None, 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 len(_CapturingProc.calls) == 1 # first segment arrived — no HW→SW fallback respawn
(cmd, *_), kwargs = _CapturingProc.calls[0]
assert _venc(cmd) == "h264_vaapi"
assert "libx264" not in cmd
# Plain-HW path unconditionally emits the nv12 downconvert (a disjunction on "-vf not in cmd"
# would silently pass if the filter were dropped — assert it is actually present).
assert cmd[cmd.index("-vf") + 1] == "scale_vaapi=format=nv12" # no SW cap
assert kwargs.get("preexec_fn") is None # GPU encode is cheap — never niced
def test_hdr_tonemap_transcode_threads_the_opencl_graph_and_flags_the_session(
self, capturing_env, monkeypatch
):
# A HW tonemap session (10-bit/HDR + OpenCL) must build the OpenCL graph in the argv and carry
# 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))
s = stream.start_session(
db=None, 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
(cmd, *_), kwargs = _CapturingProc.calls[0]
assert _venc(cmd) == "h264_vaapi"
assert "opencl=ocl@va" in cmd and cmd[cmd.index("-filter_hw_device") + 1] == "ocl"
assert "tonemap_opencl=" in cmd[cmd.index("-vf") + 1]
assert kwargs.get("preexec_fn") is None
def test_a_hw_session_that_produces_nothing_falls_back_to_software(
self, capturing_env, monkeypatch
):
# BUG 1 backstop: _want_hw picked HW but the VAAPI session emits no first segment (a VAProfile
# refusal the gates didn't catch). start_session must respawn on libx264 and report hw=False so
# the item still plays and the route/player honestly show CPU.
monkeypatch.setattr(
stream, "_select_encode", lambda db, item, transcode, src: (True, False)
)
seen = {"n": 0}
def flaky_wait(path, timeout=20.0, proc=None):
seen["n"] += 1
return seen["n"] > 1 # the first seg-0 wait (HW) fails; every wait after succeeds (SW)
monkeypatch.setattr(stream, "wait_for", flaky_wait)
s = stream.start_session(
db=None, 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
) # honest CPU after the fallback
assert len(_CapturingProc.calls) == 2 # the HW attempt, then the SW respawn
(hw_cmd, *_), _ = _CapturingProc.calls[0]
(sw_cmd, *_), sw_kwargs = _CapturingProc.calls[1]
assert _venc(hw_cmd) == "h264_vaapi"
assert _venc(sw_cmd) == "libx264"
if os.name == "posix":
assert sw_kwargs.get("preexec_fn") is stream._sw_child_preexec # the SW child is niced
def test_a_hw_TONEMAP_session_that_produces_nothing_falls_back_to_software(
self, capturing_env, monkeypatch
):
# The fallback gate is `not ready and s.hw`, shared by plain-HW and tonemap sessions — but only
# the plain-HW variant was covered. Pin the tonemap variant too: a (True, True) session that
# emits nothing (an OpenCL/tonemap init refusal) must respawn on libx264 and end hw=False,
# tonemap=False, so an HDR item still plays on CPU.
monkeypatch.setattr(stream, "_select_encode", lambda db, item, transcode, src: (True, True))
seen = {"n": 0}
def flaky_wait(path, timeout=20.0, proc=None):
seen["n"] += 1
return (
seen["n"] > 1
) # the first seg-0 wait (HW tonemap) fails; every wait after succeeds
monkeypatch.setattr(stream, "wait_for", flaky_wait)
s = stream.start_session(
db=None, 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 len(_CapturingProc.calls) == 2 # the HW tonemap attempt, then the SW respawn
(hw_cmd, *_), _ = _CapturingProc.calls[0]
(sw_cmd, *_), _ = _CapturingProc.calls[1]
assert (
"tonemap_opencl=" in hw_cmd[hw_cmd.index("-vf") + 1]
) # first attempt built the OpenCL graph
assert _venc(sw_cmd) == "libx264" # respawn is plain software, no tonemap
def test_a_superseded_hw_session_does_not_respawn_over_the_newer_one(
self, capturing_env, monkeypatch
):
# F2/race: start_session waits on seg_0 OUTSIDE _lock. If a concurrent same-key start (a rapid
# seek-restart) or _enforce_cap replaces _sessions[key] while we wait, our proc dying must NOT
# trigger the SW fallback — that would tear down the NEWER session and breach the transcode cap.
# The identity guard must detect it's been superseded and yield the currently-registered session.
monkeypatch.setattr(
stream, "_select_encode", lambda db, item, transcode, src: (True, False)
)
class _Newer: # stands in for the session a concurrent _spawn registered
hw = True
tonemap = False
transcode = True
last_access = 0.0
newer = _Newer()
def wait_then_supersede(path, timeout=20.0, proc=None):
# Simulate the concurrent replacement happening during our out-of-lock wait, then report the
# (now-killed) HW proc produced nothing.
stream._sessions["12345"] = newer
return False
monkeypatch.setattr(stream, "wait_for", wait_then_supersede)
s = stream.start_session(
db=None, 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.
assert len(_CapturingProc.calls) == 1
assert s is newer # yielded the currently-registered session, did not clobber it
assert stream._sessions["12345"] is newer
def test_a_cap_culled_hw_session_does_not_respawn_and_is_not_reported_missing(
self, capturing_env, monkeypatch
):
# F2/race, the cull sub-case: while we wait on seg_0 out of lock, cross-key cap pressure lets
# _enforce_cap evict THIS session's key entirely (no replacement). The fallback must still not
# respawn (that would re-breach the cap the cull just enforced), and must NOT return None — a None
# is turned by the route into a dishonest "file not found" 404 for a file that exists. It yields
# our own (now-dead) session instead, so the client's segment requests degrade to the retryable
# "not available yet" path.
monkeypatch.setattr(
stream, "_select_encode", lambda db, item, transcode, src: (True, False)
)
captured = {}
def wait_then_cull(path, timeout=20.0, proc=None):
captured["s"] = stream._sessions[
"12345"
] # the HW session the initial _spawn just registered
del stream._sessions[
"12345"
] # simulate _enforce_cap dropping the LRU under cap pressure
return False
monkeypatch.setattr(stream, "wait_for", wait_then_cull)
s = stream.start_session(
db=None, item=_FakeItem(codec_video="hevc"), start_s=0.0, transcode=True
)
assert (
len(_CapturingProc.calls) == 1
) # no SW respawn — the cull is respected, cap not re-breached
assert (
s is captured["s"]
) # our own session yielded, NOT None (which the route would 404 as missing)
assert "12345" not in stream._sessions # the culled key stays gone
def test_remux_reads_no_transcode_knobs_and_stream_copies(self, capturing_env, monkeypatch):
# A remux start must stream-copy the video and spawn no niced child (the transcode knobs and
# the preexec nice are gated behind `transcode=True`).
monkeypatch.setattr(
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
assert s is not None and s.transcode is False
(cmd, *_), kwargs = _CapturingProc.calls[0]
assert _venc(cmd) == "copy"
assert kwargs.get("preexec_fn") is None
class TestProbeVideoKind:
"""The ffprobe-based source classifier that routes the transcode pipeline: 8-bit SDR → plain VAAPI;
true-HDR (PQ/HLG) → the OpenCL tonemap graph; 10-bit *SDR* → software (an exact downconvert, NEVER
the HDR tone curve — the F1 distinction). Exercises the REAL function body (not a monkeypatch)."""
@staticmethod
def _probe(monkeypatch, stdout):
class _R:
def __init__(self):
self.stdout = stdout
monkeypatch.setattr(stream.subprocess, "run", lambda *a, **k: _R())
def test_8bit_sdr_is_hwsafe(self, monkeypatch):
self._probe(monkeypatch, "pix_fmt=yuv420p\nbits_per_raw_sample=8\ncolor_transfer=bt709\n")
assert stream._probe_video_kind(Path("/x")) == stream._VK_HWSAFE
def test_10bit_sdr_pix_fmt_is_sdr10(self, monkeypatch):
# yuv420p10le with a non-HDR transfer (bt2020-10 is NOT PQ/HLG) = 10-bit SDR → software, not
# tonemap. Signalled by the pix_fmt suffix even when bits_per_raw_sample is absent.
self._probe(monkeypatch, "pix_fmt=yuv420p10le\ncolor_transfer=bt2020-10\n")
assert stream._probe_video_kind(Path("/x")) == stream._VK_SDR10
def test_bits_per_raw_sample_over_8_sdr_is_sdr10(self, monkeypatch):
self._probe(monkeypatch, "pix_fmt=yuv420p\nbits_per_raw_sample=10\ncolor_transfer=bt709\n")
assert stream._probe_video_kind(Path("/x")) == stream._VK_SDR10
def test_pq_transfer_is_hdr(self, monkeypatch):
# PQ (smpte2084) is true HDR — needs a real tonemap, even if only 8-bit-tagged.
self._probe(
monkeypatch, "pix_fmt=yuv420p10le\nbits_per_raw_sample=10\ncolor_transfer=smpte2084\n"
)
assert stream._probe_video_kind(Path("/x")) == stream._VK_HDR
def test_hlg_transfer_is_hdr(self, monkeypatch):
# HLG (arib-std-b67) is the other HDR transfer — previously untested; it must classify as HDR.
self._probe(
monkeypatch,
"pix_fmt=yuv420p10le\nbits_per_raw_sample=10\ncolor_transfer=arib-std-b67\n",
)
assert stream._probe_video_kind(Path("/x")) == stream._VK_HDR
def test_empty_probe_output_is_hwsafe(self, monkeypatch):
# A probe that returns nothing (no streams / stripped metadata) → assume hwsafe; the runtime
# HW→SW fallback backstops a wrong guess.
self._probe(monkeypatch, "")
assert stream._probe_video_kind(Path("/x")) == stream._VK_HWSAFE
def test_probe_failure_assumes_hwsafe(self, monkeypatch):
# ffprobe missing/erroring → assume hwsafe; the runtime HW→SW fallback is the real backstop, so a
# probe miss costs at most one wasted HW attempt, never a stranded item.
def boom(*a, **k):
raise OSError("no ffprobe")
monkeypatch.setattr(stream.subprocess, "run", boom)
assert stream._probe_video_kind(Path("/x")) == stream._VK_HWSAFE
class TestOpenclPresent:
"""The OpenCL-runtime probe (_opencl_present) — the precondition for the HW HDR tonemap graph. Drives
the REAL glob against a temp ICD dir instead of monkeypatching the function, so a regression to the
ICD path / glob / OSError guard would be caught (this routes every HDR source on prod)."""
def test_true_when_an_icd_file_is_registered(self, tmp_path, monkeypatch):
monkeypatch.setattr(stream, "_OPENCL_ICD_DIR", tmp_path)
(tmp_path / "intel.icd").write_text("libintelocl.so\n")
assert stream._opencl_present() is True
def test_false_when_the_dir_is_empty(self, tmp_path, monkeypatch):
monkeypatch.setattr(stream, "_OPENCL_ICD_DIR", tmp_path)
assert stream._opencl_present() is False
def test_false_and_no_raise_when_the_dir_is_missing(self, tmp_path, monkeypatch):
# A missing dir: Path.glob yields nothing (no raise) → False.
monkeypatch.setattr(stream, "_OPENCL_ICD_DIR", tmp_path / "nope")
assert stream._opencl_present() is False
def test_false_when_glob_raises_oserror(self, monkeypatch):
# The `except OSError` guard proper: an unreadable ICD dir (permission denied) makes glob RAISE,
# not return empty. That must be swallowed to False, never propagated up through _select_encode
# into start_session (which would crash HDR routing).
class _BoomDir:
def glob(self, pattern):
raise OSError("permission denied")
monkeypatch.setattr(stream, "_OPENCL_ICD_DIR", _BoomDir())
assert stream._opencl_present() is False
class TestWaitForProcFastFail:
"""wait_for(proc=…) must stop as soon as the ffmpeg proc exits (a VAAPI init refusal dies in ~1s
producing nothing) instead of blocking the whole timeout — that fast-fail is what keeps the HW→SW
fallback from turning into a 25s stall. Exercises the REAL wait_for (every other test monkeypatches
it wholesale, so this branch would otherwise never run)."""
class _DeadProc:
def poll(self):
return 1 # already exited non-zero
def test_returns_true_when_the_segment_exists_despite_a_dead_proc(self, tmp_path):
seg = tmp_path / "seg_0.ts"
seg.write_bytes(b"x")
# The proc exited but DID write a first segment → ready. (The final existence re-check.)
assert stream.wait_for(seg, timeout=5.0, proc=self._DeadProc()) is True
def test_returns_false_fast_when_a_dead_proc_wrote_nothing(self, tmp_path):
# Missing segment + dead proc → give up immediately, not after `timeout`. Assert it's fast by
# using a long timeout the test would visibly hang on if the fast-fail branch regressed.
seg = tmp_path / "seg_0.ts"
assert stream.wait_for(seg, timeout=30.0, proc=self._DeadProc()) is False
def test_empty_segment_from_a_dead_proc_is_not_ready(self, tmp_path):
# A zero-byte segment is not a real segment (size > 0 required).
seg = tmp_path / "seg_0.ts"
seg.write_bytes(b"")
assert stream.wait_for(seg, timeout=30.0, proc=self._DeadProc()) is False
+65
View File
@@ -0,0 +1,65 @@
"""The sliding-window limiter behind /auth/register, password-login, password-reset and demo.
Pure logic, no DB. `reset()` in particular is only ever called by a test fixture (conftest clears
the module-level limiters between tests), so without a test here a refactor could turn it into a
no-op and nothing would notice until an unrelated test started failing with a 429.
"""
from app import ratelimit
from app.ratelimit import RateLimiter
def test_allow_trips_at_the_cap_and_stays_tripped():
lim = RateLimiter(max_events=2, window_seconds=300)
assert lim.allow("ip") is True
assert lim.allow("ip") is True
assert lim.allow("ip") is False
assert lim.allow("ip") is False
def test_hammering_while_blocked_does_not_push_the_window_forward(monkeypatch):
# A blocked attempt must not be recorded, otherwise someone who keeps retrying holds their own
# window open forever — an indefinite lockout for anyone sharing that IP. Only observable across
# the window boundary, so drive the clock: two attempts fill the cap, ten blocked retries land
# just before it expires, and the caller must still be let back in on the far side.
clock = {"t": 1000.0}
monkeypatch.setattr(ratelimit.time, "monotonic", lambda: clock["t"])
lim = RateLimiter(max_events=2, window_seconds=60)
assert lim.allow("ip") is True
assert lim.allow("ip") is True
for _ in range(10):
clock["t"] += 5 # still inside the window; every one of these is refused
assert lim.allow("ip") is False
clock["t"] = 1000.0 + 61 # past the window of the two attempts that actually counted
assert lim.allow("ip") is True
def test_keys_are_independent():
lim = RateLimiter(max_events=1, window_seconds=300)
assert lim.allow("a") is True
assert lim.allow("a") is False
assert lim.allow("b") is True
def test_reset_clears_every_key():
lim = RateLimiter(max_events=1, window_seconds=300)
lim.allow("a")
lim.allow("b")
assert lim.allow("a") is False
lim.reset()
assert lim.allow("a") is True
assert lim.allow("b") is True
def test_expired_attempts_fall_out_of_the_window():
# A zero-length window means every previous attempt is already outside it.
lim = RateLimiter(max_events=1, window_seconds=0)
assert lim.allow("ip") is True
assert lim.allow("ip") is True
+90
View File
@@ -0,0 +1,90 @@
"""What a dead email link says, and what it costs to ask (E9.5 S4 follow-up), DB-lane.
Reported 2026-09-05: delete an account, then open its password-reset link. The card drew a full
"set a new password" form — because a `#reset=` fragment was present, nothing more — and only after
the user typed a password did the server answer 400. No security hole (the account's tokens cascade
away with it, so nothing can be set on a deleted or a re-created account), but a dead end of exactly
the kind this epic exists to remove.
The fix is a check that does NOT spend the token, so the pre-flight can never cost the user their
one real attempt. That is the property most worth pinning here.
"""
from app.routes.auth import RESET_TTL, _issue_token
_PW = "correct horse battery"
_NEW = "brand new password"
def _issue(db, user):
raw = _issue_token(db, user, "reset", RESET_TTL)
db.commit()
return raw
def test_checking_a_live_link_does_not_spend_it(client, db, make_user):
raw = _issue(db, make_user(email="live@example.com", password=_PW))
assert client.post("/auth/password-reset/check", json={"token": raw}).json() == {"ok": True}
assert client.post("/auth/password-reset/check", json={"token": raw}).json() == {"ok": True}
# The whole point: after any number of checks the one real attempt is still there.
assert client.post(
"/auth/password-reset/confirm", json={"token": raw, "password": _NEW}
).json() == {"ok": True}
def test_a_link_whose_account_was_deleted_is_reported_dead(client, db, make_user):
user = make_user(email="gone@example.com", password=_PW)
raw = _issue(db, user)
db.delete(user)
db.commit()
assert client.post("/auth/password-reset/check", json={"token": raw}).json() == {"ok": False}
# And the confirm agrees, in the structured shape the card renders as a named screen — the two
# must never disagree about one token.
r = client.post("/auth/password-reset/confirm", json={"token": raw, "password": _NEW})
assert r.status_code == 400
assert r.json()["detail"] == {"code": "reset", "reason": "invalid"}
def test_a_spent_link_is_reported_dead(client, db, make_user):
raw = _issue(db, make_user(email="spent@example.com", password=_PW))
client.post("/auth/password-reset/confirm", json={"token": raw, "password": _NEW})
assert client.post("/auth/password-reset/check", json={"token": raw}).json() == {"ok": False}
def test_a_too_short_password_does_not_burn_the_link(client, db, make_user):
raw = _issue(db, make_user(email="short@example.com", password=_PW))
r = client.post("/auth/password-reset/confirm", json={"token": raw, "password": "short"})
assert r.status_code == 400
assert r.json()["detail"]["reason"] == "weak_password"
# A mistyped password must not cost the link — the length check runs BEFORE the token is spent,
# and the card tells the two failures apart by `reason` so only one of them takes the form down.
assert client.post(
"/auth/password-reset/confirm", json={"token": raw, "password": _NEW}
).json() == {"ok": True}
def test_a_dead_verification_link_answers_in_the_same_structured_shape(client):
r = client.post("/auth/verify", json={"token": "no-such-token"})
assert r.status_code == 400
assert r.json()["detail"] == {"code": "verify", "reason": "invalid"}
def test_the_check_never_reports_a_live_link_dead_when_throttled(
client, db, make_user, monkeypatch
):
from app.routes import auth as auth_routes
raw = _issue(db, make_user(email="throttled@example.com", password=_PW))
monkeypatch.setattr(auth_routes._reset_check_limiter, "allow", lambda _ip: False)
# Fail OPEN. This check may only ever save a wasted form; a throttled probe that answered "dead"
# would lock someone out of a link that works.
assert client.post("/auth/password-reset/check", json={"token": raw}).json() == {"ok": True}
+350
View File
@@ -0,0 +1,350 @@
"""E9.5 review round — the invariants the epic's own review found unpinned.
Every test here corresponds to a defect a reviewer could still introduce with the whole suite green:
a mail whose body never renders in any test, an activation that only works for the exact spelling, a
"we already wrote to them" stamp for a mail that was dropped, an env allow-list that answers a
different question than the gate it backs, and the address pre-emption that E9.5 S1's argument
covered only while the address stayed unverified.
"""
from datetime import datetime, timedelta, timezone
import pytest
from sqlalchemy import select
from app import auth as auth_core
from app import email as email_mod
from app import mailtext
from app.config import settings
from app.models import AuthToken, Invite, User
from app.routes import auth as auth_routes
_PW = "correct horse battery"
@pytest.fixture
def wire(monkeypatch):
"""Capture what `_send` would have put on the wire. Mails are the one surface where a body that
never renders in a test is a body that first renders in someone's inbox."""
out: list[tuple[list[str], str, str]] = []
monkeypatch.setattr(
email_mod,
"_send",
lambda to, subject, body, reply_to=None: out.append((to, subject, body)) is None,
)
return out
@pytest.fixture
def smtp_on(monkeypatch):
monkeypatch.setattr(email_mod, "email_enabled", lambda db=None: True)
# --- every mail actually renders ------------------------------------------------------------------
# One call per `send_*`, with arguments of the right shape. The parity test compares HU against EN
# and can therefore never catch a template whose placeholders disagree with its CALLER — that
# mismatch is a KeyError inside a background task, i.e. a mail that silently never goes out. Three
# mailers this epic rewrote (`password_reset`, `role_changed`, `account_unsuspended`) had no test
# that executed their `.format()` at all.
_EVERY_MAIL = [
("send_access_approved", ("to@example.com", "LANG", "https://x/#verify=t", True, 168)),
("send_access_approved", ("to@example.com", "LANG", None, True, 24)),
("send_access_approved", ("to@example.com", "LANG", None, False, 24)),
("send_access_invitation", ("to@example.com", "LANG", "https://x/#verify=t", True, 168)),
("send_access_invitation", ("to@example.com", "LANG", None, False, 24)),
("send_access_denied", ("to@example.com", "LANG")),
("send_role_changed", ("to@example.com", "LANG", "admin", "op@example.com")),
("send_role_changed", ("to@example.com", "LANG", "user", None)),
("send_account_unsuspended", ("to@example.com", "LANG", "op@example.com")),
("send_account_deleted", ("to@example.com", "LANG", None)),
("send_account_suspended", ("to@example.com", "LANG", "op@example.com")),
("send_account_suspended", ("to@example.com", "LANG", None)),
("send_admin_new_request", (["a@example.com"], "LANG", "who@example.com")),
("send_verify_email", ("to@example.com", "LANG", "https://x/#verify=t", 24)),
("send_password_reset", ("to@example.com", "LANG", "https://x/#reset=t")),
("send_test", ("to@example.com", "LANG")),
]
@pytest.mark.parametrize("lang", mailtext.SUPPORTED)
@pytest.mark.parametrize("name,args", _EVERY_MAIL, ids=[f"{n}{a[2:3]}" for n, a in _EVERY_MAIL])
def test_every_mail_renders_in_every_language(wire, name, args, lang):
getattr(email_mod, name)(*[lang if a == "LANG" else a for a in args])
(_, subject, body) = wire[0]
assert subject and body
# A template that lost its interpolation renders the brace text verbatim.
assert "{" not in subject and "{" not in body
def test_a_weeks_link_is_described_in_days_not_hours(wire):
# The S6 approval token lives seven days, and the mail used to say "expires in 168 hours".
email_mod.send_access_approved("to@example.com", "en", "https://x/#verify=t", True, 168)
assert "7 days" in wire[0][2] and "168" not in wire[0][2]
email_mod.send_verify_email("to@example.com", "hu", "https://x/#verify=t", 24)
assert "24 óra" in wire[1][2]
# --- what a second registration may and may not do -------------------------------------------------
def test_a_later_registration_never_touches_an_existing_password(db, client, smtp_on, wire):
"""The rule E9.5's review round 2 put back, having tried the opposite and found it worse.
"While the address is unverified, the newest registration owns it" looked like a fix for address
pre-emption. It is symmetric — an attacker can always be last — so it closed nothing, and it
handed an unauthenticated caller the power to overwrite a real registrant's chosen password and
delete the confirmation link already sitting in their inbox. On a SEC-10-stripped row it would
have installed a password where the whole point is that there is none. Reverted; the owner's
recourse is password-reset, which proves the mailbox. Residual logged as SEC-16.
"""
client.post(
"/auth/register", json={"email": "victim@example.com", "password": "the first password"}
)
db.expire_all()
user = db.execute(select(User).where(User.email == "victim@example.com")).scalar_one()
before = user.password_hash
client.post(
"/auth/register", json={"email": "victim@example.com", "password": "the second password"}
)
db.expire_all()
assert db.get(User, user.id).password_hash == before
def test_a_later_registration_leaves_a_link_already_in_flight_alive(db, client, smtp_on, wire):
client.post(
"/auth/register", json={"email": "victim@example.com", "password": "the first password"}
)
db.expire_all()
user = db.execute(select(User).where(User.email == "victim@example.com")).scalar_one()
first = db.execute(select(AuthToken.id).where(AuthToken.user_id == user.id)).scalars().all()
# Not just a subset check below: `set() <= set()` is vacuously true, so without this the test
# would go green while asserting nothing the day registration stops issuing a token.
assert len(first) == 1
client.post(
"/auth/register", json={"email": "victim@example.com", "password": "the second password"}
)
db.expire_all()
live = db.execute(select(AuthToken.id).where(AuthToken.user_id == user.id)).scalars().all()
# The re-send adds a link; it must not invalidate the one the owner may already be holding.
assert set(first) <= set(live) and len(live) == len(first) + 1
def test_a_stripped_account_keeps_its_recovery_stamp(db, client, smtp_on, make_user):
"""Prod carries accounts SEC-10 left with no password and a `password_revoked_at` stamp. That
stamp is the only reason password-reset still accepts them; an anonymous POST must not be able to
clear it, nor to put a password on a row whose whole state is "there is none"."""
stripped = make_user(
email="stripped@example.com",
password=None,
email_verified=False,
is_active=True,
password_revoked_at=datetime.now(timezone.utc),
)
client.post(
"/auth/register", json={"email": "stripped@example.com", "password": "an attacker password"}
)
db.expire_all()
row = db.get(User, stripped.id)
assert row.password_hash is None and row.password_revoked_at is not None
def test_a_denied_sibling_is_not_reopened_while_a_pending_row_exists(db, client):
# Canonical siblings exist on prod. Picking the denied one reopens it and re-notifies every
# admin, putting one person in the queue twice — and the exact-spelling tie-break made that
# deterministic rather than merely possible.
db.add_all(
[
Invite(email="pair.one@gmail.com", status="pending"),
Invite(email="pairone@gmail.com", status="denied"),
]
)
db.commit()
client.post("/auth/request-access", json={"email": "pairone@gmail.com"})
db.expire_all()
rows = {i.email: i.status for i in db.execute(select(Invite)).scalars().all()}
assert rows == {"pair.one@gmail.com": "pending", "pairone@gmail.com": "denied"}
# --- the env bootstrap answers the same question as the gate --------------------------------------
def test_the_env_allow_list_is_matched_canonically(db, monkeypatch):
# An operator writing the dotted spelling into ALLOWED_EMAILS was refused when Google answered
# with the undotted primary — the 2026-09-03 lockout, on the bootstrap path.
monkeypatch.setattr(settings, "allowed_emails", "first.last@gmail.com")
assert auth_core.is_allowed(db, "firstlast@gmail.com") is True
assert auth_core.is_allowed(db, "first.last+tag@gmail.com") is True
assert auth_core.is_allowed(db, "someone.else@gmail.com") is False
def test_an_env_granted_address_cannot_be_shadowed_by_a_stranger(db, client, monkeypatch):
# `is_allowed` short-circuits on ANY invite row for the canonical form, so before this an
# unauthenticated caller could request access under a sibling spelling and permanently shadow an
# env grant. With the env list matched canonically the request never opens a row.
monkeypatch.setattr(settings, "allowed_emails", "bob@gmail.com")
client.post("/auth/request-access", json={"email": "b.ob@gmail.com"})
assert db.execute(select(Invite)).scalars().all() == []
assert auth_core.is_allowed(db, "bob@gmail.com") is True
def test_a_dotted_admin_emails_entry_still_bootstraps_its_admin(monkeypatch):
monkeypatch.setattr(settings, "admin_emails", "the.operator+siftlode@gmail.com")
assert "theoperator@gmail.com" in auth_core.env_admin_canonicals()
# --- approving decides about a MAILBOX, not a spelling --------------------------------------------
def test_approving_one_spelling_activates_the_account_registered_under_another(
db, make_user, as_user, smtp_on, wire
):
# The 2026-09-03 incident in one test: the admin approves the address as typed into the request,
# and the account exists under the other Gmail spelling of the same mailbox.
admin = make_user(email="admin@example.com", role="admin")
user = make_user(email="dotless@gmail.com", password=_PW, is_active=False)
user.email = "dotless@gmail.com"
inv = Invite(email="dot.less@gmail.com", status="pending")
db.add(inv)
db.commit()
r = as_user(admin).post(f"/api/admin/invites/{inv.id}/approve")
assert r.status_code == 200
db.expire_all()
assert db.get(User, user.id).is_active is True
def test_deleting_one_of_two_accounts_leaves_the_other_one_allowed(db, make_user, as_user):
# Prod carries two accounts for one Gmail mailbox. A canonical invite sweep on delete took the
# survivor's grant with it, refusing their next sign-in — this epic's founding bug, self-inflicted.
admin = make_user(email="admin@example.com", role="admin")
doomed = make_user(email="pair.one@gmail.com", password=_PW)
make_user(email="pairone@gmail.com", password=_PW)
db.add_all([Invite(email="pair.one@gmail.com", status="approved")])
db.commit()
r = as_user(admin).delete(f"/api/admin/users/{doomed.id}")
assert r.status_code == 200
assert auth_core.is_allowed(db, "pairone@gmail.com") is True
# --- a button an admin pressed must not report a send that did not happen -------------------------
def test_a_failed_invitation_changes_nothing_the_admin_was_told_about(
db, make_user, as_user, smtp_on, monkeypatch
):
monkeypatch.setattr(email_mod, "send_access_invitation", lambda *a, **kw: False)
admin = make_user(email="admin@example.com", role="admin")
stranger = make_user(
email="stranger@example.com", password=_PW, email_verified=False, is_active=False
)
inv = Invite(email="stranger@example.com", status="approved")
db.add(inv)
db.commit()
r = as_user(admin).post(f"/api/admin/invites/{inv.id}/invite", json={"lang": "en"})
assert r.status_code == 422 and r.json()["detail"]["reason"] == "send_failed"
db.expire_all()
# The column exists to answer "did I already write to them?", and the UI dims the button and
# warns about a second mail on the strength of it. A dropped send must not make it say yes.
assert db.get(Invite, inv.id).invited_at is None
# And a request the UI reports as a failure must not have let somebody in behind the admin's
# back — the activation belongs after a mail that actually left.
assert db.get(User, stranger.id).is_active is False
def test_a_failed_resend_says_so_instead_of_reporting_ok(
db, make_user, as_user, smtp_on, monkeypatch
):
monkeypatch.setattr(email_mod, "send_verify_email", lambda *a, **kw: False)
admin = make_user(email="admin@example.com", role="admin")
stuck = make_user(email="stuck@example.com", password=_PW, email_verified=False)
r = as_user(admin).post(f"/api/admin/users/{stuck.id}/resend-verification")
assert r.status_code == 422 and r.json()["detail"]["reason"] == "send_failed"
# --- refusals and links that are already dead ------------------------------------------------------
def test_an_expired_reset_link_is_reported_dead_without_being_spent(db, client, make_user):
# The endpoint exists because a spent or stale link used to draw the full "set a new password"
# form. Expiry is the reason its docstring gives, and it was the one case no test covered.
user = make_user(email="late@example.com", password=_PW)
raw = auth_routes._issue_token(db, user, "reset", timedelta(hours=1))
token = db.execute(select(AuthToken).where(AuthToken.user_id == user.id)).scalar_one()
token.expires_at = datetime.now(timezone.utc) - timedelta(minutes=1)
db.commit()
assert client.post("/auth/password-reset/check", json={"token": raw}).json() == {"ok": False}
r = client.post(
"/auth/password-reset/confirm", json={"token": raw, "password": "a new one 123"}
)
assert r.status_code == 400
def test_a_verify_token_cannot_pass_as_a_reset_link(db, client, make_user):
user = make_user(email="mixed@example.com", password=_PW)
raw = auth_routes._issue_token(db, user, "verify", timedelta(hours=1))
assert client.post("/auth/password-reset/check", json={"token": raw}).json() == {"ok": False}
def test_a_throttled_login_says_nothing_about_the_account(db, client, make_user, monkeypatch):
# The two refusals that answer BEFORE the password is proven must stay byte-identical for a real
# account and an unknown one; only `invalid` was pinned.
make_user(email="real@example.com", password=_PW)
monkeypatch.setattr(auth_routes._login_limiter, "allow", lambda key: False)
real = client.post("/auth/password-login", json={"email": "real@example.com", "password": _PW})
unknown = client.post(
"/auth/password-login", json={"email": "nobody@example.com", "password": _PW}
)
assert real.status_code == unknown.status_code == 429
assert real.json() == unknown.json() == {"detail": {"code": "login", "reason": "rate_limited"}}
# --- admins hear about a request in their own language --------------------------------------------
def test_two_admins_two_languages_two_mails(db, client, make_user, smtp_on, wire):
make_user(email="hu.admin@example.com", role="admin", preferences={"language": "hu"})
make_user(email="en.admin@example.com", role="admin", preferences={"language": "en"})
client.post("/auth/request-access", json={"email": "asking@example.com", "lang": "en"})
notices = [w for w in wire if "asking@example.com" in w[1]]
assert len(notices) == 2
by_recipient = {w[0][0]: w[1] for w in notices}
assert by_recipient["hu.admin@example.com"] == mailtext.MAIL_TEXT["admin_new_request.subject"][
"hu"
].format(requester="asking@example.com")
assert by_recipient["en.admin@example.com"] == mailtext.MAIL_TEXT["admin_new_request.subject"][
"en"
].format(requester="asking@example.com")
def test_an_env_admin_without_an_account_is_written_to_in_the_default(db, monkeypatch):
monkeypatch.setattr(settings, "admin_emails", "bootstrap@example.com")
assert auth_routes.admin_notify_groups(db) == {mailtext.DEFAULT: ["bootstrap@example.com"]}
+503
View File
@@ -0,0 +1,503 @@
"""E7 S5a — local file upload ingest (the non-yt-dlp path into the media library).
Covers the ingest spine: media vs generic-file classification, the download-only serving of a
generic file (force-download so uploaded HTML/SVG can't execute inline), quota accounting with the
size known up front, the per-file cap enforced while streaming, filename sanitization, and the two
guards that keep an upload out of paths that assume a re-fetchable source (edit / redownload).
DB-backed (real Postgres) — skipped when no database is reachable. `settings.download_root` is
pointed at a per-test tmp dir so ingest's on-disk moves are isolated. The ingest/route tests
monkeypatch the shared `probe.probe_media` so they don't shell out to ffprobe; the classifier's OWN
parsing (image → file, cover-art skip, dims, duration) is covered directly by `TestProbeClassifier`
below, which stubs one level down (`run_ffprobe_json`) and exercises the real `probe_media`.
"""
from pathlib import Path
import pytest
from app.config import settings
from app.downloads import mediakind, quota, storage
from app.downloads import probe as probemod
from app.downloads import upload as uploadmod
from app.models import DownloadQuota, MediaAsset
# --- pure: the kind invariant ---------------------------------------------------------------
def test_derive_media_kind():
assert uploadmod.mediakind.derive("h264", "aac") == mediakind.VIDEO
assert uploadmod.mediakind.derive("vp9", None) == mediakind.VIDEO
assert uploadmod.mediakind.derive(None, "aac") == mediakind.AUDIO
assert uploadmod.mediakind.derive(None, None) == mediakind.FILE
# --- ingest (direct) ------------------------------------------------------------------------
@pytest.fixture
def _root(tmp_path, monkeypatch):
monkeypatch.setattr(settings, "download_root", str(tmp_path))
return tmp_path
def _probe(monkeypatch, result):
# upload ingest now calls the shared `probe.probe_media` (same ground-truth ffprobe the download
# worker uses); patch it at that shared home.
monkeypatch.setattr(probemod, "probe_media", lambda _p: result)
def test_ingest_generic_file_is_download_only(db, make_user, _root, monkeypatch):
_probe(monkeypatch, (None, None, None, None, None))
u = make_user()
src = _root / "src.bin"
src.write_bytes(b"%PDF-1.4 hello")
job = uploadmod.ingest_upload(
db, u.id, filename="My Notes.pdf", src_path=src, size=src.stat().st_size
)
asset = db.get(MediaAsset, job.asset_id)
assert asset.media_kind == mediakind.FILE
assert asset.status == "ready" and job.status == "done" and job.progress == 100
assert asset.container == "pdf"
assert asset.poster_path is None # a generic file has no player and no poster
assert asset.title == "My Notes" # display title from the original name
assert job.job_kind == "upload" and job.source_kind == "upload"
# The file was MOVED into the per-user uploads tree, inside the root, and the temp is gone.
assert not src.exists()
assert asset.rel_path.startswith(".uploads/")
assert storage.safe_abs_path(str(_root), asset.rel_path) is not None
def test_ingest_audio_is_media_no_poster(db, make_user, _root, monkeypatch):
_probe(monkeypatch, (None, "aac", None, None, 200))
u = make_user()
src = _root / "song.m4a"
src.write_bytes(b"fake-audio")
job = uploadmod.ingest_upload(db, u.id, filename="song.m4a", src_path=src, size=10)
asset = db.get(MediaAsset, job.asset_id)
assert asset.media_kind == mediakind.AUDIO
assert asset.duration_s == 200
assert asset.poster_path is None # audio has no frame to poster
def test_ingest_video_records_dims_and_poster(db, make_user, _root, monkeypatch):
_probe(monkeypatch, ("h264", "aac", 640, 480, 60))
# Stub the frame-cut so the test doesn't shell out to ffmpeg.
monkeypatch.setattr(uploadmod.editmod, "ensure_poster", lambda _a, _root: "p/x.jpg")
u = make_user()
src = _root / "clip.mp4"
src.write_bytes(b"fake-video")
job = uploadmod.ingest_upload(db, u.id, filename="clip.mp4", src_path=src, size=10)
asset = db.get(MediaAsset, job.asset_id)
assert asset.media_kind == mediakind.VIDEO
assert (asset.width, asset.height) == (640, 480)
assert asset.poster_path == "p/x.jpg"
def test_ingest_filename_is_sanitized_no_traversal(db, make_user, _root, monkeypatch):
_probe(monkeypatch, (None, None, None, None, None))
u = make_user()
src = _root / "src"
src.write_bytes(b"x")
job = uploadmod.ingest_upload(db, u.id, filename="../../etc/passwd", src_path=src, size=1)
asset = db.get(MediaAsset, job.asset_id)
assert ".." not in asset.rel_path
assert asset.rel_path.startswith(f".uploads/{u.id}/")
assert storage.safe_abs_path(str(_root), asset.rel_path) is not None
def test_ingest_rejects_over_footprint_cap_before_writing(db, make_user, _root, monkeypatch):
_probe(monkeypatch, (None, None, None, None, None))
u = make_user()
db.add(
DownloadQuota(user_id=u.id, max_bytes=1000, max_concurrent=2, max_jobs=50, unlimited=False)
)
db.commit()
src = _root / "big.bin"
src.write_bytes(b"x" * 10)
with pytest.raises(quota.QuotaExceeded) as e:
uploadmod.ingest_upload(db, u.id, filename="big.bin", src_path=src, size=2000)
assert e.value.reason == "max_bytes"
assert src.exists() # rejected before any disk move — nothing landed
assert db.query(MediaAsset).count() == 0 # and no orphan asset row
# --- route ----------------------------------------------------------------------------------
def test_upload_route_generic_forces_download(as_user, make_user, _root, monkeypatch):
_probe(monkeypatch, (None, None, None, None, None))
c = as_user(make_user())
r = c.post(
"/api/downloads/upload",
files={"file": ("report.html", b"<script>alert(1)</script>", "text/html")},
)
assert r.status_code == 200, r.text
job = r.json()
assert job["asset"]["media_kind"] == "file"
assert job["can_download"] is True
# The served file must be a neutral force-download attachment — never inline text/html.
fr = c.get(f"/api/downloads/{job['id']}/file")
assert fr.status_code == 200
assert fr.headers["content-type"].startswith("application/octet-stream")
assert "attachment" in fr.headers["content-disposition"].lower()
def test_upload_route_media_html_extension_not_served_as_html(
as_user, make_user, _root, monkeypatch
):
"""The XSS guard: a MEDIA-classified upload (real codecs) named with an executable extension must
NOT be served with a guessed text/html type. The served Content-Type comes from the verified
media_kind + a container allow-list ('html' isn't one), so it force-downloads as octet-stream —
on both the authenticated file endpoint AND the login-free public watch endpoint. Without the fix
the public inline branch let a media/HTML polyglot named evil.html run as stored XSS."""
_probe(monkeypatch, ("h264", "aac", 320, 240, 5)) # ffprobe says it's video…
c = as_user(make_user())
r = c.post(
"/api/downloads/upload",
files={"file": ("evil.html", b"<script>alert(1)</script>", "text/html")},
)
assert r.status_code == 200, r.text
job = r.json()
assert job["asset"]["media_kind"] == "video" and job["asset"]["container"] == "html"
# Authenticated endpoint: never text/html.
fr = c.get(f"/api/downloads/{job['id']}/file")
assert fr.status_code == 200
assert fr.headers["content-type"].startswith("application/octet-stream")
assert "attachment" in fr.headers["content-disposition"].lower()
# Public login-free endpoint via a STREAM-ONLY link (the inline branch) — still not text/html.
link = c.post(f"/api/downloads/{job['id']}/links", json={"allow_download": False}).json()
pr = c.get(f"/api/public/watch/{link['token']}/file")
assert pr.status_code == 200
assert not pr.headers["content-type"].startswith("text/html")
assert pr.headers["content-type"].startswith("application/octet-stream")
assert "attachment" in pr.headers["content-disposition"].lower()
def test_public_watch_file_forces_download_for_generic_file(as_user, make_user, _root, monkeypatch):
"""The login-free public serve of a generic upload (media_kind='file') must be a neutral
force-download attachment — the branch that stops uploaded HTML/SVG executing on the public
origin. This is a SEPARATE code path from the authenticated /file route, so it needs its own
route-level assertion (previously only the authenticated branch was tested)."""
_probe(monkeypatch, (None, None, None, None, None))
c = as_user(make_user())
job = c.post(
"/api/downloads/upload",
files={"file": ("notes.html", b"<script>alert(1)</script>", "text/html")},
).json()
assert job["asset"]["media_kind"] == "file"
link = c.post(f"/api/downloads/{job['id']}/links", json={"allow_download": True}).json()
pr = c.get(f"/api/public/watch/{link['token']}/file")
assert pr.status_code == 200
assert pr.headers["content-type"].startswith("application/octet-stream")
assert "attachment" in pr.headers["content-disposition"].lower()
def test_public_watch_file_serves_media_inline_with_real_type(
as_user, make_user, _root, monkeypatch
):
"""A recognised media container IS still served inline with its real audio/video type (so it
plays in the watch page) — the fix must not break legitimate playback. A stream-only link →
inline; the type is video/mp4 (from the container allow-list), never text/* and never a guess."""
_probe(monkeypatch, ("h264", "aac", 640, 360, 12))
c = as_user(make_user())
job = c.post(
"/api/downloads/upload",
files={"file": ("clip.mp4", b"fake-mp4-bytes", "video/mp4")},
).json()
link = c.post(f"/api/downloads/{job['id']}/links", json={"allow_download": False}).json()
pr = c.get(f"/api/public/watch/{link['token']}/file")
assert pr.status_code == 200
assert pr.headers["content-type"].startswith("video/mp4")
assert "inline" in pr.headers["content-disposition"].lower()
def test_upload_route_enforces_per_file_cap(as_user, make_user, db, _root, monkeypatch):
from app import sysconfig
sysconfig.set_value(db, "download_upload_max_bytes", 5)
db.commit()
c = as_user(make_user())
r = c.post(
"/api/downloads/upload",
files={"file": ("big.bin", b"way over the cap", "application/octet-stream")},
)
assert r.status_code == 422
assert r.json()["detail"]["code"] == "upload_too_large"
def test_upload_cap_cannot_be_disabled_with_zero(db):
"""download_upload_max_bytes floors at 1: a stored 0 would read falsy in the route's
`if cap and size > cap` guard and SILENTLY disable the per-file cap, so set_value must reject it.
Pins the min=1 invariant so a revert to min=0 can't pass the gate."""
from app import sysconfig
with pytest.raises(ValueError):
sysconfig.set_value(db, "download_upload_max_bytes", 0)
def test_upload_route_rejects_empty_file(as_user, make_user, _root, monkeypatch):
c = as_user(make_user())
r = c.post(
"/api/downloads/upload", files={"file": ("empty.bin", b"", "application/octet-stream")}
)
assert r.status_code == 400
assert r.json()["detail"]["code"] == "upload_empty"
def test_redownload_rejects_an_upload(as_user, make_user, _root, monkeypatch):
_probe(monkeypatch, (None, None, None, None, None))
c = as_user(make_user())
job = c.post(
"/api/downloads/upload", files={"file": ("a.bin", b"data", "application/octet-stream")}
).json()
r = c.post(f"/api/downloads/{job['id']}/redownload")
assert r.status_code == 409
def test_edit_rejects_a_generic_upload_source(as_user, make_user, _root, monkeypatch):
_probe(monkeypatch, (None, None, None, None, None))
c = as_user(make_user())
job = c.post(
"/api/downloads/upload", files={"file": ("a.bin", b"data", "application/octet-stream")}
).json()
r = c.post(
"/api/downloads/edit",
json={"source_job_id": job["id"], "edit_spec": {"trim": {"start_s": 0, "end_s": 1}}},
)
assert r.status_code == 400
assert r.json()["detail"]["reason"] == "not_media"
def test_ingest_rejects_over_max_jobs(db, make_user, _root, monkeypatch):
"""The other check_upload rejection branch (holdings count, not bytes): once the user is at
max_jobs, a further upload is refused before any disk write."""
_probe(monkeypatch, (None, None, None, None, None))
u = make_user()
db.add(
DownloadQuota(user_id=u.id, max_bytes=10**12, max_concurrent=2, max_jobs=1, unlimited=False)
)
db.commit()
src1 = _root / "a.bin"
src1.write_bytes(b"a")
uploadmod.ingest_upload(db, u.id, filename="a.bin", src_path=src1, size=1) # now 1 holding job
src2 = _root / "b.bin"
src2.write_bytes(b"b")
with pytest.raises(quota.QuotaExceeded) as e:
uploadmod.ingest_upload(db, u.id, filename="b.bin", src_path=src2, size=1)
assert e.value.reason == "max_jobs"
assert src2.exists() # rejected before the move — nothing landed
def test_ingest_cleans_up_placed_files_on_commit_failure(db, make_user, _root, monkeypatch):
"""A failure AFTER storage.place_file has moved the file onto disk must roll back AND unlink the
placed file + any (even partial) poster — otherwise the orphan is permanent (the per-user delete
keys off the rolled-back asset.rel_path, and the GC orphan sweep skips the dot-prefixed .uploads/
tree). Exercises the rollback/cleanup branch that IS the leak remediation, incl. the poster case
where ensure_poster left a partial file but returned None."""
_probe(monkeypatch, ("h264", "aac", 320, 240, 5)) # video → the poster path runs
u = make_user()
# Simulate ffmpeg leaving a partial poster on disk, then failing (ensure_poster returns None).
def _partial_poster(asset, root):
storage.abs_path(root, str(Path(asset.rel_path).with_suffix(".jpg"))).write_bytes(
b"partial"
)
return None
monkeypatch.setattr(uploadmod.editmod, "ensure_poster", _partial_poster)
# Force the commit to fail after the file (and partial poster) are on disk.
def _boom():
raise RuntimeError("db down")
monkeypatch.setattr(db, "commit", _boom)
src = _root / "clip.mp4"
src.write_bytes(b"fake-video-bytes")
with pytest.raises(RuntimeError):
uploadmod.ingest_upload(db, u.id, filename="clip.mp4", src_path=src, size=16)
db.rollback() # drop the monkeypatched-commit session state before querying
uploads = _root / ".uploads"
leftover = [p for p in uploads.rglob("*") if p.is_file()] if uploads.exists() else []
assert leftover == [], f"orphaned files left on disk: {leftover}"
assert db.query(MediaAsset).count() == 0 # the flushed asset rolled back — no dangling row
# --- classifier: the REAL probe_media parsing (stubs ffprobe one level down) ------------------
# The ingest/route tests above stub probe_media wholesale; these exercise its OWN logic — image
# detection, the cover-art skip, dimension/duration parsing — by faking only run_ffprobe_json, so a
# regression in the classifier the force-download guarantee leans on is actually caught.
class TestProbeClassifier:
def _probe_with(self, monkeypatch, data):
monkeypatch.setattr(probemod.editmod, "run_ffprobe_json", lambda *a, **k: data)
return probemod.probe_media(Path("x"))
def test_probe_failure_is_a_generic_file(self, monkeypatch):
# run_ffprobe_json returns None (ffprobe failed / not media) → all None → derive() == FILE.
v, a, w, h, d = self._probe_with(monkeypatch, None)
assert (v, a, w, h, d) == (None, None, None, None, None)
assert mediakind.derive(v, a) == mediakind.FILE
@pytest.mark.parametrize("fmt", ["image2", "png_pipe", "jpeg_pipe", "webp_pipe", "gif"])
def test_still_image_classifies_as_file_not_video(self, monkeypatch, fmt):
# A standalone image exposes a real video stream, but its container format is an image demuxer
# — it must NOT become media_kind='video' (broken player + inline serving). Stays a file.
data = {
"format": {"format_name": fmt},
"streams": [{"codec_type": "video", "codec_name": "png", "width": 8, "height": 8}],
}
v, a, w, h, d = self._probe_with(monkeypatch, data)
assert v is None and mediakind.derive(v, a) == mediakind.FILE
def test_cover_art_video_stream_is_skipped_audio_stays_audio(self, monkeypatch):
# Album art embedded in an audio file is a video stream flagged attached_pic — skip it so the
# asset classifies as audio, not video.
data = {
"format": {"format_name": "mp3", "duration": "212.5"},
"streams": [
{
"codec_type": "video",
"codec_name": "mjpeg",
"disposition": {"attached_pic": 1},
"width": 600,
"height": 600,
},
{"codec_type": "audio", "codec_name": "mp3"},
],
}
v, a, w, h, d = self._probe_with(monkeypatch, data)
assert (v, a, d) == (None, "mp3", 212)
assert mediakind.derive(v, a) == mediakind.AUDIO
def test_real_video_records_codecs_dims_duration(self, monkeypatch):
data = {
"format": {"format_name": "mov,mp4,m4a", "duration": "60.0"},
"streams": [
{"codec_type": "video", "codec_name": "h264", "width": 1920, "height": 1080},
{"codec_type": "audio", "codec_name": "aac"},
],
}
v, a, w, h, d = self._probe_with(monkeypatch, data)
assert (v, a, w, h, d) == ("h264", "aac", 1920, 1080, 60)
assert mediakind.derive(v, a) == mediakind.VIDEO
def test_unparseable_duration_is_none(self, monkeypatch):
data = {
"format": {"format_name": "wav", "duration": "N/A"},
"streams": [{"codec_type": "audio", "codec_name": "pcm_s16le"}],
}
v, a, w, h, d = self._probe_with(monkeypatch, data)
assert d is None and mediakind.derive(v, a) == mediakind.AUDIO
# --- resolve_media_facts: probe-of-the-file wins over yt-dlp's info (the FB regression fix) ---------
# The download worker's finalize must classify from the ACTUAL file, not the extractor's `info` dict.
# yt-dlp omits vcodec/acodec/dims for some sources (Facebook), and deriving media_kind from those
# nulls turned a real H.264 video into a download-only 'file' (no in-app play). These pin that the
# probe is authoritative and `info` is only a gap-filler.
class TestResolveMediaFacts:
# These stub run_ffprobe_json (one level below probe_media) so the REAL probe parsing + the
# probe-vs-info precedence are exercised together — the precedence keys off whether ffprobe RAN.
@staticmethod
def _ffprobe(monkeypatch, data):
monkeypatch.setattr(probemod.editmod, "run_ffprobe_json", lambda *a, **k: data)
def test_probe_wins_when_info_lacks_codecs(self, monkeypatch):
# THE original regression: info has no vcodec/acodec/dims (Facebook), but the file probes as
# h264/aac.
self._ffprobe(
monkeypatch,
{
"format": {"format_name": "mov,mp4,m4a"},
"streams": [
{"codec_type": "video", "codec_name": "h264", "width": 720, "height": 1280},
{"codec_type": "audio", "codec_name": "aac"},
],
},
)
v, a, w, h = probemod.resolve_media_facts(Path("x"), {"width": None, "height": None})
assert (v, a, w, h) == ("h264", "aac", 720, 1280)
assert mediakind.derive(v, a) == mediakind.VIDEO # NOT 'file'
def test_probe_wins_over_a_DIFFERING_populated_info(self, monkeypatch):
# Both the probe AND info are populated but DISAGREE (info carries a pre-remux/pre-merge codec).
# The finished file on disk is ground truth, so the probe wins outright — pins the precedence a
# `info or probe` inversion would silently break.
self._ffprobe(
monkeypatch,
{
"format": {"format_name": "mov,mp4,m4a"},
"streams": [
{"codec_type": "video", "codec_name": "h264", "width": 1920, "height": 1080},
{"codec_type": "audio", "codec_name": "aac"},
],
},
)
v, a, w, h = probemod.resolve_media_facts(
Path("x"), {"vcodec": "vp9", "acodec": "opus", "width": 640, "height": 480}
)
assert (v, a, w, h) == ("h264", "aac", 1920, 1080) # probe, not info
def test_cover_art_audio_download_is_not_resurrected_to_video_by_info(self, monkeypatch):
# THE bug this fix targets: an audio-mode download from a progressive source embeds cover art,
# and yt-dlp's info still carries the source's video codec. The probe correctly finds only audio
# (the attached_pic is skipped); info's vcodec must NOT resurrect a video track → media_kind
# stays audio, not a broken in-app 'video'.
self._ffprobe(
monkeypatch,
{
"format": {"format_name": "mp3", "duration": "180.0"},
"streams": [
{
"codec_type": "video",
"codec_name": "mjpeg",
"disposition": {"attached_pic": 1},
},
{"codec_type": "audio", "codec_name": "mp3"},
],
},
)
v, a, w, h = probemod.resolve_media_facts(
Path("x"), {"vcodec": "avc1", "acodec": "mp4a", "width": 1280, "height": 720}
)
assert v is None and a == "mp3" # info's avc1 did NOT win
assert mediakind.derive(v, a) == mediakind.AUDIO # NOT VIDEO
def test_still_image_download_is_not_resurrected_to_video_by_info(self, monkeypatch):
# A downloaded still image: the probe returns no vcodec (image-format guard). info's image
# "vcodec" must not turn it into a broken 'video' → stays a generic file.
self._ffprobe(
monkeypatch,
{
"format": {"format_name": "png_pipe"},
"streams": [{"codec_type": "video", "codec_name": "png", "width": 8, "height": 8}],
},
)
v, a, w, h = probemod.resolve_media_facts(
Path("x"), {"vcodec": "png", "width": 8, "height": 8}
)
assert v is None and mediakind.derive(v, a) == mediakind.FILE
def test_info_fills_in_only_when_ffprobe_could_not_read_the_file(self, monkeypatch):
# ffprobe failed entirely (None) → fall back to info so we never regress the working path.
self._ffprobe(monkeypatch, None)
v, a, w, h = probemod.resolve_media_facts(
Path("x"), {"vcodec": "vp9", "acodec": "opus", "width": 1920, "height": 1080}
)
assert (v, a, w, h) == ("vp9", "opus", 1920, 1080)
def test_info_none_codec_string_is_treated_as_absent(self, monkeypatch):
# yt-dlp writes the literal "none" for a missing stream — it must not become a codec name.
self._ffprobe(monkeypatch, None) # probe failed → info path, where the "none" scrub applies
v, a, w, h = probemod.resolve_media_facts(Path("x"), {"vcodec": "none", "acodec": "aac"})
assert (v, a) == (None, "aac")
assert mediakind.derive(v, a) == mediakind.AUDIO
+249
View File
@@ -0,0 +1,249 @@
"""One mail, one click — the branching welcome/invitation mail (E9.5 S6), DB-lane.
The funnel's common ordering makes a person open TWO messages: one saying their access is approved,
another asking them to confirm their address. The C option carries the confirmation link inside the
mail that is already going out, so the usual case is one mail and one click.
Also here: the silent no-op that is the most likely explanation for the 2026-09-03 report that a
confirmation mail "never arrived". Re-registering an address that already had an unconfirmed account
sent nothing at all, while answering with the same cheerful "check your inbox".
"""
from datetime import datetime, timedelta, timezone
from sqlalchemy import select
from app.models import AuditLog, AuthToken, Invite, User
_PW = "correct horse battery"
def _capture(monkeypatch, name):
"""Record the arguments a given email_mod.send_* was called with."""
from app import email as email_mod
calls: list[tuple] = []
monkeypatch.setattr(email_mod, name, lambda *a, **kw: calls.append((a, kw)) or True)
return calls
def _smtp_on(monkeypatch):
from app import email as email_mod
monkeypatch.setattr(email_mod, "email_enabled", lambda db=None: True)
def _approve(client, invite_id):
r = client.post(f"/api/admin/invites/{invite_id}/approve")
assert r.status_code == 200
return r
# --- the approval mail branches ------------------------------------------------------------------
def test_an_unconfirmed_account_gets_the_confirmation_link_in_the_approval_mail(
db, make_user, as_user, monkeypatch
):
_smtp_on(monkeypatch)
sent = _capture(monkeypatch, "send_access_approved")
admin = make_user(email="admin@example.com", role="admin")
inv = Invite(email="waiting@example.com", status="pending")
db.add(inv)
db.commit()
make_user(email="waiting@example.com", password=_PW, email_verified=False, is_active=False)
_approve(as_user(admin), inv.id)
(email, _lang, link, has_account, hours), _ = sent[0]
assert email == "waiting@example.com" and has_account is True
assert link and "#verify=" in link
# The whole point: this is the ONLY message they now have to open.
assert hours == 24 * 7
def test_the_link_in_that_mail_actually_confirms_the_address(db, make_user, as_user, monkeypatch):
_smtp_on(monkeypatch)
sent = _capture(monkeypatch, "send_access_approved")
admin = make_user(email="admin@example.com", role="admin")
inv = Invite(email="waiting@example.com", status="pending")
db.add(inv)
db.commit()
user = make_user(
email="waiting@example.com", password=_PW, email_verified=False, is_active=False
)
_approve(as_user(admin), inv.id)
token = sent[0][0][2].split("#verify=")[1]
r = as_user(admin).post("/auth/verify", json={"token": token})
assert r.status_code == 200
db.expire_all()
assert db.get(User, user.id).email_verified is True
def test_that_link_outlives_a_weekend(db, make_user, as_user, monkeypatch):
# An approval is not a mail anyone sits waiting for; the standard 24h would routinely expire
# before it is read, and the one-mail promise with it.
_smtp_on(monkeypatch)
_capture(monkeypatch, "send_access_approved")
admin = make_user(email="admin@example.com", role="admin")
inv = Invite(email="waiting@example.com", status="pending")
db.add(inv)
db.commit()
user = make_user(
email="waiting@example.com", password=_PW, email_verified=False, is_active=False
)
_approve(as_user(admin), inv.id)
tok = db.execute(select(AuthToken).where(AuthToken.user_id == user.id)).scalars().all()[-1]
assert tok.expires_at > datetime.now(timezone.utc) + timedelta(days=6)
def test_an_already_confirmed_account_is_just_told_to_sign_in(db, make_user, as_user, monkeypatch):
_smtp_on(monkeypatch)
sent = _capture(monkeypatch, "send_access_approved")
admin = make_user(email="admin@example.com", role="admin")
inv = Invite(email="ready@example.com", status="pending")
db.add(inv)
db.commit()
make_user(email="ready@example.com", password=_PW, is_active=False)
_approve(as_user(admin), inv.id)
(_, _lang, link, has_account, _), _ = sent[0]
assert link is None and has_account is True # no link to offer, and none needed
def test_someone_with_no_account_yet_is_told_a_second_mail_is_coming(
db, make_user, as_user, monkeypatch
):
_smtp_on(monkeypatch)
sent = _capture(monkeypatch, "send_access_approved")
admin = make_user(email="admin@example.com", role="admin")
inv = Invite(email="stranger@example.com", status="pending")
db.add(inv)
db.commit()
_approve(as_user(admin), inv.id)
(_, _lang, link, has_account, _), _ = sent[0]
# Nothing to link to yet. Saying a confirmation mail follows is what makes its ABSENCE
# noticeable — the failure that went unnoticed on 2026-09-03.
assert link is None and has_account is False
# --- the deliberate invitation --------------------------------------------------------------------
def test_adding_an_address_to_the_whitelist_mails_nobody(db, make_user, as_user, monkeypatch):
_smtp_on(monkeypatch)
approved = _capture(monkeypatch, "send_access_approved")
invited = _capture(monkeypatch, "send_access_invitation")
admin = make_user(email="admin@example.com", role="admin")
r = as_user(admin).post("/api/admin/invites", json={"email": "maybe@example.com"})
# Whitelisting opens a door as a possibility — often already agreed in person — and an address
# can sit approved for weeks. A mail fired automatically here would be nudging someone who never
# asked to be nudged.
assert r.status_code == 200 and r.json()["invited_at"] is None
assert approved == [] and invited == []
def test_the_admin_can_then_invite_them_on_purpose(db, make_user, as_user, monkeypatch):
_smtp_on(monkeypatch)
invited = _capture(monkeypatch, "send_access_invitation")
admin = make_user(email="admin@example.com", role="admin")
inv = Invite(email="maybe@example.com", status="approved", decided_by=admin.email)
db.add(inv)
db.commit()
r = as_user(admin).post(f"/api/admin/invites/{inv.id}/invite")
assert r.status_code == 200 and r.json()["invited_at"] is not None
assert invited[0][0][0] == "maybe@example.com"
# Recorded on the row, so "did I already write to them?" has an answer where the decision is
# made — the wrong answer means mailing a real person twice.
db.expire_all()
assert db.get(Invite, inv.id).invited_at is not None
assert "invite_send" in db.execute(select(AuditLog.action)).scalars().all()
def test_an_undecided_request_cannot_be_invited(db, make_user, as_user, monkeypatch):
_smtp_on(monkeypatch)
admin = make_user(email="admin@example.com", role="admin")
inv = Invite(email="asking@example.com", status="pending")
db.add(inv)
db.commit()
r = as_user(admin).post(f"/api/admin/invites/{inv.id}/invite")
assert r.status_code == 409 and r.json()["detail"]["reason"] == "not_approved"
def test_the_invitation_says_so_when_the_instance_cannot_send_mail(
db, make_user, as_user, monkeypatch
):
from app import email as email_mod
monkeypatch.setattr(email_mod, "email_enabled", lambda db=None: False)
admin = make_user(email="admin@example.com", role="admin")
inv = Invite(email="maybe@example.com", status="approved", decided_by=admin.email)
db.add(inv)
db.commit()
r = as_user(admin).post(f"/api/admin/invites/{inv.id}/invite")
assert r.status_code == 409 and r.json()["detail"]["reason"] == "email_off"
db.expire_all()
assert db.get(Invite, inv.id).invited_at is None # nothing sent, nothing recorded
# --- the silent register no-op --------------------------------------------------------------------
def test_registering_again_re_sends_the_link_instead_of_sending_nothing(db, client, monkeypatch):
_smtp_on(monkeypatch)
sent = _capture(monkeypatch, "send_verify_email")
assert (
client.post(
"/auth/register", json={"email": "twice@example.com", "password": _PW}
).status_code
== 200
)
assert len(sent) == 1
r = client.post("/auth/register", json={"email": "twice@example.com", "password": _PW})
# The old behaviour was to return in silence: same cheerful "check your inbox", no mail. That is
# the most likely explanation for the 2026-09-03 confirmation mail that "never arrived" — none
# was ever sent. The response stays uniform; only the dead end is gone.
assert r.status_code == 200 and r.json() == {"status": "ok"}
assert len(sent) == 2
assert sent[1][0][0] == "twice@example.com"
def test_registering_again_does_not_create_a_second_account(db, client, monkeypatch):
_smtp_on(monkeypatch)
_capture(monkeypatch, "send_verify_email")
for _ in range(2):
client.post("/auth/register", json={"email": "twice@example.com", "password": _PW})
db.expire_all()
rows = db.execute(select(User).where(User.email == "twice@example.com")).scalars().all()
assert len(rows) == 1
def test_a_confirmed_account_still_gets_nothing(db, client, make_user, monkeypatch):
_smtp_on(monkeypatch)
sent = _capture(monkeypatch, "send_verify_email")
make_user(email="done@example.com", password=_PW) # already verified
r = client.post("/auth/register", json={"email": "done@example.com", "password": _PW})
# There is nothing to confirm, so there is nothing to send — and mailing "confirm your address"
# to someone who already did would be a lie.
assert r.status_code == 200
assert sent == []
+14 -3
View File
@@ -17,6 +17,7 @@ import {
import { notify, removeByMetaKind } from "./lib/notifications";
import { LS, setAccountRaw } from "./lib/storage";
import { usePref } from "./lib/usePref";
import { useVerifyEntry } from "./lib/useVerifyEntry";
import { useNavigation, useNavigationActions } from "./components/NavigationProvider";
import { useFeedFilters, useFeedFiltersActions } from "./components/FeedFiltersProvider";
import { useChannelLayout } from "./components/ChannelsProvider";
@@ -37,6 +38,7 @@ import { StateMessage } from "./components/QueryState";
import ErrorDialog from "./components/ErrorDialog";
import VersionBanner from "./components/VersionBanner";
import DemoBanner from "./components/DemoBanner";
import VerifyBanner from "./components/VerifyBanner";
import { focusAccessRequestsTab } from "./lib/adminUsersTab";
// The built app version, NOT imported from releaseNotes — importing anything from that module
// pulls its whole ~900-line changelog into the eager App chunk and defeats the lazy ReleaseNotes
@@ -54,6 +56,11 @@ export default function App() {
const { t, i18n } = useTranslation();
const { page, channelView, ytSearch } = useNavigation();
const { setPage, enterYtSearch } = useNavigationActions();
// An emailed verification link is confirmed HERE, above the signed-in/signed-out branch below, so
// it works in a browser that already holds a session. It used to be handled inside Welcome, which
// that branch never renders when signed in — the token was silently dropped. Signed out, the
// outcome goes to the auth card's banners; signed in, to the top-of-app bar.
const verifyEntry = useVerifyEntry();
// The global feed filters live in FeedFiltersProvider (per-account persistence + the account-load
// effect + share-link capture all moved there). App reads them to hand to <Feed> and to the
// handlers that mutate the feed view (onShowInFeed, tag-click, the feed scrollKey).
@@ -240,7 +247,10 @@ export default function App() {
// Keep a single, current nudge: drop any prior one (persisted copies reload as dismissed
// history, so they'd otherwise pile up one-per-reload) before re-issuing the live notice.
removeByMetaKind("access-requests");
const pending = meQuery.data.pending_invites ?? 0;
// Both kinds of "someone is waiting on you": a request needing a decision, and a sign-up whose
// address was approved before they registered. The second used to produce NO signal at all —
// nothing lands in the queue for it — which is how the 2026-09-03 registrant went unnoticed.
const pending = (meQuery.data.pending_invites ?? 0) + (meQuery.data.pending_activations ?? 0);
if (pending > 0) {
notify({
level: "warning",
@@ -259,7 +269,7 @@ export default function App() {
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [meQuery.data?.role, meQuery.data?.pending_invites]);
}, [meQuery.data?.role, meQuery.data?.pending_invites, meQuery.data?.pending_activations]);
// Language is edited outside the Settings page (sidebar/login), so it stays instant.
function changeLanguage(code: LangCode) {
@@ -288,7 +298,7 @@ export default function App() {
</StateMessage>
</div>
);
if (!meQuery.data) return <Welcome />;
if (!meQuery.data) return <Welcome verify={verifyEntry} />;
// Shown briefly while a lazy page/module chunk loads (first visit to that page only).
const pageFallback = (
@@ -386,6 +396,7 @@ export default function App() {
header={<Header me={meQuery.data!} onYtSearch={enterYtSearch} />}
banners={
<>
<VerifyBanner entry={verifyEntry} />
{meQuery.data!.is_demo && <DemoBanner />}
<VersionBanner onOpen={() => openReleaseNotes(FRONTEND_VERSION)} />
</>
+418 -8
View File
@@ -2,12 +2,34 @@ import { useState } from "react";
import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Ban, Check, RotateCcw, Shield, Trash2, UserPlus, X } from "lucide-react";
import { api, type AdminUserRow, type Invite, type Me } from "../lib/api";
import {
Ban,
Check,
Clock,
MailWarning,
Mails,
RotateCcw,
Send,
Shield,
Trash2,
UserPlus,
X,
} from "lucide-react";
import {
api,
HttpError,
type AdminUserRow,
type Invite,
type Me,
type PendingAccessRow,
} from "../lib/api";
import { notify } from "../lib/notifications";
import { ADMIN_USERS_TAB_KEY } from "../lib/adminUsersTab";
import Tooltip from "./Tooltip";
import InlineNotice from "./InlineNotice";
import { Section } from "./ui/form";
import SegmentedToggle from "./SegmentedToggle";
import { LANGUAGES, type LangCode } from "../i18n";
import { LoadingState, StateMessage } from "./QueryState";
import { useConfirm } from "./ConfirmProvider";
import Tabs, { usePersistedTab } from "./Tabs";
@@ -26,7 +48,14 @@ export default function AdminUsers() {
const [tab, setTab] = usePersistedTab(ADMIN_USERS_TAB_KEY, "roles");
const tabs = [
{ id: "roles", label: t("users.tabs.roles") },
{ id: "access", label: t("users.tabs.access"), badge: me.pending_invites },
// Both kinds of "waiting on the admin": a request needing a decision, and a sign-up whose
// address was approved BEFORE they registered — the second never reaches the invite queue,
// so the badge used to read zero while somebody sat locked out (roadmap UX-25).
{
id: "access",
label: t("users.tabs.access"),
badge: me.pending_invites + me.pending_activations,
},
{ id: "demo", label: t("users.tabs.demo") },
];
const active = tabs.some((x) => x.id === tab) ? tab : "roles";
@@ -40,7 +69,12 @@ export default function AdminUsers() {
</PageToolbar>
<div className="px-4 pb-4 pt-3 max-w-3xl w-full mx-auto">
{active === "roles" && <UsersRoles me={me} />}
{active === "access" && <AdminInvites />}
{active === "access" && (
<>
<AdminInvites />
<PendingAccess />
</>
)}
{active === "demo" && <AdminDemo />}
</div>
</>
@@ -260,17 +294,217 @@ function UsersRoles({ me }: { me: Me }) {
);
}
/** Did the invitation fail because this instance has no working SMTP? */
function inviteEmailOff(err: HttpError): boolean {
const d = err.detailData as { code?: string; reason?: string } | null | undefined;
return d?.code === "invite_send" && d.reason === "email_off";
}
// --- Sign-ups that are still outside (roadmap UX-25) --------------------------------------------
//
// The gap this closes: approve an address BEFORE the person registers and their sign-up produces no
// queue row at all — `_decide` only takes a pending invite — so the Access-requests screen above was
// empty while they sat locked out. The only working move was re-typing the address into the
// whitelist box, which nobody would guess. This section makes the state visible and the fix one
// click, WITHOUT changing who may cause what: the button is admin-authenticated and explicit. Two
// earlier attempts moved authority instead (auto-activating on registration, reopening the approved
// invite) and were reverted after review.
function PendingAccess() {
const { t } = useTranslation();
const qc = useQueryClient();
const rows = useQuery({
queryKey: qk.adminPendingAccess(),
queryFn: () => api.adminPendingAccess(),
});
// Activating changes /api/me's badge count too, so refresh both or the number lies until reload.
const refresh = () => {
qc.invalidateQueries({ queryKey: qk.adminPendingAccess() });
qc.invalidateQueries({ queryKey: qk.me() });
qc.invalidateQueries({ queryKey: qk.adminUsers() });
};
const activate = useMutation({
mutationFn: (u: PendingAccessRow) => api.activateUser(u.id),
onSuccess: (_r, u) => {
refresh();
notify({ level: "success", message: t("users.pendingAccess.activated", { email: u.email }) });
},
onError: () => notify({ level: "error", message: t("users.pendingAccess.activateFailed") }),
});
const resend = useMutation({
mutationFn: (u: PendingAccessRow) => api.resendUserVerification(u.id),
onSuccess: (_r, u) => {
refresh();
notify({ level: "success", message: t("users.pendingAccess.resent", { email: u.email }) });
},
// The public resend is deliberately silent; this one is not, so say WHY it failed — an instance
// with no working SMTP would otherwise show a success toast for a mail that was never sent.
onError: (err) =>
notify({
level: "error",
message:
err instanceof HttpError && isEmailOff(err)
? t("users.pendingAccess.resendEmailOff")
: t("users.pendingAccess.resendFailed"),
}),
});
const list = rows.data ?? [];
const busy = activate.isPending || resend.isPending;
if (rows.isSuccess && list.length === 0) return null; // nothing stuck: no empty box to explain
return (
<Section card title={t("users.pendingAccess.title")}>
<p className="text-xs text-muted leading-relaxed mb-3">{t("users.pendingAccess.body")}</p>
{rows.isError && !rows.data ? (
<StateMessage tone="error" onRetry={() => rows.refetch()}>
{t("common.loadError")}
</StateMessage>
) : rows.isLoading && !rows.data ? (
<LoadingState />
) : (
<div className="space-y-1.5">
{list.map((u) => (
<PendingAccessRowView
key={u.id}
row={u}
busy={busy}
onActivate={() => activate.mutate(u)}
onResend={() => resend.mutate(u)}
/>
))}
</div>
)}
</Section>
);
}
/** Did the resend fail because this instance has no working SMTP? */
function isEmailOff(err: HttpError): boolean {
const d = err.detailData as { code?: string; reason?: string } | null | undefined;
return d?.code === "resend" && d.reason === "email_off";
}
// One stuck sign-up. The row names WHICH gate is shut rather than just offering buttons — the same
// reasoning as the sign-in card's blocked panel, from the admin's side: an admin about to let someone
// in should see that the address has not been confirmed yet, because on a no-SMTP instance that is
// the difference between the owner and whoever registered the address first (roadmap SEC-15).
function PendingAccessRowView({
row,
busy,
onActivate,
onResend,
}: {
row: PendingAccessRow;
busy: boolean;
onActivate: () => void;
onResend: () => void;
}) {
const { t } = useTranslation();
return (
<div className="glass-card flex items-center gap-2 p-2.5 rounded-xl">
<div className="min-w-0 flex-1">
<div className="text-sm truncate">{row.email}</div>
<div className="text-2xs text-muted flex items-center gap-2 mt-0.5 flex-wrap">
{row.created_at && (
<span>
{t("users.pendingAccess.signedUp", {
time: new Date(row.created_at).toLocaleString(),
})}
</span>
)}
{row.email_verified ? (
<span className="inline-flex items-center gap-1 text-success">
<Check className="w-3 h-3" />
{t("users.pendingAccess.emailConfirmed")}
</span>
) : (
<span className="inline-flex items-center gap-1 text-warn">
<MailWarning className="w-3 h-3" />
{t("users.pendingAccess.emailUnconfirmed")}
</span>
)}
{!row.is_active && (
<span className="inline-flex items-center gap-1">
<Clock className="w-3 h-3" />
{t("users.pendingAccess.inactive")}
</span>
)}
</div>
</div>
{!row.email_verified && (
<Tooltip hint={t("users.pendingAccess.resendHint")}>
<button
onClick={onResend}
disabled={busy}
className="shrink-0 p-1.5 rounded-lg border border-border text-muted hover:text-fg disabled:opacity-50 transition"
aria-label={t("users.pendingAccess.resend")}
>
<Send className="w-4 h-4" />
</button>
</Tooltip>
)}
{row.can_activate && (
<Tooltip hint={t("users.pendingAccess.activateHint")}>
<button
onClick={onActivate}
disabled={busy}
className="shrink-0 px-2.5 py-1.5 rounded-lg border border-accent/40 text-accent text-xs font-medium hover:bg-accent/10 disabled:opacity-50 transition"
>
{t("users.pendingAccess.activate")}
</button>
</Tooltip>
)}
</div>
);
}
/** The mail language for one invitation. Owns its own display state and reports every change up
* through `onPick`, because the dialog that hosts it resolves to a plain boolean — the choice has
* to live in the caller's closure, not in this component. Endonyms come from the app's own
* language list, so a third locale would appear here for free. */
function InviteLangPicker({
initial,
onPick,
}: {
initial: LangCode;
onPick: (lang: LangCode) => void;
}) {
const { t } = useTranslation();
const [lang, setLang] = useState<LangCode>(initial);
return (
<SegmentedToggle
className="mt-3"
size="md"
ariaLabel={t("settings.invites.inviteLang")}
label={t("settings.invites.inviteLang")}
options={LANGUAGES.map((l) => ({ value: l.code, label: l.label }))}
value={lang}
onChange={(v) => {
setLang(v);
onPick(v);
}}
/>
);
}
// --- Access requests (the Invite whitelist) — moved verbatim from Settings → Account -------
function AdminInvites() {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const qc = useQueryClient();
const confirm = useConfirm();
const me = useMe();
const invites = useQuery({ queryKey: qk.adminInvites(), queryFn: () => api.adminInvites() });
const [newEmail, setNewEmail] = useState("");
const refresh = () => {
qc.invalidateQueries({ queryKey: qk.adminInvites() });
qc.invalidateQueries({ queryKey: qk.me() }); // updates the pending badge
// The stuck-sign-ups section below describes the same backlog and changes with every decision
// here: approving an address moves a registrant INTO it (active, still unverified), whitelisting
// one takes a row out. Leaving it stale hides the resend button this epic added for them.
qc.invalidateQueries({ queryKey: qk.adminPendingAccess() });
};
const approve = useMutation({
mutationFn: ({ id }: { id: number; email: string }) => api.approveInvite(id),
@@ -297,6 +531,92 @@ function AdminInvites() {
});
if (ok) deny.mutate(i.id);
};
const remove = useMutation({
mutationFn: (id: number) => api.deleteInvite(id),
onSuccess: (d) => {
refresh();
// Removing the row is not always a revoke: is_allowed falls back to the env allow-lists, so
// for an address on those the person keeps access. Saying "removed" alone would mislead.
if (d.still_allowed_by_env)
notify({ level: "warning", message: t("settings.invites.deletedStillAllowed") });
else notify({ level: "success", message: t("settings.invites.deleted") });
},
onError: (e) => {
// A 409 means the list was stale — someone registered on that address since it loaded. Say
// what to do instead of a generic failure, and refetch so the row re-renders as read-only.
const conflict = e instanceof HttpError && e.status === 409;
notify({
level: "error",
message: t(
conflict ? "settings.invites.deleteHasAccount" : "settings.invites.deleteFailed",
),
});
if (conflict) refresh();
},
});
const onDelete = async (i: Invite) => {
const ok = await confirm({
title: t("settings.invites.deleteTitle"),
// Removing the row is not a revoke when the address is also in the server's env allow-lists —
// say so while the admin can still change their mind, not in a toast after the fact.
message: t(
i.still_allowed_by_env
? "settings.invites.deleteConfirmStillAllowed"
: "settings.invites.deleteConfirm",
{ email: i.email },
),
confirmLabel: t("settings.invites.delete"),
danger: true,
});
if (ok) remove.mutate(i.id);
};
// Sending is its OWN act, never a side effect of approving an address: the operator opens a door
// as a possibility (often already agreed in person), and a mail fired automatically would be
// nudging someone who never asked. `invited_at` comes back on the row so a second click is a
// decision rather than an accident.
const invite = useMutation({
mutationFn: (v: { inv: Invite; lang: LangCode }) => api.sendInvitation(v.inv.id, v.lang),
onSuccess: (_r, v) => {
refresh();
notify({ level: "success", message: t("settings.invites.invited", { email: v.inv.email }) });
},
onError: (err) =>
notify({
level: "error",
message:
err instanceof HttpError && inviteEmailOff(err)
? t("settings.invites.inviteEmailOff")
: t("settings.invites.inviteFailed"),
}),
});
// The one recipient the app has no language signal about: the admin typed this address in, so
// nobody ever told us how to write to its owner. The dialog is where that gets decided — and it
// is also where the "you already wrote to them" warning belongs, next to the button that would
// do it a second time, rather than only in a tooltip the admin may never hover.
const onInvite = async (inv: Invite) => {
let lang: LangCode = LANGUAGES.some((l) => l.code === inv.lang)
? (inv.lang as LangCode)
: ((i18n.language.split("-")[0] === "hu" ? "hu" : "en") as LangCode);
const ok = await confirm({
title: t("settings.invites.inviteTitle"),
message: (
<>
<p>
{t(
inv.invited_at
? "settings.invites.inviteConfirmAgain"
: "settings.invites.inviteConfirm",
{ email: inv.email, time: new Date(inv.invited_at ?? 0).toLocaleString() },
)}
</p>
<InviteLangPicker initial={lang} onPick={(v) => (lang = v)} />
</>
),
confirmLabel: t("settings.invites.inviteSend"),
});
if (ok) invite.mutate({ inv, lang });
};
const add = useMutation({
mutationFn: (email: string) => api.addInvite(email),
onSuccess: (_d, email) => {
@@ -313,6 +633,9 @@ function AdminInvites() {
return (
<Section card title={t("settings.invites.title")}>
{!me.email_configured && (
<InlineNotice className="mb-3">{t("settings.invites.emailOff")}</InlineNotice>
)}
{invites.isError && !invites.data ? (
<StateMessage tone="error" onRetry={() => invites.refetch()}>
{t("common.loadError")}
@@ -329,7 +652,8 @@ function AdminInvites() {
inv={i}
onApprove={() => approve.mutate({ id: i.id, email: i.email })}
onDeny={() => onDeny(i)}
busy={approve.isPending || deny.isPending}
onDelete={() => onDelete(i)}
busy={approve.isPending || deny.isPending || remove.isPending}
/>
))}
</div>
@@ -364,11 +688,67 @@ function AdminInvites() {
</summary>
<div className="mt-2 space-y-1 text-xs text-muted">
{decided.map((i) => (
<div key={i.id} className="flex items-center justify-between gap-2">
<span className="truncate">{i.email}</span>
<div key={i.id} className="flex items-center gap-2">
<span className="truncate flex-1">{i.email}</span>
<span className={i.status === "approved" ? "text-accent" : "text-danger"}>
{i.status}
</span>
{/* Only an approved row with NOBODY behind it yet: this button means "the door is
open, come in", which is meaningless to someone already inside — and half the
whitelist is existing users. An account that exists but is still stuck has its
own surface above, with a re-send. The label carries the answer to "did I already
write to them?", the question whose wrong answer means mailing a person twice. */}
{i.status === "approved" && !i.has_account && (
<Tooltip
hint={
i.invited_at
? t("settings.invites.invitedAt", {
time: new Date(i.invited_at).toLocaleString(),
})
: t("settings.invites.inviteHint")
}
>
<button
onClick={() => onInvite(i)}
disabled={invite.isPending}
className={`shrink-0 p-1 rounded-md disabled:opacity-50 transition ${
i.invited_at
? "text-muted/50 hover:text-fg"
: "text-muted hover:text-accent"
}`}
aria-label={t("settings.invites.inviteLabel", { email: i.email })}
>
<Mails className="w-3.5 h-3.5" />
</button>
</Tooltip>
)}
{/* An entry with an account behind it stays read-only: deleting it would revoke that
user's access, since is_allowed reads this row. Delete the user instead. */}
{i.has_account ? (
// `disabled` would drop it from the tab order AND suppress the title, so the
// reason reached nobody — same call as the Playlists drag handle.
<button
type="button"
aria-disabled
onClick={(e) => e.preventDefault()}
title={t("settings.invites.deleteHasAccount")}
aria-label={t("settings.invites.deleteHasAccount")}
className="shrink-0 p-1 text-muted/50 cursor-not-allowed"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
) : (
<Tooltip hint={t("settings.invites.deleteHint")}>
<button
onClick={() => onDelete(i)}
disabled={remove.isPending}
className="shrink-0 p-1 rounded-md text-muted hover:text-danger disabled:opacity-50 transition"
aria-label={t("settings.invites.deleteLabel", { email: i.email })}
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</Tooltip>
)}
</div>
))}
</div>
@@ -506,11 +886,13 @@ function InviteRow({
inv,
onApprove,
onDeny,
onDelete,
busy,
}: {
inv: Invite;
onApprove: () => void;
onDeny: () => void;
onDelete: () => void;
busy: boolean;
}) {
const { t } = useTranslation();
@@ -546,6 +928,34 @@ function InviteRow({
<X className="w-4 h-4" />
</button>
</Tooltip>
{/* Deny records a decision and keeps the row; delete forgets the address. Once an account
exists for it the row is that account's approval gate, so it is managed from Users & roles
instead — shown as a disabled control rather than omitted, or the admin sees a row that is
silently missing a button their other rows have. */}
{inv.has_account ? (
// aria-disabled, not `disabled`: the reason has to stay reachable by keyboard.
<button
type="button"
aria-disabled
onClick={(e) => e.preventDefault()}
title={t("settings.invites.deleteHasAccount")}
aria-label={t("settings.invites.deleteHasAccount")}
className="shrink-0 p-1.5 rounded-lg border border-border/50 text-muted/50 cursor-not-allowed"
>
<Trash2 className="w-4 h-4" />
</button>
) : (
<Tooltip hint={t("settings.invites.deleteHint")}>
<button
onClick={onDelete}
disabled={busy}
className="shrink-0 p-1.5 rounded-lg border border-border text-muted hover:text-danger disabled:opacity-50 transition"
aria-label={t("settings.invites.deleteLabel", { email: inv.email })}
>
<Trash2 className="w-4 h-4" />
</button>
</Tooltip>
)}
</div>
);
}
+9 -1
View File
@@ -26,6 +26,7 @@ export default function Banner({
action,
onDismiss,
dismissTitle,
role,
}: {
tone: BannerTone;
icon: LucideIcon;
@@ -33,10 +34,17 @@ export default function Banner({
action?: { label: string; onClick: () => void };
onDismiss?: () => void;
dismissTitle?: string;
/** `"status"` for a bar that APPEARS in response to something the user just did, so a screen
* reader announces it. Left unset for the permanent bars (demo, version): those are part of the
* page when it loads, and announcing them on every visit is noise, not information. */
role?: "status";
}) {
const c = TONES[tone];
return (
<div className={`shrink-0 flex items-center gap-2 px-4 py-2 text-sm border-b text-fg ${c.bar}`}>
<div
role={role}
className={`shrink-0 flex items-center gap-2 px-4 py-2 text-sm border-b text-fg ${c.bar}`}
>
<Icon className={`w-4 h-4 shrink-0 ${c.icon}`} />
<span className="min-w-0">{children}</span>
{action && (
+12 -1
View File
@@ -6,6 +6,8 @@ import { Plug, RotateCcw, Send } from "lucide-react";
import { api, type ConfigItem, type PlexTestResult } from "../lib/api";
import { notify } from "../lib/notifications";
import Tooltip from "./Tooltip";
import InlineNotice from "./InlineNotice";
import { useMe } from "../lib/useMe";
import Tabs, { usePersistedTab } from "./Tabs";
import { LoadingState, StateMessage } from "./QueryState";
import { PageToolbar } from "./PageShell";
@@ -35,6 +37,7 @@ const GROUP_ORDER = [
export default function ConfigPanel() {
const { t } = useTranslation();
const qc = useQueryClient();
const me = useMe();
const q = useQuery({ queryKey: qk.adminConfig(), queryFn: api.adminConfig });
const data = q.data;
@@ -100,6 +103,10 @@ export default function ConfigPanel() {
}
}
await qc.invalidateQueries({ queryKey: qk.adminConfig() });
// The email-not-configured notice reads me.email_configured, which is otherwise only
// refetched on its own 60s interval — without this it keeps warning about fields the admin
// has just filled in, which reads as the save not having taken.
await qc.invalidateQueries({ queryKey: qk.me() });
setReseedToken((n) => n + 1); // re-seed the draft from the saved server state
setSaveState("saved");
setTimeout(() => setSaveState((s) => (s === "saved" ? "idle" : s)), 2500);
@@ -204,7 +211,11 @@ export default function ConfigPanel() {
</div>
{activeGroup === "email" && (
<div className="mt-3 pt-3 border-t border-border/60">
<div className="mt-3 pt-3 border-t border-border/60 space-y-3">
{/* The one place an operator can see that mail is off. Sending is fail-soft, so a
half-filled config costs verification links, resets and access-request notices
with no error anywhere — say so next to the fields that fix it. */}
{!me.email_configured && <InlineNotice>{t("config.emailOff")}</InlineNotice>}
<Tooltip hint={t("config.testEmailHint")}>
<button
onClick={() => testEmail.mutate()}
+1 -1
View File
@@ -94,7 +94,7 @@ export function ConfirmProvider({ children }: { children: ReactNode }) {
onClose={() => close(null)}
maxWidth={choices.length > 1 ? "max-w-md" : "max-w-sm"}
>
<p className="text-sm text-muted leading-relaxed">{state.opts.message}</p>
<div className="text-sm text-muted leading-relaxed">{state.opts.message}</div>
{/* One action: the familiar Cancel/Confirm row. Two or more: a column, because side by
side they wrap raggedly and a wrapped button reads as a second, lesser group — and
these are peers. Ordered least to most destructive, top to bottom. */}
+141 -71
View File
@@ -6,8 +6,10 @@ import {
Ban,
ClipboardPaste,
Download,
Link2,
Pause,
Play,
PlayCircle,
Plus,
RotateCcw,
Scissors,
@@ -15,6 +17,7 @@ import {
Share2,
Pencil,
Trash2,
UploadCloud,
} from "lucide-react";
import Tabs from "./Tabs";
import { PageToolbar } from "./PageShell";
@@ -22,14 +25,17 @@ import { LoadingState, StateMessage } from "./QueryState";
// Lazy: these dialogs/editors open on demand, so they stay out of the Downloads page chunk until
// used (the video editor in particular is heavy — filmstrip + scrubber).
const DownloadDialog = lazy(() => import("./DownloadDialog"));
const UploadDialog = lazy(() => import("./UploadDialog"));
const ProfileEditor = lazy(() => import("./ProfileEditor"));
const VideoEditor = lazy(() => import("./VideoEditor"));
const ShareDialog = lazy(() => import("./ShareDialog"));
const LibraryPlayer = lazy(() => import("./LibraryPlayer"));
import { useConfirm } from "./ConfirmProvider";
import { useLiveQuery } from "../lib/useLiveQuery";
import { useMe } from "../lib/useMe";
import { api, type DownloadJob } from "../lib/api";
import { invalidateDownloads } from "../lib/downloads";
import { invalidateDownloads, quickShareLink } from "../lib/downloads";
import { libraryRowActions, sharedRowActions } from "../lib/downloadActions";
import { inputCls } from "./ui/form";
import { Button } from "./ui/Button";
import { DownloadRow, IconBtn, UsageBar } from "./downloadRow";
@@ -46,9 +52,11 @@ export default function DownloadCenter() {
const [tab, setTab] = useState("queue");
const [addUrl, setAddUrl] = useState("");
const [addSource, setAddSource] = useState<string | null>(null);
const [upload, setUpload] = useState(false);
const [manage, setManage] = useState(false);
const [metaJob, setMetaJob] = useState<DownloadJob | null>(null);
const [shareJob, setShareJob] = useState<DownloadJob | null>(null);
const [playJob, setPlayJob] = useState<DownloadJob | null>(null);
const [editJob, setEditJob] = useState<DownloadJob | null>(null);
const [errorJob, setErrorJob] = useState<DownloadJob | null>(null);
@@ -101,6 +109,21 @@ export default function DownloadCenter() {
if (await confirm({ title, message: body, confirmLabel: title, danger: true })) fn();
};
// One-click share (E7 S3 · D2-A): ensure a downloadable link, copy it, toast with "open in new tab"
// (see quickShareLink). Re-clicking reuses the existing link; a per-job in-flight guard also stops a
// double-click from racing two POSTs into two links before the cache is primed. Advanced controls
// (password, expiry, revoke, share-with-a-user) live in the Share dialog, via the manage-share button.
const [sharingId, setSharingId] = useState<number | null>(null);
const quickShare = async (job: DownloadJob) => {
if (sharingId !== null) return;
setSharingId(job.id);
try {
await quickShareLink(qc, job, t);
} finally {
setSharingId(null);
}
};
// Pull a link off the clipboard into the add-URL box. Clipboard reads need a secure context +
// a user gesture/permission (iOS Safari/Brave are strict) — so this is best-effort: the paste
// button is an explicit gesture (reliable), and the input's onFocus tries silently (ignored if
@@ -188,6 +211,16 @@ export default function DownloadCenter() {
>
{t("downloads.page.add")}
</Button>
{/* Local upload is a peer to "add a URL": a general file → the same library (E7 S5c). */}
<Button
variant="outline"
size="sm"
icon={<UploadCloud className="w-4 h-4" />}
onClick={() => setUpload(true)}
className="shrink-0"
>
{t("downloads.page.upload")}
</Button>
</div>
<div className="space-y-2">
{queue.map((job) => (
@@ -247,50 +280,69 @@ export default function DownloadCenter() {
<>
<UsageBar />
<div className="space-y-2">
{library.map((job) => (
<DownloadRow key={job.id} job={job}>
{saveBtn(job)}
{job.can_download && (job.asset?.duration_s ?? 0) > 0 && (
<IconBtn onClick={() => setEditJob(job)} title={t("downloads.actions.edit")}>
<Scissors className="w-4 h-4" />
{library.map((job) => {
// Row actions gated on the stored media_kind + job_kind (see downloadActions).
const a = libraryRowActions(job);
return (
<DownloadRow key={job.id} job={job}>
{a.play && (
<IconBtn onClick={() => setPlayJob(job)} title={t("downloads.actions.play")}>
<PlayCircle className="w-4 h-4" />
</IconBtn>
)}
{saveBtn(job)}
{a.edit && (
<IconBtn onClick={() => setEditJob(job)} title={t("downloads.actions.edit")}>
<Scissors className="w-4 h-4" />
</IconBtn>
)}
{a.editDetails && (
<IconBtn
onClick={() => setMetaJob(job)}
title={t("downloads.actions.editDetails")}
>
<Pencil className="w-4 h-4" />
</IconBtn>
)}
<IconBtn onClick={() => quickShare(job)} title={t("downloads.actions.share")}>
<Share2 className="w-4 h-4" />
</IconBtn>
)}
<IconBtn
onClick={() => setMetaJob(job)}
title={t("downloads.actions.editDetails")}
>
<Pencil className="w-4 h-4" />
</IconBtn>
<IconBtn onClick={() => setShareJob(job)} title={t("downloads.actions.share")}>
<Share2 className="w-4 h-4" />
</IconBtn>
<IconBtn
onClick={() =>
confirmThen(
t("downloads.confirm.redownloadTitle"),
t("downloads.confirm.redownloadBody"),
() => act.mutate({ id: job.id, op: "redownload" }),
)
}
title={t("downloads.actions.redownload")}
>
<RotateCcw className="w-4 h-4" />
</IconBtn>
<IconBtn
onClick={() =>
confirmThen(
t("downloads.confirm.deleteTitle"),
t("downloads.confirm.deleteBody"),
() => act.mutate({ id: job.id, op: "delete" }),
)
}
title={t("downloads.actions.delete")}
danger
>
<Trash2 className="w-4 h-4" />
</IconBtn>
</DownloadRow>
))}
<IconBtn
onClick={() => setShareJob(job)}
title={t("downloads.actions.shareManage")}
>
<Link2 className="w-4 h-4" />
</IconBtn>
{a.redownload && (
<IconBtn
onClick={() =>
confirmThen(
t("downloads.confirm.redownloadTitle"),
t("downloads.confirm.redownloadBody"),
() => act.mutate({ id: job.id, op: "redownload" }),
)
}
title={t("downloads.actions.redownload")}
>
<RotateCcw className="w-4 h-4" />
</IconBtn>
)}
<IconBtn
onClick={() =>
confirmThen(
t("downloads.confirm.deleteTitle"),
t("downloads.confirm.deleteBody"),
() => act.mutate({ id: job.id, op: "delete" }),
)
}
title={t("downloads.actions.delete")}
danger
>
<Trash2 className="w-4 h-4" />
</IconBtn>
</DownloadRow>
);
})}
{library.length === 0 &&
(jobsQ.isError && !jobsQ.data ? (
<StateMessage tone="error" onRetry={() => jobsQ.refetch()}>
@@ -309,34 +361,50 @@ export default function DownloadCenter() {
{tab === "shared" && (
<div className="space-y-2">
{(sharedQ.data ?? []).map((job) => (
<DownloadRow key={job.id} job={job}>
{job.can_download && saveBtn(job)}
{job.can_download && (job.asset?.duration_s ?? 0) > 0 && (
<IconBtn onClick={() => setEditJob(job)} title={t("downloads.actions.edit")}>
<Scissors className="w-4 h-4" />
{(sharedQ.data ?? []).map((job) => {
// Same media_kind + can_download gate as the library, via the shared helper (S5b).
const a = sharedRowActions(job);
return (
<DownloadRow key={job.id} job={job}>
{a.play && (
<IconBtn onClick={() => setPlayJob(job)} title={t("downloads.actions.play")}>
<PlayCircle className="w-4 h-4" />
</IconBtn>
)}
{a.save && saveBtn(job)}
{a.edit && (
<IconBtn onClick={() => setEditJob(job)} title={t("downloads.actions.edit")}>
<Scissors className="w-4 h-4" />
</IconBtn>
)}
<IconBtn
onClick={() =>
confirmThen(
t("downloads.confirm.removeSharedTitle"),
t("downloads.confirm.removeSharedBody"),
() => removeShared.mutate(job.id),
)
}
title={t("downloads.actions.removeShared")}
danger
>
<Trash2 className="w-4 h-4" />
</IconBtn>
)}
<IconBtn
onClick={() =>
confirmThen(
t("downloads.confirm.removeSharedTitle"),
t("downloads.confirm.removeSharedBody"),
() => removeShared.mutate(job.id),
)
}
title={t("downloads.actions.removeShared")}
danger
>
<Trash2 className="w-4 h-4" />
</IconBtn>
</DownloadRow>
))}
{sharedQ.data && sharedQ.data.length === 0 && (
<div className="text-sm text-muted py-10 text-center">
{t("downloads.empty.shared")}
</div>
)}
</DownloadRow>
);
})}
{(sharedQ.data ?? []).length === 0 &&
(sharedQ.isError && !sharedQ.data ? (
<StateMessage tone="error" onRetry={() => sharedQ.refetch()}>
{t("common.loadError")}
</StateMessage>
) : sharedQ.isLoading ? (
<LoadingState />
) : (
<div className="text-sm text-muted py-10 text-center">
{t("downloads.empty.shared")}
</div>
))}
</div>
)}
@@ -352,9 +420,11 @@ export default function DownloadCenter() {
}}
/>
)}
{upload && <UploadDialog onClose={() => setUpload(false)} />}
{manage && <ProfileEditor onClose={() => setManage(false)} />}
{metaJob && <MetaEditModal job={metaJob} onClose={() => setMetaJob(null)} />}
{shareJob && <ShareDialog job={shareJob} onClose={() => setShareJob(null)} />}
{playJob && <LibraryPlayer job={playJob} onClose={() => setPlayJob(null)} />}
{editJob && <VideoEditor job={editJob} onClose={() => setEditJob(null)} />}
{errorJob && (
<ErrorModal
+26
View File
@@ -0,0 +1,26 @@
import type { ReactNode } from "react";
import type { LucideIcon } from "lucide-react";
import { AlertTriangle } from "lucide-react";
// An in-flow warning that sits INSIDE a panel, next to the thing it is about — as opposed to
// Banner, which is the full-bleed top-of-app bar (border-b, app chrome) and looks wrong nested in a
// card. Same warn tokens as Banner's warning tone so the two read as one system.
export default function InlineNotice({
children,
icon: Icon = AlertTriangle,
className = "",
}: {
children: ReactNode;
icon?: LucideIcon;
className?: string;
}) {
return (
<div
role="status"
className={`flex items-start gap-2 rounded-xl border border-warn/30 bg-warn/15 px-3 py-2 text-sm text-fg ${className}`}
>
<Icon className="w-4 h-4 shrink-0 mt-0.5 text-warn" aria-hidden />
<span className="min-w-0">{children}</span>
</div>
);
}
@@ -0,0 +1,125 @@
// @vitest-environment jsdom
//
// E7·S4b — the in-app library player streams the completed item's LOCAL file into a native media
// element. video/audio is chosen from the stored media_kind (the single source), NOT a container
// list — so an audio upload with an uncommon/absent container still renders <audio src=/file>. i18n
// is key-passthrough here.
import { describe, it, expect, afterEach, vi } from "vitest";
import { render, cleanup } from "@testing-library/react";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
});
import LibraryPlayer from "./LibraryPlayer";
import type { DownloadJob } from "../lib/api";
const baseJob: DownloadJob = {
id: 7,
status: "done",
progress: 100,
speed_bps: null,
eta_s: null,
phase: null,
error: null,
error_code: null,
queue_pos: 0,
created_at: null,
source_kind: "youtube",
source_ref: "abc",
source_url: null,
poster_url: null,
profile_id: null,
spec: {
mode: "av",
max_height: null,
container: null,
audio_format: null,
vcodec: null,
embed_subs: false,
embed_chapters: false,
embed_thumbnail: false,
sponsorblock: false,
},
display_name: "Clip",
display_uploader: null,
display_uploader_url: null,
extra_links: [],
can_download: true,
expired: false,
asset: null,
};
const withAsset = (over: Partial<NonNullable<DownloadJob["asset"]>>): DownloadJob => ({
...baseJob,
asset: {
id: 10,
status: "ready",
title: "Clip",
uploader: null,
uploader_url: null,
upload_date: null,
duration_s: 12,
size_bytes: 1000,
width: null,
height: null,
container: null,
media_kind: "video",
thumbnail_url: null,
expires_at: null,
...over,
},
});
afterEach(cleanup);
describe("LibraryPlayer", () => {
it("plays a video asset in a <video> pointed at the range-aware /file endpoint", () => {
render(
<LibraryPlayer
job={withAsset({ width: 1280, height: 720, container: "mp4" })}
onClose={() => {}}
/>,
);
const video = document.body.querySelector("video");
expect(video).not.toBeNull();
expect(video!.getAttribute("src")).toBe("/api/downloads/7/file");
// No <audio> element for a video asset.
expect(document.body.querySelector("audio")).toBeNull();
});
it("plays an audio-only asset (media_kind='audio') in an <audio> element", () => {
render(
<LibraryPlayer
job={withAsset({ media_kind: "audio", container: "m4a" })}
onClose={() => {}}
/>,
);
const audio = document.body.querySelector("audio");
expect(audio).not.toBeNull();
expect(audio!.getAttribute("src")).toBe("/api/downloads/7/file");
expect(document.body.querySelector("video")).toBeNull();
});
it("uses media_kind — an audio upload with an uncommon container still renders <audio>", () => {
// The exact bug the fix targets: the old container whitelist (m4a/mp3/…) would drop an .m4b/.weba
// audio upload into a <video> box. media_kind='audio' is authoritative regardless of container.
render(
<LibraryPlayer
job={withAsset({ media_kind: "audio", container: "m4b" })}
onClose={() => {}}
/>,
);
expect(document.body.querySelector("audio")).not.toBeNull();
expect(document.body.querySelector("video")).toBeNull();
});
it("falls back to the container heuristic for a legacy null media_kind", () => {
// A pre-discriminator asset (media_kind null) still resolves audio vs video from dims+container.
render(
<LibraryPlayer job={withAsset({ media_kind: null, container: "mp3" })} onClose={() => {}} />,
);
expect(document.body.querySelector("audio")).not.toBeNull();
});
});
+62
View File
@@ -0,0 +1,62 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import Modal from "./Modal";
import { api, type DownloadJob } from "../lib/api";
import { jobTitle } from "./downloadRow";
// E7 S4b — in-app player for a completed library item. We do NOT reuse PlayerModal (a cross-origin
// YouTube IFrame shell) or PlexPlayer (a Plex HLS/session shell): both are bound to their own domain
// APIs and can't point at a local file. Instead we reuse the shared a11y shell (`Modal`: focus-trap,
// ESC/back-to-close, scroll-lock, layer-stack, portal) and stream the local file into a NATIVE
// <video>/<audio controls> — the same element VideoEditor already plays this URL in. Downloads are a
// browser-native container (mp4/H.264/AAC + m4a/mp3), so native playback (seek via the range-aware
// /file endpoint, volume, fullscreen, speed) works with zero transcode; an uploaded media item may be
// any codec (best-effort playback).
export default function LibraryPlayer({ job, onClose }: { job: DownloadJob; onClose: () => void }) {
const { t } = useTranslation();
const fileUrl = useMemo(() => api.downloadFileUrl(job.id), [job.id]);
// Video vs audio-only comes from the stored media_kind (the single source every sibling reads —
// WatchPage/downloadRow/DownloadCenter) — NOT a re-derived container list, which would misclassify
// an uploaded audio file whose container is outside a hardcoded set (uploads accept any container).
// Only fall back to the old dimension+container heuristic for a legacy asset with a null media_kind.
const asset = job.asset;
const isAudio =
asset?.media_kind === "audio" ||
(asset?.media_kind == null &&
!(asset?.width && asset?.height) &&
!!asset?.container &&
["m4a", "mp3", "opus", "ogg", "wav", "flac", "aac"].includes(asset.container.toLowerCase()));
// Our self-hosted poster (always same-origin, never expires) is the primary; the source thumbnail is
// the fallback. Same precedence as the library-row thumbnail.
const poster = job.poster_url ? api.posterImageUrl(job.id) : (asset?.thumbnail_url ?? undefined);
return (
<Modal title={jobTitle(job)} onClose={onClose} maxWidth="max-w-4xl">
{isAudio ? (
<div className="flex flex-col items-center gap-4 py-2">
{poster ? (
<img
src={poster}
alt=""
className="w-40 h-40 rounded-xl object-cover border border-border"
/>
) : null}
<audio src={fileUrl} controls autoPlay className="w-full">
{t("downloads.player.unsupported")}
</audio>
</div>
) : (
<video
src={fileUrl}
poster={poster}
controls
autoPlay
playsInline
className="w-full max-h-[70vh] rounded-lg bg-black object-contain"
>
{t("downloads.player.unsupported")}
</video>
)}
</Modal>
);
}
+14
View File
@@ -281,6 +281,20 @@ export default function NavSidebar({
<div className="min-w-0">
<div className="text-sm font-semibold truncate">{name}</div>
<div className="text-xs text-muted truncate">{me.email}</div>
{/* The address Google answers with, when it isn't the one above. /api/me sends it
only when the two differ, so for most accounts this line never exists — and for
someone who registered as `me+siftlode@gmail.com` it answers the question their
sign-in screen would otherwise leave open: which address gets me in? A text
label rather than a brand glyph, since the app draws one icon set (lucide) and a
generic mail icon here would say nothing. */}
{me.google_email && (
<div
className="text-2xs text-muted truncate mt-0.5"
title={t("header.account.googleAddress", { email: me.google_email })}
>
<span className="uppercase tracking-wide">Google</span> · {me.google_email}
</div>
)}
</div>
</div>
{me.role === "admin" && (
@@ -243,6 +243,7 @@ function NotificationRow({
const isMaintenance = n.type === "maintenance" && typeof n.data?.count === "number";
const isScheduler = n.type === "scheduler" && typeof n.data?.job_id === "string";
const isDownloadDone = n.type === "download_done";
const isEmailUndelivered = n.type === "email_undelivered" && typeof n.data?.reason === "string";
let title = n.title;
let body = n.body;
if (isMaintenance) {
@@ -258,6 +259,17 @@ function NotificationRow({
job,
});
body = d.summary || n.body;
} else if (isEmailUndelivered) {
// Email is fail-soft: a drop leaves no error anywhere else, so this notice IS the trace. Name
// the message that was lost, since that is what tells the operator what their users didn't get.
const d = n.data as { reason: string; subject?: string };
title = t("inbox.emailUndelivered.title");
body = t(
d.reason === "not_configured"
? "inbox.emailUndelivered.notConfigured"
: "inbox.emailUndelivered.sendFailed",
{ subject: d.subject || n.body || "" },
);
} else if (isDownloadDone) {
// "Download ready" / "Edit ready" per the producer's kind, with the video's title as the body
// (shipped in data.title). Same notification type; the copy differs so an edit isn't mislabelled.
+174 -150
View File
@@ -39,6 +39,7 @@ import { notifyYouTubeActionError } from "../lib/youtubeErrors";
import { playlistName } from "../lib/playlistName";
import { useUndoable } from "../lib/useUndoable";
import { cycleSort, type SortState } from "../lib/columnSort";
import { formatRuntime } from "../lib/format";
import { applyView, sameOrder } from "../lib/playlistView";
const PlayerModal = lazy(() => import("./PlayerModal"));
import UndoToolbar from "./UndoToolbar";
@@ -259,6 +260,13 @@ export default function Playlists() {
return (v: Video) => m.get(v.id) ?? 0;
}, [items]);
// Total playtime of the whole playlist (sums every item, matching the itemCount beside it — not
// the filtered `shown` set). Items with an unknown duration count as 0; hidden when nothing sums.
const totalSeconds = useMemo(
() => items.reduce((acc, v) => acc + (v.duration_seconds ?? 0), 0),
[items],
);
const columns = useMemo(
() => playlistColumns({ t, indexOf: storedIndex, onPlay, onRemove, canEdit }),
[t, storedIndex, onPlay, onRemove, canEdit],
@@ -591,176 +599,192 @@ export default function Playlists() {
}
return (
<div className="px-4 pb-4 pt-3">
// No top padding: the sticky column header must sit flush at the scroller top so it doesn't
// shift up when `position: sticky` engages on the first scroll (the fixed toolbar band above
// gives the breathing room). Same reason as the channel table — see Channels.tsx.
<div className="px-4 pb-4">
{/* Header (title, actions, view controls) is fixed chrome — it portals into the shell's
fixed band, so only the video list scrolls. */}
<PageToolbar>
<div className="px-4 pt-2 pb-2 max-w-5xl mx-auto">
<div className="flex gap-3 items-start mb-4">
<div className="w-24 h-13.5 rounded-lg bg-card overflow-hidden shrink-0">
{/* Two tight rows beside a small cover: title + actions on the first, the meta line and the
view controls sharing the second. This band is FIXED chrome (it never scrolls), so every
row it sheds is list height won back — the old layout stacked three rows next to the
cover plus a fourth block under it, for roughly twice this height. */}
<div className="px-4 py-2 max-w-5xl mx-auto">
<div className="flex gap-3 items-center">
<div className="w-20 h-11.25 rounded-lg bg-card overflow-hidden shrink-0">
{items[0]?.thumbnail_url && (
<img src={items[0].thumbnail_url} alt="" className="w-full h-full object-cover" />
)}
</div>
<div className="min-w-0 flex-1">
{renaming ? (
<input
autoFocus
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onBlur={saveRename}
onKeyDown={(e) => {
if (e.key === "Enter") saveRename();
if (e.key === "Escape") setRenaming(false);
}}
aria-label={t("playlists.rename")}
className="text-lg font-semibold bg-surface border border-border rounded-md px-2 py-0.5 focus:outline-hidden focus:ring-2 focus:ring-accent/40"
/>
) : (
<div className="flex items-center gap-2 min-w-0">
<h2 className="text-lg font-semibold truncate">{plName(detail)}</h2>
{editable && (
<button
onClick={() => {
setRenameValue(detail.name);
setRenaming(true);
}}
title={t("playlists.rename")}
aria-label={t("playlists.rename")}
className="shrink-0 text-muted hover:text-fg"
<div className="flex items-center gap-2 min-w-0">
{renaming ? (
<input
autoFocus
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onBlur={saveRename}
onKeyDown={(e) => {
if (e.key === "Enter") saveRename();
if (e.key === "Escape") setRenaming(false);
}}
aria-label={t("playlists.rename")}
className="min-w-0 flex-1 text-lg font-semibold bg-surface border border-border rounded-md px-2 py-0.5 focus:outline-hidden focus:ring-2 focus:ring-accent/40"
/>
) : (
<>
<h2 className="text-lg font-semibold truncate">{plName(detail)}</h2>
{editable && (
<button
onClick={() => {
setRenameValue(detail.name);
setRenaming(true);
}}
title={t("playlists.rename")}
aria-label={t("playlists.rename")}
className="shrink-0 text-muted hover:text-fg"
>
<Pencil className="w-3.5 h-3.5" />
</button>
)}
{detail.yt_playlist_id && (
<a
href={`https://www.youtube.com/playlist?list=${detail.yt_playlist_id}`}
target="_blank"
rel="noreferrer noopener"
title={t("playlists.openOnYoutube")}
aria-label={t("playlists.openOnYoutube")}
className="shrink-0 text-muted hover:text-accent"
>
<ExternalLink className="w-3.5 h-3.5" />
</a>
)}
</>
)}
<div className="ml-auto flex gap-1.5 items-center shrink-0">
{linked && detail.dirty && (
<span
title={t("playlists.unsyncedHint")}
className="inline-flex items-center gap-1.5 text-2xs text-accent mr-1"
>
<Pencil className="w-3.5 h-3.5" />
<span className="w-1.5 h-1.5 rounded-full bg-accent" />
{t("playlists.unsynced")}
</span>
)}
<button
onClick={() => shown.length && setPlayingId(shown[0]!.id)}
disabled={!shown.length}
className="inline-flex items-center gap-1.5 text-xs font-semibold px-3 py-1.5 rounded-lg btn-primary disabled:opacity-40 transition"
>
<Play className="w-3.5 h-3.5 fill-current" /> {t("playlists.playAll")}
</button>
{editable && canWrite && (
<button
onClick={pushToYoutube}
disabled={pushing}
title={linked ? t("playlists.pushToYoutube") : t("playlists.exportToYoutube")}
aria-label={
linked ? t("playlists.pushToYoutube") : t("playlists.exportToYoutube")
}
className={`inline-flex items-center justify-center p-2 rounded-lg border transition disabled:opacity-40 ${
detail.dirty
? "border-accent text-accent hover:bg-accent/10"
: "border-border text-muted hover:text-fg hover:border-accent"
}`}
>
{pushing ? (
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
) : linked ? (
<Youtube className="w-3.5 h-3.5" />
) : (
<Upload className="w-3.5 h-3.5" />
)}
</button>
)}
{detail.yt_playlist_id && (
<a
href={`https://www.youtube.com/playlist?list=${detail.yt_playlist_id}`}
target="_blank"
rel="noreferrer noopener"
title={t("playlists.openOnYoutube")}
aria-label={t("playlists.openOnYoutube")}
className="shrink-0 text-muted hover:text-accent"
{linked && detail.dirty && (
<button
onClick={revertToYoutube}
disabled={reverting}
title={t("playlists.revert")}
aria-label={t("playlists.revert")}
className="inline-flex items-center justify-center p-2 rounded-lg border border-border text-muted hover:text-fg hover:border-accent transition disabled:opacity-40"
>
<ExternalLink className="w-3.5 h-3.5" />
</a>
<RotateCcw className={`w-3.5 h-3.5 ${reverting ? "animate-spin" : ""}`} />
</button>
)}
{editable && (
<button
onClick={deletePlaylist}
title={t("playlists.delete")}
aria-label={t("playlists.delete")}
className="inline-flex items-center justify-center p-2 rounded-lg border border-border text-muted hover:text-danger hover:border-danger/50 transition"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</div>
)}
<div className="text-xs text-muted mt-0.5">
{t("playlists.itemCount", { count: items.length })}
{filterActive && ` · ${t("playlists.filteredCount", { count: shown.length })}`}
</div>
<div className="flex gap-1.5 mt-3 flex-wrap items-center">
<button
onClick={() => shown.length && setPlayingId(shown[0]!.id)}
disabled={!shown.length}
className="inline-flex items-center gap-1.5 text-xs font-semibold px-3 py-1.5 rounded-lg btn-primary disabled:opacity-40 transition"
>
<Play className="w-3.5 h-3.5 fill-current" /> {t("playlists.playAll")}
</button>
{editable && canWrite && (
<button
onClick={pushToYoutube}
disabled={pushing}
title={linked ? t("playlists.pushToYoutube") : t("playlists.exportToYoutube")}
aria-label={
linked ? t("playlists.pushToYoutube") : t("playlists.exportToYoutube")
}
className={`inline-flex items-center justify-center p-2 rounded-lg border transition disabled:opacity-40 ${
detail.dirty
? "border-accent text-accent hover:bg-accent/10"
: "border-border text-muted hover:text-fg hover:border-accent"
}`}
>
{pushing ? (
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
) : linked ? (
<Youtube className="w-3.5 h-3.5" />
) : (
<Upload className="w-3.5 h-3.5" />
<div className="flex items-center gap-2 mt-1 flex-wrap">
<span className="text-xs text-muted">
{t("playlists.itemCount", { count: items.length })}
{totalSeconds > 0 && ` · ${formatRuntime(totalSeconds)}`}
{filterActive && ` · ${t("playlists.filteredCount", { count: shown.length })}`}
</span>
{(items.length > 1 || viewActive) && (
<>
<span aria-hidden="true" className="text-xs text-muted/40">
·
</span>
<label className="inline-flex items-center gap-1.5 text-xs text-muted cursor-pointer select-none">
<input
type="checkbox"
checked={groupBy}
onChange={(e) => setGroupBy(e.target.checked)}
className="accent-accent"
/>
{t("playlists.groupByChannel")}
</label>
{viewActive && (
<button
onClick={clearView}
className="text-xs text-muted hover:text-accent underline underline-offset-2 transition"
>
{t("playlists.clearView")}
</button>
)}
</button>
)}
{linked && detail.dirty && (
<button
onClick={revertToYoutube}
disabled={reverting}
title={t("playlists.revert")}
aria-label={t("playlists.revert")}
className="inline-flex items-center justify-center p-2 rounded-lg border border-border text-muted hover:text-fg hover:border-accent transition disabled:opacity-40"
>
<RotateCcw className={`w-3.5 h-3.5 ${reverting ? "animate-spin" : ""}`} />
</button>
)}
{editable && (
<button
onClick={deletePlaylist}
title={t("playlists.delete")}
aria-label={t("playlists.delete")}
className="inline-flex items-center justify-center p-2 rounded-lg border border-border text-muted hover:text-danger hover:border-danger/50 transition"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
{linked && detail.dirty && (
<span
title={t("playlists.unsyncedHint")}
className="inline-flex items-center gap-1.5 text-2xs text-accent ml-1"
>
<span className="w-1.5 h-1.5 rounded-full bg-accent" />
{t("playlists.unsynced")}
</span>
{canSaveOrder && (
<button
onClick={saveOrder}
className="inline-flex items-center gap-1.5 text-xs font-semibold px-2.5 py-1 rounded-lg border border-accent text-accent hover:bg-accent/10 transition"
>
<Check className="w-3.5 h-3.5" /> {t("playlists.saveOrder")}
</button>
)}
{/* ONE sentence that names the state. Three loose texts used to pile up here
(reset link + save hint + "reordering is off"), and none of them answered the
question a user actually has — "did I just change my playlist?". Worse,
sorting a list whose stored order ALREADY matches the sort produced a
warning, no Save button and no visible change, which reads as a malfunction.
Now the line says which of the three situations you are in; the disabled grip
still explains itself on hover/focus. */}
{viewActive && <span className="text-2xs text-muted/80">{viewStatus}</span>}
{canEdit && (
<div className="ml-auto">
<UndoToolbar
canUndo={order.canUndo}
canRedo={order.canRedo}
onUndo={order.undo}
onRedo={order.redo}
/>
</div>
)}
</>
)}
</div>
</div>
</div>
{(items.length > 1 || viewActive) && (
<div className="flex items-center gap-2 mb-3 flex-wrap max-w-5xl">
<label className="inline-flex items-center gap-1.5 text-xs text-muted cursor-pointer select-none">
<input
type="checkbox"
checked={groupBy}
onChange={(e) => setGroupBy(e.target.checked)}
className="accent-accent"
/>
{t("playlists.groupByChannel")}
</label>
{viewActive && (
<button
onClick={clearView}
className="text-xs text-muted hover:text-accent underline underline-offset-2 transition"
>
{t("playlists.clearView")}
</button>
)}
{canSaveOrder && (
<button
onClick={saveOrder}
className="inline-flex items-center gap-1.5 text-xs font-semibold px-2.5 py-1 rounded-lg border border-accent text-accent hover:bg-accent/10 transition"
>
<Check className="w-3.5 h-3.5" /> {t("playlists.saveOrder")}
</button>
)}
{/* ONE sentence that names the state. Three loose texts used to pile up here (reset
link + save hint + "reordering is off"), and none of them answered the question a
user actually has — "did I just change my playlist?". Worse, sorting a list whose
stored order ALREADY matches the sort produced a warning, no Save button and no
visible change, which reads as a malfunction. Now the line says which of the three
situations you are in; the disabled grip still explains itself on hover/focus. */}
{viewActive && <span className="text-2xs text-muted/80">{viewStatus}</span>}
{canEdit && (
<div className="ml-auto">
<UndoToolbar
canUndo={order.canUndo}
canRedo={order.canRedo}
onUndo={order.undo}
onRedo={order.redo}
/>
</div>
)}
</div>
)}
</div>
</PageToolbar>
+2 -2
View File
@@ -4,7 +4,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import { ListVideo, Pin, Plus, RefreshCw, Youtube } from "lucide-react";
import { api, type Playlist } from "../lib/api";
import { formatDuration } from "../lib/format";
import { formatRuntime } from "../lib/format";
import { notify } from "../lib/notifications";
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
import { LS, readAccountMerged, writeAccount } from "../lib/storage";
@@ -247,7 +247,7 @@ export default function PlaylistsRail({
<div className="text-2xs text-muted">
{t("playlists.itemCount", { count: pl.item_count })}
{pl.total_duration_seconds > 0 &&
` · ${formatDuration(pl.total_duration_seconds)}`}
` · ${formatRuntime(pl.total_duration_seconds)}`}
</div>
</div>
</button>
+30 -1
View File
@@ -19,6 +19,7 @@ import i18n from "../i18n";
import { Carousel } from "./Carousel";
import PlexCollectionEditor from "./PlexCollectionEditor";
import PlexPlaylistAdd from "./PlexPlaylistAdd";
import { PLAYABLE_TINT, playableClass } from "./plexCards";
// The rich "media info" surface for a Plex item — poster/art, IMDb score + link, content rating,
// genres, director, cast (with photos), summary. Reused in TWO places: a full-page view opened from
@@ -253,6 +254,33 @@ function CrewPanel({
);
}
// The playback-class chip (dot + Plex-native label), with a muted codec · bit-depth subtitle when
// known. Class + colour are derived through the SAME shared helper the browse-card corner dot uses,
// so the two surfaces can't drift. `overlay` tints the label for the dark player background.
function PlaybackChip({ detail, overlay }: { detail: PlexItemDetail; overlay: boolean }) {
const { t } = useTranslation();
if (!detail.playable) return null;
const cls = playableClass(detail.playable, detail.transcode_hw);
const sub = [
detail.codec_video ? detail.codec_video.toUpperCase() : null,
detail.video_bit_depth != null ? `${detail.video_bit_depth}-bit` : null,
]
.filter(Boolean)
.join(" · ");
return (
<span
className={`inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-xs ${
overlay ? "bg-white/10 text-white/90" : "bg-surface text-muted"
}`}
title={t(`plex.playable.${cls}`)}
>
<span className={`h-2 w-2 shrink-0 rounded-full ${PLAYABLE_TINT[cls] ?? "bg-gray-400"}`} />
<span className="font-medium">{t(`plex.filter.playableOpt.${cls}`)}</span>
{sub && <span className="opacity-70">{sub}</span>}
</span>
);
}
type Props = {
detail: PlexItemDetail;
variant: "page" | "overlay";
@@ -425,7 +453,7 @@ export default function PlexInfo({
<button
onClick={onClose}
className="shrink-0 p-1.5 rounded-lg text-white/80 hover:bg-white/15"
aria-label={t("plex.player.back")}
aria-label={t("plex.player.close")}
>
<X className="w-5 h-5" />
</button>
@@ -479,6 +507,7 @@ export default function PlexInfo({
{detail.studio}
</Filterable>
)}
<PlaybackChip detail={detail} overlay={overlay} />
</div>
{/* Multi-source ratings row (E5 S5): IMDb / TMDb / audience, whichever are present. */}
+116 -9
View File
@@ -9,6 +9,7 @@ import {
ArrowLeft,
Clock,
Download,
Gauge,
Info,
Keyboard,
Languages,
@@ -38,8 +39,10 @@ import {
subShadowCss,
type PlexPlayerPrefs,
} from "../lib/plexPlayerPrefs";
import { Ctrl, PanelHead } from "./plexPlayerControls";
import { Ctrl, PanelHead, Skip10 } from "./plexPlayerControls";
import { PlexPlayerSettings } from "./PlexPlayerSettings";
import { PlexStatsOverlay } from "./PlexStatsOverlay";
import { UpNextCard } from "./UpNextCard";
import {
useAutoSkip,
useControlsVisibility,
@@ -95,6 +98,12 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
const aliveRef = useRef(true);
const wrapRef = useRef<HTMLDivElement>(null);
const sessionStartRef = useRef(0); // absolute offset the current HLS/direct session begins at
// Which encoder the current transcode session ran: true=VAAPI hardware, false=libx264 software,
// null=not a transcode. Feeds the stats overlay's mode row ("transcode · GPU" / "· CPU").
const hwRef = useRef<boolean | null>(null);
// Whether the current HW transcode tone-mapped a 10-bit/HDR source on the GPU (OpenCL). Feeds the
// stats mode row's "· HDR→SDR" tag; null/false for an 8-bit HW, software, or non-transcode session.
const tonemapRef = useRef<boolean | null>(null);
const durationRef = useRef(0);
// ≥2 audio tracks → multi-rendition HLS: all tracks ship in one session and hls.js switches audio
// client-side (no restart). Ref so the stable loadSession/changeAudio callbacks read it fresh.
@@ -114,6 +123,10 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
// which only covers the INITIAL session load; this covers stalls AFTER playback has started (e.g. the
// brief cold-ffmpeg rebuffer on auto-advance, now largely prevented by the server-side segment warm-up).
const [buffering, setBuffering] = useState(false);
// The current session re-encodes the video (playable="transcode"). Its cold-start (the first transcode
// segment) is genuinely slower than a remux stall, so the initial-load overlay says "Preparing stream…"
// instead of the generic "Loading…" — honest, and it stops a warm-up reading as a hang.
const [sessionIsTranscode, setSessionIsTranscode] = useState(false);
const [loadError, setLoadError] = useState<string | null>(null);
// The current session's real media-start K (reactive mirror of sessionStartRef). Subtitles are
// absolute-time VTT shifted by K to the session's zero-based clock, so the <track> src/key keys off
@@ -171,6 +184,16 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
const [infoOpen, setInfoOpen] = useState(false);
const infoOpenRef = useRef(false);
infoOpenRef.current = infoOpen;
// "D" toggles the stats-for-nerds debug overlay (active mode, codec, dropped frames, buffer…).
const [statsOpen, setStatsOpen] = useState(false);
const statsOpenRef = useRef(false);
statsOpenRef.current = statsOpen;
// Up-next end-card: shown at episode end (with a next item) instead of an instant jump (S4).
const [upNextOpen, setUpNextOpen] = useState(false);
const upNextOpenRef = useRef(false);
upNextOpenRef.current = upNextOpen;
const upNextPrefRef = useRef(prefs.upNext);
upNextPrefRef.current = prefs.upNext;
const now = useWallClock();
durationRef.current = duration;
@@ -186,6 +209,10 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
setReady(false);
setBuffering(false);
setLoadError(null);
// Clear the prior item's transcode flag up front so the cold-start overlay can't show a stale
// "Preparing stream…" for a not-yet-known session while the plexSession request is in flight —
// it's re-set honestly from the response below.
setSessionIsTranscode(false);
let sess;
try {
// Multi-audio → request the multi-rendition session (no single `audio` ordinal); the track is
@@ -206,6 +233,12 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
);
return;
}
// Surface the active mode for the stats overlay + the honest cold-start label: a transcode session
// re-encodes the video (hw = which encoder), and its first segment is genuinely slower than remux.
const isTranscode = sess.mode === "hls" && !!sess.transcode;
hwRef.current = isTranscode ? (sess.hw ?? false) : null;
tonemapRef.current = isTranscode ? (sess.tonemap ?? false) : null;
setSessionIsTranscode(isTranscode);
// hls.js plays our non-zero-start copyts stream ~1s BEHIND the media clock (see HLS_SUB_LAG);
// fold that into the session start so the seekbar clock, seeking AND the subtitle shift are all
// content-accurate. Direct-play (start_s 0) uses the file's real clock — no compensation.
@@ -228,7 +261,15 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
hlsRef.current = null;
}
if (sess.mode === "hls" && Hls.isSupported()) {
const hls = new Hls({ maxBufferLength: 30, enableWorker: true });
// startPosition: 0 — our HLS is a growing EVENT playlist with no ENDLIST (see _HLS_TAIL in
// stream.py), which hls.js classifies as LIVE. Its default (startPosition: -1) then starts
// playback at the live edge and hugs it, so it never builds a forward buffer (rebuffer
// spinners), and on a resume/seek session the segments' absolute PTS (-copyts, offset by the
// server's -ss) don't line up with the live-edge timeline → 0 fragments load → permanent
// stall. But the seek-restart model means fragment 0 IS the requested content position
// (`?t=` positioned the server ffmpeg there), so we always want to start VOD-style at 0 and
// buffer forward up to maxBufferLength. Applies to remux + transcode alike (both use copyts).
const hls = new Hls({ maxBufferLength: 30, enableWorker: true, startPosition: 0 });
hlsRef.current = hls;
hls.loadSource(`${sess.url}?t=${Math.floor(startAt)}`);
hls.attachMedia(video);
@@ -514,7 +555,11 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
// (absRef 0) — its stray 'ended' must not mark anything watched or cascade another advance.
if (absRef.current <= 0) return;
api.plexSetState(id, "watched").catch(() => {});
if (nextId) go(nextId);
// With a next item and the up-next pref on, surface the end-card (poster + countdown + Cancel)
// and let IT advance — binge stays in the viewer's control (S4/D6-A). The card only appears here,
// on play-to-the-end; credits-auto-skip advances earlier and this handler never fires for it.
if (nextId && upNextPrefRef.current) setUpNextOpen(true);
else if (nextId) go(nextId);
// Nothing queued next → dismiss the player back to the previous page (same as Stop/Escape),
// instead of stranding a black, ended <video> on screen.
else onClose();
@@ -523,6 +568,14 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
return () => video.removeEventListener("ended", onEnded);
}, [id, nextId, go, onClose]);
// Up-next card actions: advance = go to the next item (it was already marked watched on 'ended');
// cancel = dismiss and stay on the finished frame. Loading a new item closes any lingering card.
const advanceUpNext = useCallback(() => {
setUpNextOpen(false);
if (nextId) go(nextId);
}, [nextId, go]);
useEffect(() => setUpNextOpen(false), [id]);
// "Back" cascade shared by the on-screen Back arrow AND the keyboard/remote Backspace·Escape: peel
// off whatever is layered on top (a tuning/tracks menu, then the shortcuts sheet, then the info
// overlay) before leaving the player. The arrow used to call onClose() unconditionally, so Back
@@ -533,6 +586,8 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
setTracksOpen(false);
} else if (helpOpenRef.current) setHelpOpen(false);
else if (infoOpenRef.current) setInfoOpen(false);
else if (statsOpenRef.current) setStatsOpen(false);
else if (upNextOpenRef.current) setUpNextOpen(false);
else onClose();
}, [onClose]);
@@ -618,6 +673,10 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
case "I":
setInfoOpen((v) => !v);
break;
case "d":
case "D":
setStatsOpen((v) => !v);
break;
case "Backspace":
// HTPC-style "stop & back to the feed" — mirrors the mouse Back button (shared cascade).
e.preventDefault();
@@ -639,6 +698,8 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
setTracksOpen(false);
} else if (helpOpenRef.current) setHelpOpen(false);
else if (infoOpenRef.current) setInfoOpen(false);
else if (statsOpenRef.current) setStatsOpen(false);
else if (upNextOpenRef.current) setUpNextOpen(false);
else if (!fullscreenElement()) onClose();
break;
}
@@ -774,7 +835,9 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
rebuffer/seek (ready, no error) has no visible label, so give it an sr-only one via the
aria-live region so a screen reader distinguishes "buffering" from a frozen frame. */}
{!ready ? (
<span>{t("plex.player.loading")}</span>
<span>
{t(sessionIsTranscode ? "plex.player.preparing" : "plex.player.loading")}
</span>
) : buffering ? (
<span className="sr-only">{t("plex.player.buffering")}</span>
) : null}
@@ -785,9 +848,13 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
{/* While a panel/menu is open or an auto-skip countdown is running, take over the browser/mouse
Back button (history popstate) so it closes/cancels that instead of dropping to the grid. */}
{(menuOpen || tracksOpen || helpOpen || infoOpen || skipProgress != null) && (
<BackClose onBack={backIntent} />
)}
{(menuOpen ||
tracksOpen ||
helpOpen ||
infoOpen ||
statsOpen ||
upNextOpen ||
skipProgress != null) && <BackClose onBack={backIntent} />}
{/* Top bar */}
<div
@@ -936,9 +1003,19 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
</div>
<div className="flex items-center gap-3 text-white">
<Ctrl label={t("plex.player.playPause")} onClick={togglePlay} align="start">
<Ctrl
label={t("plex.player.rewind10")}
onClick={() => seekTo(absRef.current - 10)}
align="start"
>
<Skip10 dir="back" />
</Ctrl>
<Ctrl label={t("plex.player.playPause")} onClick={togglePlay}>
{playing ? <Pause className="w-6 h-6" /> : <Play className="w-6 h-6" />}
</Ctrl>
<Ctrl label={t("plex.player.forward10")} onClick={() => seekTo(absRef.current + 10)}>
<Skip10 dir="forward" />
</Ctrl>
<Ctrl label={t("plex.player.restart")} onClick={() => seekTo(0)}>
<RotateCcw className="w-5 h-5" />
</Ctrl>
@@ -1114,6 +1191,9 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
)}
</div>
)}
<Ctrl label={t("plex.player.stats.title")} onClick={() => setStatsOpen((v) => !v)}>
<Gauge className="w-5 h-5" />
</Ctrl>
<Ctrl label={t("plex.player.infoBtn")} onClick={() => setInfoOpen((v) => !v)}>
<Info className="w-5 h-5" />
</Ctrl>
@@ -1130,6 +1210,32 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
</div>
</div>
{/* Stats-for-nerds debug overlay (toggle with "d" or the gauge button). Non-modal corner panel —
the video keeps playing and stays interactive behind it. */}
{statsOpen && (
<PlexStatsOverlay
videoRef={videoRef}
hlsRef={hlsRef}
playable={detail?.playable ?? ""}
hwRef={hwRef}
tonemapRef={tonemapRef}
sessionStartRef={sessionStartRef}
absRef={absRef}
durationRef={durationRef}
onClose={() => setStatsOpen(false)}
/>
)}
{/* Up-next end-card (episode end, next item queued, pref on). Non-modal — the finished frame
stays behind it; the card's own countdown advances unless the viewer cancels. */}
{upNextOpen && nextId && (
<UpNextCard
nextId={nextId}
onAdvance={advanceUpNext}
onCancel={() => setUpNextOpen(false)}
/>
)}
{/* Keyboard-shortcut cheat sheet (toggle with "h" or the keyboard button). */}
{helpOpen && (
<div
@@ -1150,7 +1256,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
<button
onClick={() => setHelpOpen(false)}
className="p-1 rounded-lg hover:bg-white/10"
aria-label={t("plex.player.back")}
aria-label={t("plex.player.close")}
>
<X className="w-5 h-5" />
</button>
@@ -1170,6 +1276,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
[["S"], "plex.player.keys.subtitle"],
[["F"], "plex.player.keys.fullscreen"],
[["I"], "plex.player.keys.info"],
[["D"], "plex.player.keys.stats"],
[["⌫", "Esc"], "plex.player.keys.back"],
[["H"], "plex.player.keys.help"],
] as [string[], string][]
@@ -113,6 +113,11 @@ export function PlexPlayerSettings(props: {
checked={prefs.autoSkipCredits}
onChange={(v) => patchPrefs({ autoSkipCredits: v })}
/>
<Toggle
label={t("plex.player.upNext.pref")}
checked={prefs.upNext}
onChange={(v) => patchPrefs({ upNext: v })}
/>
{(prefs.autoSkipIntro || prefs.autoSkipCredits) && (
<Slider
label={t("plex.player.autoSkipDelay")}
+27 -12
View File
@@ -32,6 +32,7 @@ import {
X,
} from "lucide-react";
import { api, type PlexCard } from "../lib/api";
import { formatRuntime } from "../lib/format";
import { getAccountRaw, LS, setAccountRaw } from "../lib/storage";
import { useConfirm } from "./ConfirmProvider";
import { LoadingState, StateMessage } from "./QueryState";
@@ -387,6 +388,12 @@ export default function PlexPlaylistView({
}, [q.data]);
const blocks = useMemo(() => buildBlocks(items), [items]);
const order = useMemo(() => items.map((i) => i.id), [items]);
// Total playtime of the whole playlist, mirroring the YouTube playlist header (E9 12D): items with
// an unknown duration count as 0, and the runtime is dropped from the meta line when nothing sums.
const totalSeconds = useMemo(
() => items.reduce((acc, it) => acc + (it.duration_seconds ?? 0), 0),
[items],
);
const invalidate = () => {
qc.invalidateQueries({ queryKey: qk.plexPlaylist(playlistId) });
@@ -517,18 +524,26 @@ export default function PlexPlaylistView({
<div className="glass rounded-2xl p-4 sm:p-6">
<div className="mb-4 flex flex-wrap items-center gap-3">
{renaming ? (
<input
autoFocus
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && rename()}
onBlur={rename}
className="glass-card min-w-0 flex-1 rounded-lg px-3 py-1.5 text-lg font-bold focus:outline-hidden focus:ring-2 focus:ring-accent/40"
/>
) : (
<h1 className="min-w-0 flex-1 truncate text-2xl font-bold">{q.data?.title ?? " "}</h1>
)}
<div className="min-w-0 flex-1">
{renaming ? (
<input
autoFocus
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && rename()}
onBlur={rename}
className="glass-card w-full rounded-lg px-3 py-1.5 text-lg font-bold focus:outline-hidden focus:ring-2 focus:ring-accent/40"
/>
) : (
<h1 className="truncate text-2xl font-bold">{q.data?.title ?? " "}</h1>
)}
{items.length > 0 && (
<div className="mt-0.5 text-sm text-muted">
{t("plex.browse.items", { count: items.length })}
{totalSeconds > 0 && ` · ${formatRuntime(totalSeconds)}`}
</div>
)}
</div>
{/* Layout preference: Accordion or Tree — the shared list-view switcher. */}
<ViewSwitcher
value={layout}
+22
View File
@@ -296,6 +296,28 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
},
]
: []),
// Playback class (Direct Play / Direct Stream / Transcode HW/SW) — movie-only (shows have no
// class), and dropped when the facet is empty. Labels are localized (the facet ships class keys).
...(scope === "movie" && facets && facets.playable.length > 0
? [
{
id: "playable",
title: t("plex.filter.playable"),
body: (
<Combobox
label={t("plex.filter.playable")}
options={facets.playable.map((p) => ({
value: p.value,
label: t(`plex.filter.playableOpt.${p.value}`),
count: p.count,
}))}
selected={filters.playable}
onChange={(next) => patch({ playable: next })}
/>
),
},
]
: []),
];
return (
@@ -0,0 +1,134 @@
import { type RefObject, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import type Hls from "hls.js";
import { X } from "lucide-react";
import { buildStatRows, type HlsLevelInfo, type StatRow } from "../lib/plexStats";
// "Stats for nerds" debug overlay (E6 S2). Samples the live HTML5 <video> + hls.js state once a second
// and renders the active mode, resolution, codec/bitrate, dropped frames, buffer, hls level and session
// offset. No data layer — the refs are already in hand. Doubles as the S1 verification surface (it shows
// the transcode mode + that the re-encode is engaged). Toggled with "d" / the gauge button / its X.
export function PlexStatsOverlay({
videoRef,
hlsRef,
playable,
hwRef,
tonemapRef,
sessionStartRef,
absRef,
durationRef,
onClose,
}: {
videoRef: RefObject<HTMLVideoElement | null>;
hlsRef: RefObject<Hls | null>;
playable: string;
hwRef: RefObject<boolean | null>; // active transcode encoder: true=GPU, false=CPU, null=not transcode
tonemapRef: RefObject<boolean | null>; // HW HDR→SDR tonemap engaged (adds a "· HDR→SDR" mode tag)
sessionStartRef: RefObject<number>;
absRef: RefObject<number>;
durationRef: RefObject<number>;
onClose: () => void;
}) {
const { t } = useTranslation();
const [rows, setRows] = useState<StatRow[]>([]);
useEffect(() => {
const sample = () => {
const v = videoRef.current;
const hls = hlsRef.current;
let dropped = 0;
let total = 0;
try {
const q = v?.getVideoPlaybackQuality?.();
if (q) {
dropped = q.droppedVideoFrames;
total = q.totalVideoFrames;
}
} catch {
/* getVideoPlaybackQuality unsupported — leave 0/0 (renders as a dash) */
}
let bufferAhead = 0;
if (v) {
const now = v.currentTime;
for (let i = 0; i < v.buffered.length; i++) {
if (now >= v.buffered.start(i) && now <= v.buffered.end(i)) {
bufferAhead = v.buffered.end(i) - now;
break;
}
}
}
let level: HlsLevelInfo | null = null;
if (hls) {
// currentLevel is -1 while auto-ABR hasn't settled; fall back to loadLevel for the res/bitrate.
const idx = hls.currentLevel >= 0 ? hls.currentLevel : hls.loadLevel;
const lv = idx >= 0 ? hls.levels?.[idx] : undefined;
level = {
index: hls.currentLevel,
total: hls.levels?.length ?? 0,
width: lv?.width,
height: lv?.height,
bitrate: lv?.bitrate,
videoCodec: lv?.videoCodec,
audioCodec: lv?.audioCodec,
};
}
setRows(
buildStatRows({
playable,
hwEncoder: hwRef.current,
tonemap: tonemapRef.current,
transport: hls ? "hls" : "direct",
videoWidth: v?.videoWidth ?? 0,
videoHeight: v?.videoHeight ?? 0,
droppedFrames: dropped,
totalFrames: total,
bufferAheadS: bufferAhead,
hlsLevel: level,
sessionStartS: sessionStartRef.current ?? 0,
abs: absRef.current ?? 0,
duration: durationRef.current ?? 0,
}),
);
};
sample();
const iv = setInterval(sample, 1000);
return () => clearInterval(iv);
}, [videoRef, hlsRef, playable, hwRef, tonemapRef, sessionStartRef, absRef, durationRef]);
return (
<div
role="region"
aria-label={t("plex.player.stats.title")}
onClick={(e) => e.stopPropagation()}
className="absolute left-4 top-16 z-menu w-64 rounded-xl border border-white/15 bg-black/80 p-3 text-white shadow-2xl backdrop-blur-sm"
>
<div className="mb-2 flex items-center justify-between">
<span className="text-xs font-semibold uppercase tracking-wide text-white/70">
{t("plex.player.stats.title")}
</span>
<button
onClick={onClose}
aria-label={t("plex.player.close")}
className="rounded p-0.5 text-white/60 hover:bg-white/10 hover:text-white"
>
<X className="h-4 w-4" />
</button>
</div>
{/* No aria-live here: the rows re-sample every second, so a live region would make a screen
reader announce the changing values continuously and drown out real player announcements.
The overlay is a role="region" (labelled above) a user reads on demand, not a status feed. */}
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-2xs">
{rows.map((r) => (
<div key={r.key} className="contents">
<dt className="text-white/55">{t(r.labelKey)}</dt>
<dd className="text-right font-mono tabular-nums text-white/90">{r.value}</dd>
</div>
))}
</dl>
</div>
);
}
+5 -30
View File
@@ -195,6 +195,8 @@ export default function ProfileEditor({ onClose }: { onClose: () => void }) {
<label className="block text-sm font-medium mb-1">
{t("downloads.profiles.audioFormat")}
</label>
{/* Only browser-playable audio: AAC (m4a) + MP3. Opus is dropped — Safari plays it
unreliably as a bare file, and the module's purpose is in-browser playback. */}
<select
value={spec.audio_format ?? "m4a"}
onChange={set("audio_format")}
@@ -202,39 +204,12 @@ export default function ProfileEditor({ onClose }: { onClose: () => void }) {
>
<option value="m4a">M4A</option>
<option value="mp3">MP3</option>
<option value="opus">Opus</option>
</select>
</div>
)}
{spec.mode !== "a" && (
<>
<div>
<label className="block text-sm font-medium mb-1">
{t("downloads.profiles.container")}
</label>
<select
value={spec.container ?? "mp4"}
onChange={set("container")}
className={inputCls}
>
<option value="mp4">MP4</option>
<option value="mkv">MKV</option>
<option value="webm">WebM</option>
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1">
{t("downloads.profiles.vcodec")}
</label>
<select value={spec.vcodec ?? ""} onChange={set("vcodec")} className={inputCls}>
<option value="">{t("downloads.profiles.any")}</option>
<option value="h264">H.264</option>
<option value="vp9">VP9</option>
<option value="av1">AV1</option>
</select>
</div>
</>
)}
{/* Container/codec are no longer user-selectable: every download is clamped to the
mp4/H.264/AAC compat profile (the only universally browser-playable combo) so a
shared link plays everywhere, iOS/Safari included. Enforced in backend `normalize`. */}
</div>
<div className="space-y-1.5 pt-1">
{spec.mode !== "a" && (
@@ -413,6 +413,15 @@ function SignInMethods({ me }: { me: Me }) {
? t("settings.account.googleLink.connectedHint")
: t("settings.account.googleLink.connectHint")}
</p>
{/* Which address Google actually answers with — sent only when it differs from the
account's own (see /api/me). "Connected" alone left that unanswerable, and it is
exactly what someone who registered with a `+tag` needs to know. */}
{me.google_email && (
<p className="text-xs text-fg/80 mt-1.5">
<span className="text-muted">{t("settings.account.googleLink.signedInAs")}</span>{" "}
{me.google_email}
</p>
)}
</div>
{me.has_google ? (
<span className="shrink-0 text-2xs px-2 py-1 rounded-full border border-accent/40 text-accent">
+5 -3
View File
@@ -254,7 +254,9 @@ function LinkShare({ job }: { job: DownloadJob }) {
queryFn: () => api.downloadLinks(job.id),
});
const [creating, setCreating] = useState(false);
const [allowDownload, setAllowDownload] = useState(false);
// D1 (E7 S3): downloadable by default — matches the one-click Share (which relies on the backend
// default) and the common "let them have the file" intent. The owner can uncheck it here.
const [allowDownload, setAllowDownload] = useState(true);
const [expiryDays, setExpiryDays] = useState<number | null>(null);
const [password, setPassword] = useState("");
const invalidate = () => qc.invalidateQueries({ queryKey: qk.downloadLinks(job.id) });
@@ -276,8 +278,8 @@ function LinkShare({ job }: { job: DownloadJob }) {
});
// Open with a link already generated: sharing is almost always the goal, so mint a default
// (no password, no expiry, view-only) link the moment the dialog finds none — as if the user
// had pressed "New link" → "Create". All further controls (password, download toggle, expiry,
// (no password, no expiry, downloadable per D1) link the moment the dialog finds none — as if the
// user had pressed "New link" → "Create". All further controls (password, download toggle, expiry,
// extra links, revoke) stay available on the created link and via the "New link" button.
const autoTried = useRef(false);
useEffect(() => {
@@ -0,0 +1,93 @@
// @vitest-environment jsdom
//
// The up-next end-card's core behaviour (E6 S4): the countdown that auto-advances at ~10s (the whole
// point — it REPLACED the old instant jump, so a regression that fires early or never is exactly the
// bug to guard), the "Play now"/Cancel controls, and the episode heading's null season/episode
// fallback. i18n is key-passthrough; `../lib/api` is mocked so no network runs; the countdown uses
// performance.now, which vitest's fake timers advance alongside setInterval.
import { describe, it, expect, afterEach, vi } from "vitest";
import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
});
vi.mock("../lib/api", () => ({
api: { plexItem: vi.fn(), plexImageUrl: () => "/img" },
}));
import { UpNextCard } from "./UpNextCard";
import { api } from "../lib/api";
afterEach(() => {
cleanup();
vi.useRealTimers();
vi.clearAllMocks();
});
function renderCard(opts: { onAdvance?: () => void; onCancel?: () => void } = {}) {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={qc}>
<UpNextCard
nextId="e7"
onAdvance={opts.onAdvance ?? vi.fn()}
onCancel={opts.onCancel ?? vi.fn()}
/>
</QueryClientProvider>,
);
}
describe("UpNextCard", () => {
it("auto-advances after the ~10s countdown, and NOT before", () => {
vi.useFakeTimers();
// A never-resolving detail query: the heading is irrelevant here and this avoids async state
// updates racing the fake clock.
(api.plexItem as ReturnType<typeof vi.fn>).mockReturnValue(new Promise(() => {}));
const onAdvance = vi.fn();
renderCard({ onAdvance });
act(() => {
vi.advanceTimersByTime(9000);
});
expect(onAdvance).not.toHaveBeenCalled(); // still counting down
act(() => {
vi.advanceTimersByTime(1200);
});
expect(onAdvance).toHaveBeenCalledTimes(1); // crossed zero → advanced exactly once
});
it("'Play now' advances immediately, without waiting for the countdown", () => {
(api.plexItem as ReturnType<typeof vi.fn>).mockReturnValue(new Promise(() => {}));
const onAdvance = vi.fn();
renderCard({ onAdvance });
fireEvent.click(screen.getByRole("button", { name: /playNow/ }));
expect(onAdvance).toHaveBeenCalledTimes(1);
});
it("Cancel dismisses via onCancel and does not advance", () => {
(api.plexItem as ReturnType<typeof vi.fn>).mockReturnValue(new Promise(() => {}));
const onAdvance = vi.fn();
const onCancel = vi.fn();
renderCard({ onAdvance, onCancel });
fireEvent.click(screen.getByText("plex.player.upNext.cancel"));
expect(onCancel).toHaveBeenCalledTimes(1);
expect(onAdvance).not.toHaveBeenCalled();
});
it("an episode heading falls back to S?·E? when season/episode are null", async () => {
(api.plexItem as ReturnType<typeof vi.fn>).mockResolvedValue({
id: "e7",
kind: "episode",
show_title: "Westworld",
title: "The Adversary",
season_number: null,
episode_number: null,
});
renderCard();
expect(await screen.findByText("Westworld · S?·E?")).toBeTruthy();
});
});
+135
View File
@@ -0,0 +1,135 @@
import { useEffect, useRef, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { Play, X } from "lucide-react";
import { api } from "../lib/api";
import { plexLang } from "../i18n";
import { qk } from "../lib/queryKeys";
// Up-next / end-card (E6 S4). Shown at episode end when a next item exists and the pref is on, INSTEAD
// of the old instant jump — so binge stays under the viewer's control (D6-A). Counts down ~10s then
// advances; "Play now" advances immediately; Cancel/✕ dismisses and stays on the finished frame. It only
// lives on the play-to-the-end path; credits-auto-skip advances earlier and never reaches this card.
const COUNTDOWN_S = 10;
export function UpNextCard({
nextId,
onAdvance,
onCancel,
}: {
nextId: string;
onAdvance: () => void;
onCancel: () => void;
}) {
const { t } = useTranslation();
const lang = plexLang();
const nextQ = useQuery({
queryKey: qk.plexItem(nextId, lang),
queryFn: () => api.plexItem(nextId, lang),
});
const next = nextQ.data;
// Countdown. `onAdvance` is read through a ref so the 100ms interval never restarts on re-render.
const [remaining, setRemaining] = useState(COUNTDOWN_S);
const advanceRef = useRef(onAdvance);
advanceRef.current = onAdvance;
useEffect(() => {
const started = performance.now();
const iv = window.setInterval(() => {
const left = COUNTDOWN_S - (performance.now() - started) / 1000;
if (left <= 0) {
window.clearInterval(iv);
setRemaining(0);
advanceRef.current();
} else {
setRemaining(left);
}
}, 100);
return () => window.clearInterval(iv);
}, []);
const secs = Math.ceil(remaining);
const frac = remaining / COUNTDOWN_S; // 1 → 0 as it counts down
const R = 10;
const C = 2 * Math.PI * R;
// Title: "Show · S2·E5" over the episode title for episodes; the plain title for movies/queue items.
const heading =
next?.kind === "episode" && next.show_title
? `${next.show_title} · S${next.season_number ?? "?"}·E${next.episode_number ?? "?"}`
: (next?.title ?? "");
const sub = next?.kind === "episode" ? next.title : null;
return (
<div
role="region"
aria-label={t("plex.player.upNext.title")}
onClick={(e) => e.stopPropagation()}
className="absolute bottom-24 right-6 z-menu flex w-[22rem] max-w-[calc(100vw-3rem)] gap-3 rounded-2xl border border-white/15 bg-neutral-900/95 p-3.5 text-white shadow-2xl"
>
<div className="h-[6.5rem] w-[4.6rem] flex-none overflow-hidden rounded-lg bg-white/10">
{next && (
<img
src={api.plexImageUrl(nextId, "thumb")}
alt=""
className="h-full w-full object-cover"
draggable={false}
/>
)}
</div>
<div className="flex min-w-0 flex-1 flex-col">
<div className="mb-0.5 text-2xs font-semibold uppercase tracking-wide text-white/50">
{t("plex.player.upNext.title")}
</div>
<div className="truncate text-sm font-semibold leading-tight">{heading || "…"}</div>
{sub && <div className="truncate text-xs text-white/60">{sub}</div>}
<div className="mt-auto flex items-center gap-2 pt-2.5">
<button
onClick={onAdvance}
className="flex items-center gap-1.5 rounded-full bg-accent px-3 py-1.5 text-xs font-semibold text-black hover:brightness-110"
>
<span className="relative grid h-[22px] w-[22px] flex-none place-items-center">
<svg viewBox="0 0 24 24" className="absolute inset-0 -rotate-90">
<circle
cx="12"
cy="12"
r={R}
fill="none"
stroke="rgba(0,0,0,0.25)"
strokeWidth="2"
/>
<circle
cx="12"
cy="12"
r={R}
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeDasharray={C}
strokeDashoffset={C * (1 - frac)}
/>
</svg>
<Play className="h-2.5 w-2.5 fill-black" />
</span>
{t("plex.player.upNext.playNow")}
<span className="tabular-nums">{secs}</span>
</button>
<button
onClick={onCancel}
className="rounded-full border border-white/25 px-3 py-1.5 text-xs text-white/85 hover:bg-white/10"
>
{t("plex.player.upNext.cancel")}
</button>
</div>
</div>
<button
onClick={onCancel}
aria-label={t("plex.player.upNext.cancel")}
className="absolute right-2 top-2 rounded p-0.5 text-white/50 hover:bg-white/10 hover:text-white"
>
<X className="h-4 w-4" />
</button>
</div>
);
}
@@ -0,0 +1,75 @@
// @vitest-environment jsdom
//
// E7·S5c — the local-upload dialog. It's a general file upload (any type, no format list): a picked
// file surfaces its name+size, and Upload streams it through api.uploadDownload (the XHR-progress
// path). This drives the real dialog against a mocked api so we can assert the file + optional name
// actually reach the ingest call, and that the success toast points at the library. i18n is
// key-passthrough.
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
const h = vi.hoisted(() => ({
uploadDownload: vi.fn(async (_f: File, _o: unknown) => ({ id: 42 })),
notify: vi.fn(),
}));
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
});
vi.mock("../lib/api", () => ({ api: { uploadDownload: h.uploadDownload } }));
vi.mock("../lib/downloads", () => ({ invalidateDownloads: vi.fn() }));
vi.mock("../lib/notifications", () => ({ notify: h.notify }));
vi.mock("../lib/nav", () => ({ navigateTo: vi.fn() }));
import UploadDialog from "./UploadDialog";
const wrap = (onClose = vi.fn()) => {
const qc = new QueryClient();
return render(
<QueryClientProvider client={qc}>
<UploadDialog onClose={onClose} />
</QueryClientProvider>,
);
};
// The dialog portals into document.body, so query the whole document, not the render container.
const fileInput = () => document.querySelector('input[type="file"]') as HTMLInputElement;
beforeEach(() => {
h.uploadDownload.mockClear();
h.notify.mockClear();
});
afterEach(cleanup);
describe("UploadDialog", () => {
it("shows the picked file's name and uploads it with the optional name", async () => {
const onClose = vi.fn();
wrap(onClose);
const f = new File(["hello world"], "notes.pdf", { type: "application/pdf" });
fireEvent.change(fileInput(), { target: { files: [f] } });
// The drop zone now surfaces the file name.
expect(screen.getByText("notes.pdf")).toBeTruthy();
// Optional display name.
const nameBox = screen.getByPlaceholderText("downloads.upload.namePlaceholder");
fireEvent.change(nameBox, { target: { value: "My notes" } });
fireEvent.click(screen.getByText("downloads.upload.submit"));
await waitFor(() => expect(h.uploadDownload).toHaveBeenCalledTimes(1));
const [passedFile, opts] = h.uploadDownload.mock.calls[0]!;
expect(passedFile).toBe(f);
expect((opts as { displayName?: string }).displayName).toBe("My notes");
await waitFor(() => expect(h.notify).toHaveBeenCalledTimes(1));
await waitFor(() => expect(onClose).toHaveBeenCalled());
});
it("keeps Upload disabled until a file is chosen", () => {
wrap();
const btn = screen.getByText("downloads.upload.submit").closest("button")!;
expect(btn.disabled).toBe(true);
});
});
+175
View File
@@ -0,0 +1,175 @@
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useQueryClient } from "@tanstack/react-query";
import { UploadCloud, File as FileIcon } from "lucide-react";
import Modal from "./Modal";
import { Button } from "./ui/Button";
import { api } from "../lib/api";
import { invalidateDownloads } from "../lib/downloads";
import { notify } from "../lib/notifications";
import { navigateTo } from "../lib/nav";
import { formatBytes } from "../lib/format";
import { inputCls } from "./ui/form";
// Local-file ingest (E7 S5c · D5-A). A general upload — ANY file type, no format list: media
// (video/audio) joins the library with the full function set, a generic file is download-only. The
// file is streamed to the server via XHR so this dialog can show real upload progress. On success it
// lands in the Library as an already-`done` job, so we invalidate + point the user there.
export default function UploadDialog({ onClose }: { onClose: () => void }) {
const { t } = useTranslation();
const qc = useQueryClient();
const [file, setFile] = useState<File | null>(null);
const [name, setName] = useState("");
const [dragOver, setDragOver] = useState(false);
const [pct, setPct] = useState<number | null>(null); // null = idle; 0–100 while uploading
const inputRef = useRef<HTMLInputElement>(null);
const abortRef = useRef<AbortController | null>(null);
// dragenter/dragleave fire as the cursor crosses the drop zone's OWN children (icon/text), so a
// plain boolean flickers. Count enter vs leave and only clear the highlight at depth 0.
const dragDepth = useRef(0);
const busy = pct !== null;
// Closing the dialog mid-upload (X / ESC / backdrop / browser Back all unmount us) must abort the
// in-flight XHR — otherwise it keeps streaming in the background and still toasts success. Only the
// footer Cancel wired the abort; this covers every other dismissal.
useEffect(() => () => abortRef.current?.abort(), []);
const pick = () => inputRef.current?.click();
const submit = async () => {
if (!file || busy) return;
const ac = new AbortController();
abortRef.current = ac;
setPct(0);
try {
await api.uploadDownload(file, {
displayName: name.trim() || undefined,
onProgress: setPct,
signal: ac.signal,
});
invalidateDownloads(qc);
notify({
level: "success",
message: t("downloads.upload.added"),
action: { label: t("downloads.dialog.view"), onClick: () => navigateTo("downloads") },
});
onClose();
} catch {
// An aborted upload (Cancel) or a server rejection (cap/quota — already surfaced by the global
// error modal, like every other req call). Re-arm the form so the user can retry or pick again.
setPct(null);
abortRef.current = null;
}
};
return (
<Modal title={t("downloads.upload.title")} onClose={onClose}>
<p className="text-sm text-muted mb-4">{t("downloads.upload.hint")}</p>
{/* Drop zone doubles as the picker trigger — a click anywhere opens the native file dialog. */}
<button
type="button"
onClick={pick}
onDragEnter={(e) => {
e.preventDefault();
dragDepth.current += 1;
setDragOver(true);
}}
onDragOver={(e) => e.preventDefault()}
onDragLeave={() => {
dragDepth.current = Math.max(0, dragDepth.current - 1);
if (dragDepth.current === 0) setDragOver(false);
}}
onDrop={(e) => {
e.preventDefault();
dragDepth.current = 0;
setDragOver(false);
const f = e.dataTransfer.files?.[0];
if (f) {
setFile(f);
setName("");
}
}}
disabled={busy}
className={`w-full flex flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed px-4 py-8 text-center transition disabled:opacity-60 ${
dragOver ? "border-accent bg-accent/5" : "border-border hover:border-fg/40"
}`}
>
{file ? (
<>
<FileIcon className="w-6 h-6 text-muted" />
<span className="text-sm font-medium break-all">{file.name}</span>
<span className="text-xs text-muted">{formatBytes(file.size)}</span>
</>
) : (
<>
<UploadCloud className="w-7 h-7 text-muted" />
<span className="text-sm text-muted">
{dragOver ? t("downloads.upload.dropActive") : t("downloads.upload.drop")}
</span>
</>
)}
</button>
<input
ref={inputRef}
type="file"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) {
setFile(f);
setName("");
}
e.target.value = ""; // let re-picking the same file fire onChange again
}}
/>
<label className="block text-sm font-medium mt-4 mb-1">
{t("downloads.upload.nameLabel")}
</label>
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t("downloads.upload.namePlaceholder")}
disabled={busy}
className={`${inputCls} mb-5`}
/>
{busy && (
<div className="mb-5" aria-live="polite">
<div
className="h-2 rounded-full bg-surface overflow-hidden"
role="progressbar"
aria-valuenow={pct ?? 0}
aria-valuemin={0}
aria-valuemax={100}
// A STABLE descriptor as the name; the live percentage rides aria-valuenow (and the visible
// <p> below re-announces it via the aria-live wrapper) — don't bake the value into the name.
aria-label={t("downloads.upload.uploadProgress")}
>
<div
className="h-full bg-accent transition-[width] duration-150"
style={{ width: `${pct ?? 0}%` }}
/>
</div>
<p className="text-xs text-muted mt-1.5 text-center">
{t("downloads.upload.uploading", { pct: pct ?? 0 })}
</p>
</div>
)}
<div className="flex justify-end gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => (busy ? abortRef.current?.abort() : onClose())}
>
{t("downloads.actions.cancel")}
</Button>
<Button variant="primary" size="sm" onClick={submit} disabled={!file || busy}>
{t("downloads.upload.submit")}
</Button>
</div>
</Modal>
);
}
+41
View File
@@ -0,0 +1,41 @@
import { useTranslation } from "react-i18next";
import { CheckCircle2, AlertTriangle } from "lucide-react";
import Banner from "./Banner";
import type { VerifyEntry } from "../lib/useVerifyEntry";
// The top-of-app outcome of an emailed verification link opened in a browser that ALREADY holds a
// session. That case used to be silent: the token was only ever consumed inside Welcome.tsx, which a
// signed-in visit never reaches, so the link opened a tab, the fragment was stripped, and nothing
// happened. Signed OUT, the same outcome is rendered by the auth card's own banners instead — this
// bar is only for the signed-in half.
//
// No "send a new link" action here, unlike the auth card: this viewer is signed in, so the sign-in
// screen (where resending lives) is not where they are, and the message is a report, not a task. A
// wrong-account case is why the address is named.
export default function VerifyBanner({ entry }: { entry: VerifyEntry }) {
const { t } = useTranslation();
if (!entry.outcome) return null;
const ok = entry.outcome !== "invalid";
const text =
entry.outcome === "invalid"
? t("header.verify.invalid")
: entry.email
? t("header.verify.confirmedNamed", { email: entry.email })
: t("header.verify.confirmed");
return (
<Banner
tone={ok ? "accent" : "warning"}
icon={ok ? CheckCircle2 : AlertTriangle}
role="status"
onDismiss={entry.dismiss}
dismissTitle={t("header.banner.dismiss")}
>
{text}
{entry.outcome === "ok_reset" && (
<span className="text-muted"> {t("header.verify.reset")}</span>
)}
</Banner>
);
}
+41 -14
View File
@@ -7,6 +7,7 @@ import clsx from "clsx";
import Modal from "./Modal";
import { notify } from "../lib/notifications";
import { api, type DownloadJob, type EditSpec } from "../lib/api";
import { contentRect, cropToSourcePx, type Frac } from "../lib/cropGeometry";
import { inputCls, btnCls } from "./ui/form";
import { Button } from "./ui/Button";
@@ -31,7 +32,6 @@ function parseTc(v: string): number | null {
}
type Seg = { id: number; start: number; end: number; keep: boolean };
type Frac = { x: number; y: number; w: number; h: number };
type Drag = { type: "seek" } | { type: "boundary"; bi: number };
const numCls =
@@ -51,6 +51,11 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
const [current, setCurrent] = useState(0);
const [playing, setPlaying] = useState(false);
const [trackW, setTrackW] = useState(0);
// Intrinsic (source) video dims and the video element's rendered box, both needed to map the
// crop marquee onto the letterboxed picture. `vdim` starts from asset metadata so the overlay
// can size before the media decodes; `box` is measured off the element (max-h can clamp it).
const [vdim, setVdim] = useState({ w: job.asset?.width ?? 0, h: job.asset?.height ?? 0 });
const [box, setBox] = useState({ w: 0, h: 0 });
// The cut-list: contiguous segments covering [0, duration]. Starts as the whole video, kept.
const [segments, setSegments] = useState<Seg[]>([{ id: 0, start: 0, end: srcDur, keep: true }]);
@@ -83,11 +88,28 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
return () => ro.disconnect();
}, []);
// Measure the video element's rendered box (max-h can shrink it below its intrinsic size, which
// then pillarboxes the picture). The crop overlay is anchored to the picture, not this box.
useEffect(() => {
const v = videoRef.current;
if (!v || typeof ResizeObserver === "undefined") return;
const ro = new ResizeObserver(() => setBox({ w: v.clientWidth, h: v.clientHeight }));
ro.observe(v);
setBox({ w: v.clientWidth, h: v.clientHeight });
return () => ro.disconnect();
}, []);
// The picture's rendered rectangle inside the (possibly letterboxed) video box — the overlay's
// frame, so a crop fraction is a fraction of the real picture, not of the black bars.
const va = vdim.w && vdim.h ? vdim.w / vdim.h : aspect;
const picRect = contentRect(box.w, box.h, va);
const onLoaded = () => {
const v = videoRef.current;
if (!v) return;
const d = v.duration && isFinite(v.duration) ? v.duration : srcDur;
setDuration(d);
if (v.videoWidth && v.videoHeight) setVdim({ w: v.videoWidth, h: v.videoHeight });
// Reconcile the still-pristine full-length segment to the REAL decoded duration. The initial
// segment ends at the (integer, often-rounded) metadata `srcDur`; once the media loads we know
// the true length (e.g. 213.44 vs 213). Without this the untouched last segment stays short, so
@@ -258,16 +280,12 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
const cropPx = (): { x: number; y: number; w: number; h: number } | undefined => {
const v = videoRef.current;
const vw = v?.videoWidth || job.asset?.width || 0;
const vh = v?.videoHeight || job.asset?.height || 0;
if (!cropOn || !vw || !vh) return undefined;
const even = (n: number) => Math.max(2, Math.round(n / 2) * 2);
return {
x: even(crop.x * vw),
y: even(crop.y * vh),
w: even(crop.w * vw),
h: even(crop.h * vh),
};
const vw = v?.videoWidth || vdim.w;
const vh = v?.videoHeight || vdim.h;
if (!cropOn) return undefined;
// crop fractions are relative to the picture (the overlay is anchored to it), so this is a
// straight fraction→source-px scale; see cropGeometry for the even-rounding + in-bounds clamp.
return cropToSourcePx(crop, vw, vh) ?? undefined;
};
const create = useMutation({
@@ -322,11 +340,20 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
onTimeUpdate={onTimeUpdate}
onPlay={() => setPlaying(true)}
onPause={() => setPlaying(false)}
className="w-full max-h-[42vh] mx-auto block"
className="w-full max-h-[42vh] mx-auto block object-contain"
playsInline
/>
{cropOn && (
<div ref={overlayRef} className="absolute inset-0">
{cropOn && picRect && (
<div
ref={overlayRef}
className="absolute"
style={{
left: picRect.left,
top: picRect.top,
width: picRect.width,
height: picRect.height,
}}
>
<div
className="absolute border-2 border-accent cursor-move"
style={{
@@ -36,4 +36,41 @@ describe("WatchPage media element (S3 · 5a)", () => {
await waitFor(() => expect(container.querySelector("video")).not.toBeNull());
expect(container.querySelector("audio")).toBeNull();
});
it("renders an info card + Download for a generic file, no player (S5d)", async () => {
window.history.replaceState({}, "", "/watch/tok-file");
mockMeta({
needs_password: false,
media_kind: "file",
title: "report.pdf",
container: "pdf",
size_bytes: 2_400_000,
allow_download: true,
file_url: "/f.pdf",
});
const { container, findByText } = render(<WatchPage />);
await findByText("report.pdf");
expect(container.querySelector("video")).toBeNull();
expect(container.querySelector("audio")).toBeNull();
// Type · size chip and a Download link to the file.
await findByText(/PDF · 2\.3 MB/);
const dl = container.querySelector('a[href="/f.pdf"]');
expect(dl).not.toBeNull();
});
it("omits the Download for a view-only file link (S5d)", async () => {
window.history.replaceState({}, "", "/watch/tok-file-noview");
mockMeta({
needs_password: false,
media_kind: "file",
title: "secret.zip",
container: "zip",
size_bytes: 1000,
allow_download: false,
file_url: "/f.zip",
});
const { container, findByText } = render(<WatchPage />);
await findByText("secret.zip");
expect(container.querySelector('a[href="/f.zip"]')).toBeNull();
});
});
+39 -2
View File
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from "react";
import { Download, Link2, Lock, PlayCircle } from "lucide-react";
import { Download, File as FileIcon, Link2, Lock, PlayCircle } from "lucide-react";
import { Button } from "./ui/Button";
import { formatBytes } from "../lib/format";
// Standalone, login-free video page for a shared /watch/<token> link. Rendered OUTSIDE the app
// tree (no auth, no providers) — like the /privacy and /terms pages. Uses plain fetch and a tiny
@@ -48,6 +49,9 @@ type Meta = {
extra_links?: string[];
duration_s?: number | null;
audio?: boolean;
media_kind?: "video" | "audio" | "file" | null;
size_bytes?: number | null;
container?: string | null;
allow_download?: boolean;
file_url?: string;
poster_url?: string | null;
@@ -73,6 +77,7 @@ const STR = {
watch: "Watch",
wrong: "Wrong password.",
download: "Download",
file: "File",
links: "Links",
brand: "Shared via Siftlode",
},
@@ -85,6 +90,7 @@ const STR = {
watch: "Megnézem",
wrong: "Hibás jelszó.",
download: "Letöltés",
file: "Fájl",
links: "Linkek",
brand: "Megosztva a Siftlode-dal",
},
@@ -97,6 +103,7 @@ const STR = {
watch: "Ansehen",
wrong: "Falsches Passwort.",
download: "Herunterladen",
file: "Datei",
links: "Links",
brand: "Geteilt über Siftlode",
},
@@ -215,7 +222,37 @@ export default function WatchPage() {
</div>
)}
{status === "ready" && meta && (
{status === "ready" && meta && meta.media_kind === "file" && (
/* A generic uploaded file has no player — just an info card (name/type/size) and, if the
link allows it, a Download. The backend serves the file force-download regardless, so
gating the button on allow_download only mirrors the owner's ACL, matching the media
branch's download affordance below. */
<div className="flex-1 grid place-items-center">
<div className="w-full max-w-md rounded-2xl glass p-6 text-center">
<div className="mx-auto w-16 h-16 rounded-xl bg-surface grid place-items-center mb-4">
<FileIcon className="w-8 h-8 text-accent" />
</div>
<h1 className="text-lg font-semibold leading-snug break-words">
{meta.title || T.file}
</h1>
<div className="text-sm text-muted mt-1">
{[meta.container?.toUpperCase(), formatBytes(meta.size_bytes)]
.filter(Boolean)
.join(" · ")}
</div>
{meta.allow_download && (
<a
href={meta.file_url}
className="mt-5 inline-flex items-center gap-1.5 rounded-lg bg-surface hover:bg-card px-4 py-2 text-sm font-medium transition"
>
<Download className="w-4 h-4" /> {T.download}
</a>
)}
</div>
</div>
)}
{status === "ready" && meta && meta.media_kind !== "file" && (
<div>
{meta.audio ? (
/* Audio-only asset (mp3): WebKit won't play it in a <video>, so render a native
+357 -57
View File
@@ -1,33 +1,53 @@
import { useEffect, useRef, useState } from "react";
import { useHistorySubview, type SubviewUrl } from "../lib/history";
import { useTranslation } from "react-i18next";
import {
ArrowRight,
Check,
Clapperboard,
Clock,
Code2,
Download,
Expand,
Filter,
Inbox,
Link2Off,
ListVideo,
Lock,
MailWarning,
PlayCircle,
Search,
ShieldAlert,
X,
type LucideIcon,
} from "lucide-react";
// Siftlode is open source; the landing footer links to the public repository.
const REPO_URL = "https://git.b1fr0st.eu/peter/siftlode";
import { api } from "../lib/api";
import { api, deadResetLink, loginBlocked, type LoginBlocked } from "../lib/api";
import { HttpError } from "../lib/api";
import { setLanguage, type LangCode } from "../i18n";
import LanguageSwitcher from "./LanguageSwitcher";
import { inputCls } from "./ui/form";
import { entryHash, entrySearch } from "../lib/entryUrl";
import type { VerifyEntry } from "../lib/useVerifyEntry";
const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
const PASSWORD_MIN = 10;
type Mode = "signin" | "register" | "forgot" | "reset" | "resend";
export type Mode = "signin" | "register" | "forgot" | "reset" | "resend";
// The card's sub-views are real history entries, so browser/mouse Back leaves one the way it leaves
// any other page — they used to be plain state, and Back silently changed only the address bar.
// "signin" is the root (no param) and "reset" is deliberately NOT mirrored: that view is only
// reachable with the one-time token from the #reset fragment, so a bookmarked ?m=reset would open a
// form that cannot submit.
export const MODE_URL: SubviewUrl<Mode> = {
key: "m",
encode: (m: Mode) => (m === "signin" || m === "reset" ? null : m),
decode: (raw: string) =>
raw === "register" || raw === "forgot" || raw === "resend" ? (raw as Mode) : null,
};
// Per-mode i18n keys for the card title and the submit button. A flat map (vs nested ternaries)
// keeps adding a new mode a one-line change and makes a missing key a compile error.
@@ -50,7 +70,7 @@ const SUBMIT_KEY: Record<Mode, string> = {
// password, Google, explicit demo) over a short marketing pitch with feature cards. Shown
// whenever the user isn't signed in (see App.tsx). The auth forms talk to /auth/* (see the
// backend); errors are surfaced inline (the api calls use the quiet flag).
export default function Welcome() {
export default function Welcome({ verify }: { verify: VerifyEntry }) {
const { t, i18n } = useTranslation();
// Which screenshot (if any) is open in the full-size lightbox.
const [zoomed, setZoomed] = useState<{ src: string; label: string } | null>(null);
@@ -74,7 +94,7 @@ export default function Welcome() {
<p className="text-muted mt-4 text-lg leading-relaxed">{t("welcome.hero.subtitle")}</p>
<p className="text-sm text-muted mt-4">{t("welcome.hero.trust")}</p>
</div>
<AuthCard />
<AuthCard verify={verify} />
</section>
{/* App preview */}
@@ -288,47 +308,59 @@ function Lightbox({ src, label, onClose }: { src: string; label: string; onClose
);
}
function AuthCard() {
// One answer per reset token, however many times React mounts this card. StrictMode double-invokes
// effects in dev and the shell remounts the card as the session probe settles — measured in UAT on
// 2026-09-05: FOUR probes went out for one opened link. That would burn the endpoint's per-IP budget
// after a couple of reloads and silently drop back to drawing the form unchecked. Holding the
// in-flight promise means a remount joins the request already running instead of starting another.
const resetTokenChecks = new Map<string, Promise<boolean>>();
function checkResetToken(token: string): Promise<boolean> {
let pending = resetTokenChecks.get(token);
if (!pending) {
pending = api
.checkPasswordReset(token)
.then((r) => r.ok)
// A probe that FAILED says nothing about the token, so it answers "go ahead" and forgets
// itself: a network blip must never be cached as "this link is dead".
.catch(() => {
resetTokenChecks.delete(token);
return true;
});
resetTokenChecks.set(token, pending);
}
return pending;
}
function AuthCard({ verify }: { verify: VerifyEntry }) {
const { t } = useTranslation();
// Snapshot the pre-login params ONCE (query + fragment) so we can clean the address bar below
// without dismissing the banners/reset-mode they drive. Secret tokens (reset, verify) ride the
// URL FRAGMENT so they never reach the server / proxy logs / Referer (SB3); plain status flags
// stay in the query string.
const [params] = useState(() => new URLSearchParams(window.location.search));
const [hashParams] = useState(() => new URLSearchParams(window.location.hash.replace(/^#/, "")));
const resetToken = hashParams.get("reset");
const verifyToken = hashParams.get("verify"); // raw token → POST to confirm (new flow)
const bounced = params.get("access"); // Google denied → "requested" | "denied"
const deleted = params.get("deleted"); // self-service account deletion just completed
// The entry URL is snapshotted once at import time (lib/entryUrl) rather than here, so nothing can
// rewrite the address bar out from under this card before it mounts — and so the App-level hook and
// this card read the SAME values regardless of which mounts first. Clearing the address bar is that
// hook's job now, since it is the one thing that runs on every visit, signed in or not.
//
// State, not a const: once the token has been spent the reset view must stop being the card's
// root, or a Back from the "password changed" screen lands on a form whose token is already used.
const [resetToken, setResetToken] = useState<string | null>(() => entryHash.get("reset"));
// The Google access-request notice, off the FRAGMENT (see lib/entryUrl): it names the address now,
// and an address in the query string would land in every reverse-proxy access log. `linked` is set
// only when the request already existed under ANOTHER spelling of the same mailbox — the case worth
// explaining; the same spelling twice is just a repeat.
const bounced = entryHash.get("access"); // Google denied → "requested" | "denied"
const bouncedEmail = entryHash.get("email");
const bouncedLinked = entryHash.get("linked");
const deleted = entrySearch.get("deleted"); // self-service account deletion just completed
// Google login outcome → "suspended" | "oauth_cancelled". State (not a const) so it clears on the
// first deliberate action, like the verify banner — otherwise it lingers stalely on later screens.
const [login, setLogin] = useState<string | null>(() => params.get("login"));
// Verification outcome: from POSTing the fragment token (new flow), or the legacy
// ?verify=ok|invalid redirect (pre-SB3 emails still in flight).
const [verify, setVerify] = useState<string | null>(() => params.get("verify"));
const [login, setLogin] = useState<string | null>(() => entrySearch.get("login"));
// The verification outcome is confirmed in App (above the signed-in branch) and handed down, so
// one emailed link behaves the same whether or not this browser already holds a session.
const verifyOutcome = verify.outcome;
// Strip BOTH the query string and the fragment so the address bar stays clean and the token
// doesn't linger in history.
useEffect(() => {
if (window.location.search || window.location.hash) {
window.history.replaceState(null, "", window.location.pathname);
}
}, []);
// Confirm a fragment verification token by POSTing it (new SB3 flow). Fire ONCE: the token is
// single-use, so a double POST (StrictMode remount / any re-render) would 400 on the second call
// and wrongly flip the banner to "invalid".
const verifyFired = useRef(false);
useEffect(() => {
if (!verifyToken || verifyFired.current) return;
verifyFired.current = true;
api
.verifyEmail(verifyToken)
.then(() => setVerify("ok"))
.catch(() => setVerify("invalid"));
}, [verifyToken]);
const [mode, setMode] = useState<Mode>(resetToken ? "reset" : "signin");
const { view: mode, open: setMode } = useHistorySubview<Mode>(
resetToken ? "reset" : "signin",
MODE_URL,
);
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [busy, setBusy] = useState(false);
@@ -344,6 +376,44 @@ function AuthCard() {
.catch(() => {});
}, []);
const [done, setDone] = useState<string>(""); // success/info message replacing the form
// Why password sign-in refused, when the server named a state worth a screen rather than a red line
// (E9.5 S4). It REPLACES the form: in all three of those states the password was already proven
// correct, so there is nothing to retype — leaving the form up would only invite a pointless retry.
const [blocked, setBlocked] = useState<LoginBlocked | null>(null);
// The emailed reset link turned out to be spent, expired, or to belong to an account that no longer
// exists. Same reasoning as `blocked`: the form cannot succeed, so it must not be on screen.
const [deadReset, setDeadReset] = useState(false);
// Ask whether the `#reset=` token is still usable BEFORE drawing the form, and hold the card blank
// until the answer lands. Reported 2026-09-05: deleting an account and then opening its reset link
// rendered a full "set a new password" form that could only ever answer 400 — the user finds out
// after typing. This is advisory (the confirm below stays the authority); a check that errors is
// treated as "go ahead", never as "dead", so a broken probe cannot lock anyone out of a live link.
const [checkingReset, setCheckingReset] = useState(() => entryHash.get("reset") !== null);
useEffect(() => {
const token = entryHash.get("reset");
if (!token) return;
let live = true;
void checkResetToken(token).then((ok) => {
if (!live) return;
if (!ok) {
setDeadReset(true);
setResetToken(null); // and stop the reset view being the card's root, so Back can't return
}
setCheckingReset(false);
});
return () => {
live = false;
};
}, []);
// `done` and `error` belong to the view that produced them, and the render is `done ? msg : form`
// — without this, Back out of a submitted form would show the old success message under the
// previous view's title. Clearing on view change covers both Back/Forward and the mode links.
useEffect(() => {
setDone("");
setError("");
setBlocked(null);
setDeadReset(false);
}, [mode]);
// Explicit demo entry (replaces the old hidden probe): a labelled email field, still gated
// by the demo whitelist server-side.
@@ -355,9 +425,11 @@ function AuthCard() {
const reset = () => {
setError("");
setDone("");
setBlocked(null);
setDeadReset(false);
// The verify/login banners are one-time entry notices — dismiss once the user takes any
// deliberate action (mode switch, CTA, submit) so they don't linger stalely on later screens.
setVerify(null);
verify.dismiss();
setLogin(null);
};
@@ -367,6 +439,13 @@ function AuthCard() {
setMode("resend");
};
// Used by the "verified, but your password was revoked" banner — the only way back into that
// account is a reset link, so put the user one click from it.
const goForgot = () => {
reset();
setMode("forgot");
};
async function onSubmit(e: React.FormEvent) {
e.preventDefault();
reset();
@@ -400,9 +479,24 @@ function AuthCard() {
setDone(t("welcome.auth.resendDone"));
} else if (mode === "reset") {
await api.confirmPasswordReset(resetToken ?? "", password);
setResetToken(null); // single-use: the reset view is no longer a place to come back to
setDone(t("welcome.auth.resetDone"));
}
} catch (err) {
// An account-shaped refusal becomes a named screen; `invalid` / `rate_limited` stay a line
// under the form, because there the form IS the next step.
const refusal = err instanceof HttpError ? loginBlocked(err.detailData) : null;
if (refusal && PANEL_REASONS.has(refusal.reason)) {
setBlocked(refusal);
return;
}
// The token died between the pre-flight check and this submit — or was never checked, because
// the probe itself failed. Either way the answer is the same screen, not a red line.
if (mode === "reset" && err instanceof HttpError && deadResetLink(err.detailData)) {
setDeadReset(true);
setResetToken(null);
return;
}
setError(
err instanceof HttpError && err.detail ? err.detail : t("welcome.auth.genericError"),
);
@@ -431,15 +525,37 @@ function AuthCard() {
}
}
const titleKey = TITLE_KEY[mode];
// A named state owns the card's heading while it is up, so it reads as the card's subject rather
// than a box sitting inside a heading that still says "Set a new password".
const titleKey = blocked
? `welcome.auth.blocked.${blocked.reason}.title`
: deadReset
? "welcome.auth.deadLink.title"
: TITLE_KEY[mode];
const StateIcon = blocked ? BLOCKED_ICON[blocked.reason] : deadReset ? Link2Off : null;
return (
<div className="glass rounded-2xl p-6 sm:p-7 w-full max-w-md lg:justify-self-end">
<h2 className="text-lg font-semibold mb-1">{t(titleKey)}</h2>
<h2 className="text-lg font-semibold mb-1 flex items-center gap-2.5">
{StateIcon && (
<span className="w-8 h-8 rounded-xl bg-warn/15 text-warn grid place-items-center shrink-0">
<StateIcon className="w-[18px] h-[18px]" />
</span>
)}
{t(titleKey)}
</h2>
{/* One-time status banners from a redirect (Google-denied, email verification). */}
{verify === "ok" && <Banner tone="ok">{t("welcome.auth.verifyOk")}</Banner>}
{verify === "invalid" && (
{verifyOutcome === "ok" && <Banner tone="ok">{t("welcome.auth.verifyOk")}</Banner>}
{verifyOutcome === "ok_reset" && (
<Banner tone="warn">
{t("welcome.auth.verifyOkReset")}{" "}
<button onClick={goForgot} className="font-semibold text-accent hover:underline">
{t("welcome.auth.verifyOkResetCta")}
</button>
</Banner>
)}
{verifyOutcome === "invalid" && (
<Banner tone="warn">
{t("welcome.auth.verifyInvalid")}{" "}
<button onClick={goResend} className="font-semibold text-accent hover:underline">
@@ -447,7 +563,26 @@ function AuthCard() {
</button>
</Banner>
)}
{bounced === "requested" && <Banner tone="ok">{t("welcome.auth.accessRequested")}</Banner>}
{bounced === "requested" &&
(bouncedEmail ? (
<Banner tone="ok">
<span className="block">{t("welcome.auth.accessRequestedFor")}</span>
<Address value={bouncedEmail} />
<span className="block">{t("welcome.auth.accessRequestedReview")}</span>
</Banner>
) : (
<Banner tone="ok">{t("welcome.auth.accessRequested")}</Banner>
))}
{/* The alias insight, stated only when it applies: Google answered with one spelling of a
mailbox whose request is already on file under another. Silence here is what let the
2026-09-03 registrant believe nothing had been recorded at all. */}
{bounced === "requested" && bouncedLinked && (
<Banner tone="warn">
<span className="block">{t("welcome.auth.accessLinked")}</span>
<Address value={bouncedLinked} />
<span className="block">{t("welcome.auth.accessLinkedTail")}</span>
</Banner>
)}
{bounced === "denied" && <Banner tone="warn">{t("welcome.auth.accessDenied")}</Banner>}
{login === "suspended" && <Banner tone="warn">{t("welcome.auth.suspended")}</Banner>}
{login === "oauth_cancelled" && (
@@ -455,7 +590,22 @@ function AuthCard() {
)}
{deleted && <Banner tone="ok">{t("welcome.auth.deleted")}</Banner>}
{done ? (
{blocked ? (
<BlockedPanel blocked={blocked} onResend={goResend} onBack={() => setBlocked(null)} />
) : deadReset ? (
<StatePanel
body={t("welcome.auth.deadLink.body")}
cta={{ label: t("welcome.auth.deadLink.cta"), onClick: goForgot }}
onBack={() => {
reset();
setMode("signin");
}}
/>
) : checkingReset ? (
// Blank rather than the form: showing a form we may be about to withdraw is the flicker this
// whole check exists to remove.
<p className="mt-3 text-sm text-muted">{t("welcome.auth.working")}</p>
) : done ? (
<div className="mt-3">
<p className="text-sm text-fg/80 leading-relaxed">{done}</p>
<button
@@ -507,6 +657,18 @@ function AuthCard() {
</button>
</form>
{/* A confirmation mail can be slow, filtered, or — before E9.5 S6 — never sent at all for a
repeat sign-up. Offered right under the form rather than below the mode switches: it is
the second thing a stuck person needs, after the password they just tried. */}
{mode === "signin" && (
<button
onClick={goResend}
className="mt-3 block w-full text-center text-xs text-muted hover:text-fg"
>
{t("welcome.auth.resendLink")}
</button>
)}
{/* Mode switches */}
<div className="flex items-center justify-between mt-3 text-xs">
{mode === "signin" ? (
@@ -545,14 +707,6 @@ function AuthCard() {
{mode === "signin" && (
<>
{/* A verification email can be lost or never arrive, so offer a resend path from
sign-in too — not only from the expired-link banner (needs a broken link to show). */}
<button
onClick={goResend}
className="mt-2 block w-full text-center text-xs text-muted hover:text-fg"
>
{t("welcome.auth.resendLink")}
</button>
<div className="flex items-center gap-3 my-4 text-2xs uppercase tracking-wide text-muted">
<span className="h-px flex-1 bg-border" />
{t("welcome.auth.or")}
@@ -603,9 +757,155 @@ function AuthCard() {
);
}
// An email address the user has to READ and compare, character by character — the recorded address on
// a blocked-sign-in panel, the mailbox a request was filed under. Monospaced and breakable because
// that comparison is the whole job: a dot or a `+tag` in a proportional font is exactly what nobody
// noticed on 2026-09-03.
function Address({ value }: { value: string }) {
return <span className="block font-mono font-medium break-all my-0.5">{value}</span>;
}
// Which refusals earn a screen of their own. The other two (`invalid`, `rate_limited`) are answered
// BEFORE the password is proven, carry no account detail, and leave the form as the next step — a
// line under it is the honest size for them.
const PANEL_REASONS = new Set<LoginBlocked["reason"]>([
"email_unverified",
"pending_approval",
"suspended",
]);
const BLOCKED_ICON: Partial<Record<LoginBlocked["reason"], LucideIcon>> = {
email_unverified: MailWarning,
pending_approval: Clock,
suspended: ShieldAlert,
};
// The frame every named state in this card shares: a sentence, whatever detail that state can give,
// then the way out. The icon and title sit in the card's own h2 (see titleKey/StateIcon) so a state
// reads as the card's subject. One frame, because two states that mean "this form cannot succeed"
// should not drift apart in shape.
function StatePanel({
body,
cta,
onBack,
children,
}: {
body: string;
cta?: { label: string; onClick: () => void };
onBack: () => void;
children?: React.ReactNode;
}) {
const { t } = useTranslation();
// Announced and focused, because this panel REPLACES the form the user just submitted: without a
// live region a screen reader reports nothing at all, and without the focus move the button they
// were standing on is unmounted, dropping focus to <body> so Tab restarts at the top of the page.
// The Banner in this same file got `role="status"` for the same reason; the panel is the louder
// half — it is the whole answer to a submit — so it announces assertively.
const heading = useRef<HTMLParagraphElement>(null);
useEffect(() => heading.current?.focus(), []);
return (
<div className="mt-3 flex flex-col gap-4" role="alert" aria-live="assertive">
<p
ref={heading}
tabIndex={-1}
className="text-sm text-fg/80 leading-relaxed focus:outline-hidden"
>
{body}
</p>
{children}
<div className="flex items-center justify-between gap-3">
<button onClick={onBack} className="text-sm text-accent hover:underline">
{t("welcome.auth.backToSignin")}
</button>
{cta && (
<button
onClick={cta.onClick}
className="shrink-0 px-3 py-2 rounded-xl text-xs font-semibold bg-card border border-border hover:border-accent transition"
>
{cta.label}
</button>
)}
</div>
</div>
);
}
// The named state behind a 403, in place of the sign-in form (E9.5 S4). It says WHICH mailbox the
// account is on and WHICH of the two gates is still shut, because the flat "awaiting admin approval"
// string it replaces was true and useless: it never revealed that the address the account carried was
// a different spelling of the one the user had just typed.
function BlockedPanel({
blocked,
onResend,
onBack,
}: {
blocked: LoginBlocked;
onResend: () => void;
onBack: () => void;
}) {
const { t } = useTranslation();
const { reason, email, emailVerified, approved, operator } = blocked;
// Suspension is not a gate that will open on its own, so the checklist would be a lie there.
const gated = reason !== "suspended";
return (
<StatePanel
body={t(`welcome.auth.blocked.${reason}.body`)}
cta={
reason === "email_unverified"
? { label: t("welcome.auth.resendCta"), onClick: onResend }
: undefined
}
onBack={onBack}
>
{gated && (
<div className="rounded-xl border border-border bg-card/60 px-3.5 py-3 flex flex-col gap-2">
<Gate done={emailVerified === true} k="email" />
<Gate done={approved === true} k="approval" />
</div>
)}
{email && (
<div className="border-l-2 border-border pl-3 flex flex-col gap-0.5">
<span className="text-2xs uppercase tracking-wide text-muted">
{t("welcome.auth.blocked.addressLabel")}
</span>
<span className="text-sm">
<Address value={email} />
</span>
</div>
)}
{operator && (
<p className="text-xs text-muted leading-relaxed">
{t("welcome.auth.blocked.operator", { contact: operator })}
</p>
)}
</StatePanel>
);
}
// One row of the two-gate checklist. Both states get their own full sentence rather than a shared
// label plus a status word: Hungarian puts them together differently, and gluing translated
// fragments is how a bilingual UI ends up reading like neither language.
function Gate({ done, k }: { done: boolean; k: "email" | "approval" }) {
const { t } = useTranslation();
const Icon = done ? Check : Clock;
return (
<span className={`flex items-center gap-2 text-xs ${done ? "text-fg/80" : "text-muted"}`}>
<Icon className={`w-4 h-4 shrink-0 ${done ? "text-success" : ""}`} />
{t(`welcome.auth.blocked.gate.${k}${done ? "Done" : "Pending"}`)}
</span>
);
}
// `role="status"` because these appear in response to something that just happened (a verification
// link, a denied Google sign-in, a completed deletion) rather than being part of the card at rest —
// without it a screen-reader user gets no announcement that anything changed.
function Banner({ tone, children }: { tone: "ok" | "warn"; children: React.ReactNode }) {
const cls = tone === "ok" ? "bg-accent/15 text-fg" : "bg-warn/15 text-fg";
return (
<div className={`rounded-xl px-3 py-2 text-xs leading-relaxed mt-3 ${cls}`}>{children}</div>
<div role="status" className={`rounded-xl px-3 py-2 text-xs leading-relaxed mt-3 ${cls}`}>
{children}
</div>
);
}
@@ -0,0 +1,347 @@
// @vitest-environment jsdom
//
// The Access-requests panel's delete affordance and its three outcomes. The control's whole point is
// that it explains itself: an entry backed by an account keeps a focusable, labelled control instead
// of quietly missing one, and an address the server's env still allow-lists gets a warning BEFORE the
// irreversible click, not a correction after it.
//
// i18n is a key-passthrough so assertions read stable keys; the API and the confirm/notify
// singletons are stubbed so the panel can be driven without a server.
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
import { render, screen, cleanup, waitFor, fireEvent } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return {
...actual,
useTranslation: () => ({
// Echo the interpolation too, so a message that forgets its {{email}} is visible in the test.
t: (key: string, vars?: Record<string, unknown>) =>
vars?.email ? `${key}:${String(vars.email)}` : key,
i18n: { language: "en" },
}),
};
});
type ConfirmOpts = {
title?: string;
message?: React.ReactNode;
confirmLabel?: string;
danger?: boolean;
};
const confirmMock = vi.fn(async (_opts: ConfirmOpts) => true);
vi.mock("./ConfirmProvider", () => ({
useConfirm: () => confirmMock,
ConfirmProvider: ({ children }: { children: React.ReactNode }) => children,
}));
const notifyMock = vi.fn();
vi.mock("../lib/notifications", async (importOriginal) => {
const actual = await importOriginal<typeof import("../lib/notifications")>();
return { ...actual, notify: (...a: unknown[]) => notifyMock(...a) };
});
const meMock = {
pending_invites: 0,
pending_activations: 0,
email_configured: true,
id: 1,
role: "admin",
};
vi.mock("../lib/useMe", () => ({ useMe: () => meMock }));
// The tab strip is rendered into an app-level toolbar slot that does not exist here, so pin the
// active tab directly — this file is about the Access-requests panel, not the tab chrome.
vi.mock("./Tabs", () => ({
default: () => null,
usePersistedTab: () => ["access", () => {}],
}));
const listMock = vi.fn();
const deleteMock = vi.fn();
const inviteMock = vi.fn();
vi.mock("../lib/api", async (importOriginal) => {
const actual = await importOriginal<typeof import("../lib/api")>();
return {
...actual,
api: {
...actual.api,
adminInvites: () => listMock(),
deleteInvite: (id: number) => deleteMock(id),
sendInvitation: (id: number, lang: string) => inviteMock(id, lang),
// The access tab also renders the stuck-sign-ups section (E9.5 S5); nothing stuck here, so it
// renders nothing and this file stays about the delete affordance.
adminPendingAccess: () => Promise.resolve([]),
},
};
});
import { HttpError } from "../lib/api";
import AdminUsers from "./AdminUsers";
const invite = (over: Partial<Record<string, unknown>> = {}) => ({
id: 1,
email: "asker@example.com",
status: "pending",
note: null,
requested_at: null,
decided_at: null,
decided_by: null,
has_account: false,
still_allowed_by_env: false,
invited_at: null,
lang: null,
...over,
});
const renderPanel = () => {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={qc}>
<AdminUsers />
</QueryClientProvider>,
);
};
beforeEach(() => {
confirmMock.mockClear().mockResolvedValue(true);
notifyMock.mockClear();
deleteMock.mockReset().mockResolvedValue({ deleted: 1, still_allowed_by_env: false });
listMock.mockReset().mockResolvedValue([invite()]);
inviteMock
.mockReset()
.mockResolvedValue(invite({ status: "approved", invited_at: "2026-09-05T09:00:00Z" }));
meMock.email_configured = true;
});
afterEach(cleanup);
const openAccessTab = async () => {
await screen.findByText("settings.invites.title");
};
describe("Access requests — the delete affordance", () => {
it("offers a labelled delete on an entry with no account behind it", async () => {
renderPanel();
await openAccessTab();
const btn = await screen.findByLabelText("settings.invites.deleteLabel:asker@example.com");
expect(btn.getAttribute("aria-disabled")).toBeNull();
});
it("keeps the control present and focusable — not missing — when the address has an account", async () => {
listMock.mockResolvedValue([invite({ has_account: true })]);
renderPanel();
await openAccessTab();
const btn = await screen.findByLabelText("settings.invites.deleteHasAccount");
// aria-disabled, not `disabled`: a disabled button leaves the tab order and takes its
// explanation with it, which is the whole reason the control is shown at all.
expect(btn.getAttribute("aria-disabled")).toBe("true");
expect((btn as HTMLButtonElement).disabled).toBe(false);
expect(btn.getAttribute("title")).toBe("settings.invites.deleteHasAccount");
expect(screen.queryByLabelText(/deleteLabel/)).toBeNull();
});
it("keeps the same control on the previously-decided list, where the case is commonest", async () => {
// The affordance exists twice — the pending rows and the collapsed "decided" list — and an
// approved address that has since registered lands in the second one. Both must behave alike.
listMock.mockResolvedValue([invite({ status: "approved", has_account: true })]);
renderPanel();
await openAccessTab();
fireEvent.click(await screen.findByText(/settings\.invites\.decided/));
const btn = await screen.findByLabelText("settings.invites.deleteHasAccount");
expect(btn.getAttribute("aria-disabled")).toBe("true");
expect((btn as HTMLButtonElement).disabled).toBe(false);
});
it("offers a working delete on a decided entry with no account", async () => {
listMock.mockResolvedValue([invite({ status: "denied" })]);
renderPanel();
await openAccessTab();
fireEvent.click(await screen.findByText(/settings\.invites\.decided/));
fireEvent.click(await screen.findByLabelText("settings.invites.deleteLabel:asker@example.com"));
await waitFor(() => expect(deleteMock).toHaveBeenCalledWith(1));
});
it("warns in the confirmation — before the click is irreversible — when the env still allows it", async () => {
listMock.mockResolvedValue([invite({ still_allowed_by_env: true })]);
renderPanel();
await openAccessTab();
fireEvent.click(await screen.findByLabelText("settings.invites.deleteLabel:asker@example.com"));
expect(confirmMock).toHaveBeenCalledTimes(1);
expect(confirmMock.mock.calls[0]![0]).toMatchObject({
message: "settings.invites.deleteConfirmStillAllowed:asker@example.com",
danger: true,
});
});
it("uses the plain confirmation otherwise", async () => {
renderPanel();
await openAccessTab();
fireEvent.click(await screen.findByLabelText("settings.invites.deleteLabel:asker@example.com"));
expect(confirmMock.mock.calls[0]![0]).toMatchObject({
message: "settings.invites.deleteConfirm:asker@example.com",
});
});
it("says the entry kept its access when the server reports the env allow-list", async () => {
deleteMock.mockResolvedValue({ deleted: 1, still_allowed_by_env: true });
renderPanel();
await openAccessTab();
fireEvent.click(await screen.findByLabelText("settings.invites.deleteLabel:asker@example.com"));
await waitFor(() =>
expect(notifyMock).toHaveBeenCalledWith({
level: "warning",
message: "settings.invites.deletedStillAllowed",
}),
);
});
it("turns a 409 into the actionable reason rather than a generic failure", async () => {
// The stale-list case: someone registered on the address after the page loaded.
deleteMock.mockRejectedValue(new HttpError(409, "That address already has an account"));
renderPanel();
await openAccessTab();
fireEvent.click(await screen.findByLabelText("settings.invites.deleteLabel:asker@example.com"));
await waitFor(() =>
expect(notifyMock).toHaveBeenCalledWith({
level: "error",
message: "settings.invites.deleteHasAccount",
}),
);
});
it("shows the mail-is-off warning only while email is unconfigured", async () => {
renderPanel();
await openAccessTab();
expect(screen.queryByText("settings.invites.emailOff")).toBeNull();
cleanup();
meMock.email_configured = false;
renderPanel();
await openAccessTab();
expect(await screen.findByText("settings.invites.emailOff")).toBeTruthy();
});
});
// Whitelisting an address is opening a door as a possibility, not knocking on one — the operator has
// usually already agreed it with the person, and a row can sit approved for weeks. So adding stays
// silent and telling them is its own act, on its own button (E9.5 S6).
describe("inviting someone the admin whitelisted", () => {
// The decided rows live behind a <details>; await the summary rather than the section title, or
// the click races the invite query that populates it.
const open = async () => {
renderPanel();
fireEvent.click(await screen.findByText(/settings.invites.decided/));
};
it("offers a send button on an approved row", async () => {
listMock.mockResolvedValue([invite({ status: "approved" })]);
await open();
fireEvent.click(screen.getByLabelText("settings.invites.inviteLabel:asker@example.com"));
// Falls back to the admin's own UI language: nobody ever told us anything about this mailbox.
await waitFor(() => expect(inviteMock).toHaveBeenCalledWith(1, "en"));
expect(notifyMock).toHaveBeenCalledWith(
expect.objectContaining({ message: "settings.invites.invited:asker@example.com" }),
);
});
it("asks first, and writes to nobody when the admin backs out", async () => {
confirmMock.mockResolvedValue(false);
listMock.mockResolvedValue([invite({ status: "approved" })]);
await open();
fireEvent.click(screen.getByLabelText("settings.invites.inviteLabel:asker@example.com"));
await waitFor(() => expect(confirmMock).toHaveBeenCalledTimes(1));
expect(inviteMock).not.toHaveBeenCalled();
});
it("defaults to the language this person asked us in", async () => {
listMock.mockResolvedValue([invite({ status: "approved", lang: "hu" })]);
await open();
fireEvent.click(screen.getByLabelText("settings.invites.inviteLabel:asker@example.com"));
await waitFor(() => expect(inviteMock).toHaveBeenCalledWith(1, "hu"));
});
it("sends in the language picked in the dialog, not the default", async () => {
// The dialog is the only place this decision can be made — and the pick has to survive back to
// the caller, because the confirmation resolves to a plain boolean.
let release: (ok: boolean) => void = () => {};
confirmMock.mockImplementation(() => new Promise<boolean>((r) => (release = r)));
listMock.mockResolvedValue([invite({ status: "approved" })]);
await open();
fireEvent.click(screen.getByLabelText("settings.invites.inviteLabel:asker@example.com"));
await waitFor(() => expect(confirmMock).toHaveBeenCalled());
const dialog = render(<>{confirmMock.mock.calls[0]![0].message}</>);
fireEvent.click(dialog.getByText("Magyar"));
release(true);
await waitFor(() => expect(inviteMock).toHaveBeenCalledWith(1, "hu"));
});
it("warns in the dialog when this person has already been written to", async () => {
// The warning used to live only in a tooltip, which the admin may never hover. It belongs next
// to the button that would mail a real person a second time.
listMock.mockResolvedValue([
invite({ status: "approved", invited_at: "2026-09-05T09:00:00Z" }),
]);
await open();
fireEvent.click(screen.getByLabelText("settings.invites.inviteLabel:asker@example.com"));
await waitFor(() => expect(confirmMock).toHaveBeenCalled());
const dialog = render(<>{confirmMock.mock.calls[0]![0].message}</>);
expect(dialog.container.textContent).toContain("settings.invites.inviteConfirmAgain");
});
it("offers none on a denied row, which would contradict the decision", async () => {
listMock.mockResolvedValue([invite({ status: "denied" })]);
await open();
expect(screen.queryByLabelText("settings.invites.inviteLabel:asker@example.com")).toBeNull();
});
it("offers none once somebody is already behind that address", async () => {
// "The door is open, come in" is meaningless to someone already inside, and most of a mature
// whitelist is existing users. An account that exists but is stuck has its own row above.
listMock.mockResolvedValue([invite({ status: "approved", has_account: true })]);
await open();
expect(screen.queryByLabelText("settings.invites.inviteLabel:asker@example.com")).toBeNull();
});
it("says the mail was never sent when the instance has no SMTP", async () => {
listMock.mockResolvedValue([invite({ status: "approved" })]);
inviteMock.mockRejectedValue(
new HttpError(409, "ignored", { code: "invite_send", reason: "email_off" }),
);
await open();
fireEvent.click(screen.getByLabelText("settings.invites.inviteLabel:asker@example.com"));
// A button the admin pressed must not answer with a success toast for a mail nobody received.
await waitFor(() =>
expect(notifyMock).toHaveBeenCalledWith(
expect.objectContaining({ message: "settings.invites.inviteEmailOff" }),
),
);
});
});
@@ -0,0 +1,186 @@
// @vitest-environment jsdom
//
// The admin lifeline for a sign-up that is stuck outside (E9.5 S5, roadmap UX-25). Approve an address
// BEFORE the person registers and their sign-up produces no queue row at all, so the Access-requests
// screen was empty while they sat locked out and the only working move — re-typing the address into
// the whitelist box — was undiscoverable. These pin what the section shows and, just as importantly,
// what it does NOT show: the section is a discovery surface, and a person the queue above already
// covers must not appear twice.
//
// i18n is a key-passthrough so assertions read stable keys; the API and the notify singleton are
// stubbed so the panel can be driven without a server.
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
import { render, screen, cleanup, waitFor, fireEvent } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return {
...actual,
useTranslation: () => ({
t: (key: string, vars?: Record<string, unknown>) =>
vars?.email ? `${key}:${String(vars.email)}` : key,
i18n: { language: "en" },
}),
};
});
vi.mock("./ConfirmProvider", () => ({
useConfirm: () => vi.fn(async () => true),
ConfirmProvider: ({ children }: { children: React.ReactNode }) => children,
}));
const notifyMock = vi.fn();
vi.mock("../lib/notifications", async (importOriginal) => {
const actual = await importOriginal<typeof import("../lib/notifications")>();
return { ...actual, notify: (...a: unknown[]) => notifyMock(...a) };
});
const meMock = {
pending_invites: 0,
pending_activations: 0,
email_configured: true,
id: 1,
role: "admin",
};
vi.mock("../lib/useMe", () => ({ useMe: () => meMock }));
// The tab strip renders into an app-level toolbar slot that does not exist here; pin the tab.
vi.mock("./Tabs", () => ({
default: () => null,
usePersistedTab: () => ["access", () => {}],
}));
const pendingMock = vi.fn();
const activateMock = vi.fn();
const resendMock = vi.fn();
vi.mock("../lib/api", async (importOriginal) => {
const actual = await importOriginal<typeof import("../lib/api")>();
return {
...actual,
api: {
...actual.api,
adminInvites: () => Promise.resolve([]),
adminPendingAccess: () => pendingMock(),
activateUser: (id: number) => activateMock(id),
resendUserVerification: (id: number) => resendMock(id),
},
};
});
import AdminUsers from "./AdminUsers";
/** A row as /api/admin/pending-access serves it. */
const row = (over: Record<string, unknown> = {}) => ({
id: 7,
email: "later@example.com",
display_name: null,
role: "user",
is_active: false,
is_suspended: false,
email_verified: true,
is_demo: false,
has_password: true,
has_google: false,
created_at: "2026-09-01T10:00:00Z",
can_activate: true,
...over,
});
function mount() {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={qc}>
<AdminUsers />
</QueryClientProvider>,
);
}
beforeEach(() => {
pendingMock.mockReset();
activateMock.mockReset().mockResolvedValue({});
resendMock.mockReset().mockResolvedValue({ ok: true, email: "later@example.com" });
notifyMock.mockReset();
});
afterEach(cleanup);
describe("the stuck sign-ups section", () => {
it("offers Activate for a registrant whose address was already approved", async () => {
pendingMock.mockResolvedValue([row()]);
mount();
const button = await screen.findByText("users.pendingAccess.activate");
fireEvent.click(button);
await waitFor(() => expect(activateMock).toHaveBeenCalledWith(7));
expect(notifyMock).toHaveBeenCalledWith(
expect.objectContaining({ message: "users.pendingAccess.activated:later@example.com" }),
);
});
it("shows no Activate when the address is not allowed, since that would be a different decision", async () => {
// Activating an address with no approved invite makes an account that signs in with a password
// but is turned away by Google — the whitelist box is the honest move for that.
pendingMock.mockResolvedValue([row({ can_activate: false, email_verified: false })]);
mount();
await screen.findByText("users.pendingAccess.emailUnconfirmed");
expect(screen.queryByText("users.pendingAccess.activate")).toBeNull();
});
it("names which gate is shut, so an admin sees an unconfirmed address before letting it in", async () => {
pendingMock.mockResolvedValue([row({ email_verified: false })]);
mount();
// On a no-SMTP instance this is the difference between the owner and whoever registered the
// address first (roadmap SEC-15), so it belongs on the row, not in a tooltip.
expect(await screen.findByText("users.pendingAccess.emailUnconfirmed")).toBeTruthy();
expect(screen.getByText("users.pendingAccess.inactive")).toBeTruthy();
});
it("re-sends the confirmation link for an unconfirmed account", async () => {
pendingMock.mockResolvedValue([row({ email_verified: false })]);
mount();
fireEvent.click(await screen.findByLabelText("users.pendingAccess.resend"));
await waitFor(() => expect(resendMock).toHaveBeenCalledWith(7));
});
it("offers no resend once the address is confirmed", async () => {
pendingMock.mockResolvedValue([row()]);
mount();
await screen.findByText("users.pendingAccess.activate");
expect(screen.queryByLabelText("users.pendingAccess.resend")).toBeNull();
});
it("says the mail was never sent when the instance has no SMTP", async () => {
const { HttpError } = await import("../lib/api");
pendingMock.mockResolvedValue([row({ email_verified: false })]);
resendMock.mockRejectedValue(
new HttpError(409, "ignored", { code: "resend", reason: "email_off" }),
);
mount();
fireEvent.click(await screen.findByLabelText("users.pendingAccess.resend"));
// Mail is fail-soft everywhere else, which is what left the 0.78.5 sign-ups with nothing to fall
// back on. A button the admin pressed must not answer with a success toast.
await waitFor(() =>
expect(notifyMock).toHaveBeenCalledWith(
expect.objectContaining({ message: "users.pendingAccess.resendEmailOff" }),
),
);
});
it("renders nothing at all when nobody is stuck", async () => {
pendingMock.mockResolvedValue([]);
mount();
// The section withdraws itself rather than leaving an empty box to explain — so wait for it to
// GO, not merely for the request to have been made.
await waitFor(() => expect(screen.queryByText("users.pendingAccess.title")).toBeNull());
expect(pendingMock).toHaveBeenCalled();
});
});
@@ -0,0 +1,267 @@
// @vitest-environment jsdom
//
// The honest states of the sign-in card (E9.5 S4). Two things are under test and they share a root:
// a screen that does not name the address it is talking about hides an alias mismatch. On 2026-09-03
// a registration as `np.temu@gmail.com` met a Google claim of `nptemu@gmail.com` and every screen in
// the funnel stayed silent about it.
//
// entryUrl snapshots window.location at IMPORT time, so the access-notice cases reset the module
// registry and set the URL before importing — that is the behaviour under test, not a workaround.
import { describe, it, expect, afterEach, vi } from "vitest";
import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
import type { VerifyEntry } from "../lib/useVerifyEntry";
const noVerify: VerifyEntry = { outcome: null, email: null, dismiss: () => {} };
afterEach(() => {
cleanup();
vi.resetModules();
vi.restoreAllMocks();
window.history.replaceState(null, "", "/");
});
/** Mount the real card at a given entry URL, with i18n as a key-passthrough and /auth/* stubbed. */
async function mountCard(url: string, apiOverrides: Record<string, unknown> = {}) {
window.history.replaceState(null, "", url);
vi.resetModules();
vi.doMock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return {
...actual,
useTranslation: () => ({
t: (key: string, opts?: Record<string, unknown>) =>
opts && typeof opts.contact === "string" ? `${key}:${opts.contact}` : key,
i18n: { language: "en" },
}),
};
});
vi.doMock("../lib/api", async (importOriginal) => {
const actual = await importOriginal<typeof import("../lib/api")>();
return {
...actual,
api: {
...actual.api,
authConfig: () => Promise.resolve({ google_enabled: false }),
...apiOverrides,
},
};
});
const { HttpError } = await import("../lib/api");
const { default: Welcome } = await import("./Welcome");
await act(async () => {
render(<Welcome verify={noVerify} />);
});
return { HttpError, Welcome };
}
/** Fill the sign-in form and submit it. */
async function signIn(email = "someone@example.com") {
fireEvent.change(screen.getByPlaceholderText("welcome.auth.email"), { target: { value: email } });
fireEvent.change(screen.getByPlaceholderText("welcome.auth.password"), {
target: { value: "correct horse battery" },
});
await act(async () => {
fireEvent.click(screen.getByText("welcome.auth.signinButton"));
});
}
/** A password-login refusal carrying the server's structured detail. */
function refusal(
HttpError: typeof import("../lib/api").HttpError,
detail: Record<string, unknown>,
) {
return () => Promise.reject(new HttpError(403, "ignored", { code: "login", ...detail }));
}
describe("a refused sign-in becomes a named state", () => {
it("names the address the account is on, not the one that was typed", async () => {
const passwordLogin = vi.fn();
const { HttpError } = await mountCard("/", { passwordLogin });
passwordLogin.mockImplementation(
refusal(HttpError, {
reason: "pending_approval",
email: "np.temu@gmail.com",
email_verified: true,
approved: false,
}),
);
await signIn("nptemu@gmail.com");
expect(screen.getByText("welcome.auth.blocked.pending_approval.title")).toBeTruthy();
expect(screen.getByText("np.temu@gmail.com")).toBeTruthy();
// The password was already proven correct, so the form is not the next step and must be gone —
// leaving it up would only invite a retry that cannot succeed.
expect(screen.queryByPlaceholderText("welcome.auth.password")).toBeNull();
});
it("reports each gate from its own flag, so an approved-but-unverified account is told the truth", async () => {
const passwordLogin = vi.fn();
const { HttpError } = await mountCard("/", { passwordLogin });
passwordLogin.mockImplementation(
refusal(HttpError, {
reason: "email_unverified",
email: "approved.first@example.com",
email_verified: false,
approved: true,
}),
);
await signIn("approved.first@example.com");
// E9.5 S1 made the two gates order-independent; a screen that assumed a sequence would send this
// user off to wait for an admin who has already decided.
expect(screen.getByText("welcome.auth.blocked.gate.emailPending")).toBeTruthy();
expect(screen.getByText("welcome.auth.blocked.gate.approvalDone")).toBeTruthy();
expect(screen.getByText("welcome.auth.resendCta")).toBeTruthy();
});
it("shows no checklist for a suspension, which is not a gate that opens on its own", async () => {
const passwordLogin = vi.fn();
const { HttpError } = await mountCard("/", { passwordLogin });
passwordLogin.mockImplementation(
refusal(HttpError, {
reason: "suspended",
email: "banned@example.com",
operator: "ops@example.com",
}),
);
await signIn("banned@example.com");
expect(screen.getByText("welcome.auth.blocked.suspended.title")).toBeTruthy();
expect(screen.getByText("welcome.auth.blocked.operator:ops@example.com")).toBeTruthy();
expect(screen.queryByText("welcome.auth.blocked.gate.emailDone")).toBeNull();
});
it("leaves a wrong password as a line under the form, where the form IS the next step", async () => {
const passwordLogin = vi.fn();
const { HttpError } = await mountCard("/", { passwordLogin });
passwordLogin.mockImplementation(() =>
Promise.reject(new HttpError(401, "localized invalid", { code: "login", reason: "invalid" })),
);
await signIn();
expect(screen.getByText("localized invalid")).toBeTruthy();
expect(screen.getByPlaceholderText("welcome.auth.password")).toBeTruthy();
});
});
// Reported 2026-09-05: delete an account, then open its reset link. The card drew the full "set a new
// password" form purely because a `#reset=` fragment was present, and only told the user after they
// had typed one. Opening a dead reset link is ordinary — the token lives an hour, mail is slow, and
// whoever got two mails clicks the older one.
describe("an emailed reset link that is already finished", () => {
it("refuses to draw a form that cannot succeed", async () => {
await mountCard("/#reset=dead-token", {
checkPasswordReset: () => Promise.resolve({ ok: false }),
});
expect(screen.getByText("welcome.auth.deadLink.title")).toBeTruthy();
expect(screen.getByText("welcome.auth.deadLink.cta")).toBeTruthy();
expect(screen.queryByPlaceholderText("welcome.auth.newPassword")).toBeNull();
});
it("draws the form when the link is still good", async () => {
await mountCard("/#reset=live-token", {
checkPasswordReset: () => Promise.resolve({ ok: true }),
});
expect(screen.getByPlaceholderText("welcome.auth.newPassword")).toBeTruthy();
expect(screen.queryByText("welcome.auth.deadLink.title")).toBeNull();
});
it("takes the form down if the link dies between the check and the submit", async () => {
const confirmPasswordReset = vi.fn();
const { HttpError } = await mountCard("/#reset=racing-token", {
checkPasswordReset: () => Promise.resolve({ ok: true }),
confirmPasswordReset,
});
confirmPasswordReset.mockImplementation(() =>
Promise.reject(new HttpError(400, "ignored", { code: "reset", reason: "invalid" })),
);
fireEvent.change(screen.getByPlaceholderText("welcome.auth.newPassword"), {
target: { value: "brand new password" },
});
await act(async () => {
fireEvent.click(screen.getByText("welcome.auth.resetButton"));
});
expect(screen.getByText("welcome.auth.deadLink.title")).toBeTruthy();
});
it("keeps the form up for a too-short password, which costs the link nothing", async () => {
const confirmPasswordReset = vi.fn();
const { HttpError } = await mountCard("/#reset=live-token", {
checkPasswordReset: () => Promise.resolve({ ok: true }),
confirmPasswordReset,
});
confirmPasswordReset.mockImplementation(() =>
Promise.reject(
new HttpError(400, "too short", { code: "reset", reason: "weak_password", min: 10 }),
),
);
fireEvent.change(screen.getByPlaceholderText("welcome.auth.newPassword"), {
target: { value: "0123456789ab" },
});
await act(async () => {
fireEvent.click(screen.getByText("welcome.auth.resetButton"));
});
expect(screen.getByPlaceholderText("welcome.auth.newPassword")).toBeTruthy();
expect(screen.queryByText("welcome.auth.deadLink.title")).toBeNull();
});
it("probes a token once, however many times the card mounts", async () => {
const checkPasswordReset = vi.fn().mockResolvedValue({ ok: true });
const { Welcome } = await mountCard("/#reset=counted-token", { checkPasswordReset });
await act(async () => {
render(<Welcome verify={noVerify} />);
});
// StrictMode double-invokes effects and the shell remounts this card as the session probe
// settles; four probes per opened link would exhaust the endpoint's per-IP budget in two reloads
// and silently drop back to drawing the form unchecked.
expect(checkPasswordReset).toHaveBeenCalledTimes(1);
});
it("shows the form when the check itself fails, so a broken probe locks nobody out", async () => {
await mountCard("/#reset=live-token", {
checkPasswordReset: () => Promise.reject(new Error("network")),
});
expect(screen.getByPlaceholderText("welcome.auth.newPassword")).toBeTruthy();
});
});
describe("the access-request notice from a denied Google sign-in", () => {
it("names the address the request was recorded for", async () => {
await mountCard("/#access=requested&email=nptemu%40gmail.com");
expect(screen.getByText("welcome.auth.accessRequestedFor")).toBeTruthy();
expect(screen.getByText("nptemu@gmail.com")).toBeTruthy();
expect(screen.queryByText("welcome.auth.accessLinked")).toBeNull();
});
it("explains the alias when the request is already on file under another spelling", async () => {
await mountCard(
"/#access=requested&email=nptemu%40gmail.com&linked=np.temu%2Bsiftlode%40gmail.com",
);
expect(screen.getByText("welcome.auth.accessLinked")).toBeTruthy();
// %2B, not a bare `+`: URLSearchParams reads the latter as a space and would mangle exactly the
// address family this epic exists to protect.
expect(screen.getByText("np.temu+siftlode@gmail.com")).toBeTruthy();
});
it("falls back to the unnamed notice when the server sent no address", async () => {
await mountCard("/#access=requested");
expect(screen.getByText("welcome.auth.accessRequested")).toBeTruthy();
expect(screen.queryByText("welcome.auth.accessRequestedFor")).toBeNull();
});
});
@@ -0,0 +1,118 @@
// @vitest-environment jsdom
//
// Library-row metadata (E7·S1): the completed row surfaces the concrete container/format alongside the
// already-shown size. The format chip reads the raw `asset.container` (uppercased in the UI), so it must
// render when present and be absent when the asset has no container yet (queued/running before the worker
// fills it). i18n is key-passthrough here.
import { describe, it, expect, afterEach, vi } from "vitest";
import { render, screen, cleanup } from "@testing-library/react";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
});
import { DownloadRow } from "./downloadRow";
import type { DownloadJob } from "../lib/api";
const baseJob: DownloadJob = {
id: 1,
status: "done",
progress: 100,
speed_bps: null,
eta_s: null,
phase: null,
error: null,
error_code: null,
queue_pos: 0,
created_at: null,
source_kind: "youtube",
source_ref: "abc",
source_url: null,
poster_url: null,
profile_id: null,
spec: {
mode: "av",
max_height: null,
container: null,
audio_format: null,
vcodec: null,
embed_subs: false,
embed_chapters: false,
embed_thumbnail: false,
sponsorblock: false,
},
display_name: "Clip",
display_uploader: null,
display_uploader_url: null,
extra_links: [],
can_download: true,
expired: false,
asset: null,
};
const withAsset = (
container: string | null,
over: Partial<NonNullable<DownloadJob["asset"]>> = {},
): DownloadJob => ({
...baseJob,
asset: {
id: 10,
status: "ready",
title: "Clip",
uploader: null,
uploader_url: null,
upload_date: null,
duration_s: 90,
size_bytes: 148_800_000,
width: 1920,
height: 1080,
container,
media_kind: "video",
thumbnail_url: null,
expires_at: null,
...over,
},
});
afterEach(cleanup);
describe("DownloadRow format chip", () => {
it("shows the uppercased container next to the size when ready", () => {
render(<DownloadRow job={withAsset("mp4")} />);
// Raw container is stored lowercase; the row uppercases it via CSS, so the text node stays "mp4".
expect(screen.getByText(/·\s*mp4/i)).toBeTruthy();
expect(screen.getByText(/142 MB/)).toBeTruthy();
});
it("omits the format chip when the asset has no container yet", () => {
const { container } = render(<DownloadRow job={withAsset(null)} />);
expect(container.textContent).not.toMatch(/·\s*(mp4|m4a|webm)/i);
});
});
// E7·S5b — a generic uploaded file (media_kind='file') has no video frame and no timeline: the row
// shows a neutral file glyph instead of a thumbnail <img>, and no duration chip (it stays media info
// like the container/size). Media rows keep their thumbnail path (an <img>, or empty until a poster).
describe("DownloadRow file kind", () => {
it("renders a file glyph, not a thumbnail <img>, for a generic file", () => {
const { container } = render(
<DownloadRow
job={withAsset("pdf", { media_kind: "file", duration_s: null, size_bytes: 2_400_000 })}
/>,
);
// The file glyph is an inline svg; no <img> element is emitted for a file.
expect(container.querySelector("img")).toBeNull();
expect(container.querySelector("svg")).not.toBeNull();
// File info still reads (container + size), but there is no duration chip.
expect(screen.getByText(/·\s*pdf/i)).toBeTruthy();
expect(screen.getByText(/2\.3 MB/)).toBeTruthy();
});
it("keeps the thumbnail path (an <img> when a poster exists) for a media asset", () => {
const { container } = render(
<DownloadRow job={{ ...withAsset("mp4"), poster_url: "/api/downloads/1/poster.jpg" }} />,
);
expect(container.querySelector("img")).not.toBeNull();
});
});

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