From 94f9ffdb1c97c02b407c391be442f4a6a5ae7a25 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 14 Nov 2026 14:43:04 +0100 Subject: [PATCH 01/13] fix(auth): a session in use slides instead of expiring on day 14 Starlette re-signs the session cookie only when the session changes, so a browser in daily use was signed out 14 days after sign-in, mid-episode. The first authenticated request a day after the last renewal now writes a fresh stamp, which re-signs the cookie; only 14 idle days end a session. The epoch stays the revoke. --- backend/app/auth.py | 20 ++++++++++++ backend/tests/test_auth_session.py | 50 ++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+) diff --git a/backend/app/auth.py b/backend/app/auth.py index c21de036..4ebebad6 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -1,3 +1,5 @@ +from datetime import datetime, timezone + import httpx from fastapi import BackgroundTasks, Depends, HTTPException, Request from sqlalchemy import delete, func, select @@ -261,6 +263,23 @@ def stamp_session(request: Request, user: User) -> None: """Record the account's current `session_epoch` in the signed cookie, so `current_user` can later reject the cookie once the epoch moves (password reset/change, or "log out everywhere").""" request.session["epoch"] = user.session_epoch or 0 + request.session["renewed"] = int(datetime.now(timezone.utc).timestamp()) + + +# The signed cookie lives 14 days (`SessionMiddleware`'s max_age, checked again on every read), and +# Starlette re-signs it only when the session changes — a signed-in browser in daily use was signed +# out on day 14, mid-episode. So the session is a sliding one: the first authenticated request a day +# after the last renewal writes a fresh stamp, which re-signs the cookie for another 14 days. Only 14 +# idle days end it; "log out everywhere" (the epoch) stays the way to revoke it. Once a day, not on +# every request: a Set-Cookie on each response would race between parallel requests for nothing. +_RENEW_AFTER_SECONDS = 86400 + + +def _renew_session(request: Request) -> None: + now = int(datetime.now(timezone.utc).timestamp()) + renewed = request.session.get("renewed") + if not isinstance(renewed, int) or now - renewed >= _RENEW_AFTER_SECONDS: + request.session["renewed"] = now # The account a tab was built for, sent with every request it makes (`identityHeaders`, the @@ -308,6 +327,7 @@ def _session_user(request: Request, db: Session, user_id: int | None) -> User: if epoch is None or int(epoch) != (user.session_epoch or 0): request.session.clear() raise refused(401, "not_authenticated") + _renew_session(request) # Attribute this request's log lines to the account. Here, not in the request middleware: # this is the one place a session becomes an account, and the middleware runs outside both the # session middleware and this task's context. `request.state` is shared across middleware layers diff --git a/backend/tests/test_auth_session.py b/backend/tests/test_auth_session.py index 5053ecbc..4dc63654 100644 --- a/backend/tests/test_auth_session.py +++ b/backend/tests/test_auth_session.py @@ -122,6 +122,56 @@ def test_deleting_the_account_signs_the_browser_out(client, make_user): assert "user_id" not in _session(client) +_DAY = 86400 +_PROTECTED = "/api/me/notifications" # any route behind `current_user` that answers 200 offline + + +def _signed_at(monkeypatch, at: int, payload: dict) -> str: + """A session cookie carrying `payload`, signed as if at the unix time `at`.""" + with monkeypatch.context() as m: # not `undo()`: that would also undo the fixtures' patches + m.setattr(TimestampSigner, "get_timestamp", lambda self: at) + return _cookie(payload) + + +def _hold(client, cookie: str) -> None: + """Make `cookie` the browser's only session cookie (a plain `set` would sit beside the one the + server set, under another domain).""" + client.cookies.clear() + client.cookies.set("session", cookie) + + +def test_a_session_in_use_does_not_expire_after_the_cookie_lifetime(client, make_user, monkeypatch): + # The cookie's signature is checked against a 14-day life, and Starlette re-signs it only when + # the session changes. A browser in daily use must still stay signed in: a session that has not + # been renewed for a day is renewed by the next authenticated request. + make_user(email="slide@example.com", password=_PW) + assert _login(client, "slide@example.com", _PW).status_code == 200 + now = int(TimestampSigner(str(settings.secret_key)).get_timestamp()) + payload = {**_session(client), "renewed": now - 2 * _DAY} + old = _signed_at(monkeypatch, now - 13 * _DAY, payload) + + _hold(client, old) + r = client.get(_PROTECTED) + assert r.status_code == 200 + renewed = r.cookies.get("session") + assert renewed and renewed != old # the request re-signed the cookie + + # Two days on, the cookie signed 13 days ago is past its 14 days; the renewed one is not. + monkeypatch.setattr(TimestampSigner, "get_timestamp", lambda self: now + 2 * _DAY) + _hold(client, old) + assert client.get(_PROTECTED).status_code == 401 + _hold(client, renewed) + assert client.get(_PROTECTED).status_code == 200 + + +def test_a_session_renewed_today_is_not_re_signed_on_every_request(client, make_user): + make_user(email="slide2@example.com", password=_PW) + assert _login(client, "slide2@example.com", _PW).status_code == 200 + r = client.get(_PROTECTED) + assert r.status_code == 200 + assert "set-cookie" not in r.headers + + def _session(client) -> dict: """The decoded payload of the client's session cookie.""" raw = client.cookies.get("session") From 1dff80926828a2d90f8e20e0f4ffbb2b74a1ff5a Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 14 Nov 2026 15:08:50 +0100 Subject: [PATCH 02/13] feat(filters): pick one year from a decade grid, in the feed and in Plex A shared YearPicker: the recent years as chips with their counts, and a More dialog that lays every year out by decade (sortable by count), so a ninety-year library fits on one screen. The feed's scrolling popover gives way to it; the Plex rail gets it above its range fields, a pick setting both ends. The Plex facets now count titles per year, the year filter left out. --- backend/app/plex/library.py | 25 +-- backend/tests/test_plex_service.py | 22 +++ frontend/src/components/ui/README.md | 14 +- .../src/components/ui/YearPicker.dom.test.tsx | 83 +++++++++ frontend/src/components/ui/YearPicker.tsx | 160 ++++++++++++++++++ frontend/src/i18n/locales/en/sidebar.json | 1 + frontend/src/i18n/locales/hu/sidebar.json | 1 + frontend/src/lib/api/plex.ts | 2 + frontend/src/modules/feed/YearFilter.tsx | 151 +++-------------- frontend/src/modules/plex/PlexSidebar.tsx | 40 +++-- 10 files changed, 338 insertions(+), 161 deletions(-) create mode 100644 frontend/src/components/ui/YearPicker.dom.test.tsx create mode 100644 frontend/src/components/ui/YearPicker.tsx diff --git a/backend/app/plex/library.py b/backend/app/plex/library.py index b767535a..a78d17fd 100644 --- a/backend/app/plex/library.py +++ b/backend/app/plex/library.py @@ -242,6 +242,7 @@ def facets(db: Session, user: User, *, scope: str, show: str, lang: str, f: Libr "content_ratings": [], "studios": [], "playable": [], + "years": [], "year_min": None, "year_max": None, "rating_max": None, @@ -302,7 +303,7 @@ def facets(db: Session, user: User, *, scope: str, show: str, lang: str, f: Libr cr_counts: dict[str, int] = {} studio_counts: dict[str, int] = {} playable_counts: dict[str, int] = {} - years: list[int] = [] + year_counts: dict[int, int] = {} ratings: list[float] = [] durs: list[int] = [] @@ -383,21 +384,22 @@ def facets(db: Session, user: User, *, scope: str, show: str, lang: str, f: Libr ) 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( + # Titles per year — exclude the year filter, so a picked year never empties the list it was + # picked from. The year bounds are read from the same counts. + yq = ( wsq( - select(func.min(model.year), func.max(model.year)), + select(model.year, func.count()), model, ids, {**p, "year_min": None, "year_max": None}, kind_movie, True, ) - ).one() - if by[0] is not None: - years.append(by[0]) - if by[1] is not None: - years.append(by[1]) + .where(model.year.isnot(None)) + .group_by(model.year) + ) + for y, c in db.execute(yq).all(): + year_counts[y] = year_counts.get(y, 0) + c # Rating max — exclude the rating filter. Coalesce(imdb, audience) to match the score the # rating filter and the UI badge use (IMDb preferred, audience fallback). br = db.scalar( @@ -465,8 +467,9 @@ def facets(db: Session, user: User, *, scope: str, show: str, lang: str, f: Libr 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, + "years": [{"year": y, "count": c} for y, c in sorted(year_counts.items(), reverse=True)], + "year_min": min(year_counts) if year_counts else None, + "year_max": max(year_counts) if year_counts else None, "rating_max": max(ratings) if ratings else None, "duration_min": min(durs) if durs else None, "duration_max": max(durs) if durs else None, diff --git a/backend/tests/test_plex_service.py b/backend/tests/test_plex_service.py index 181e0cc3..1922a60c 100644 --- a/backend/tests/test_plex_service.py +++ b/backend/tests/test_plex_service.py @@ -105,6 +105,28 @@ def test_the_facets_count_what_the_caller_would_see(db, make_user): assert (theirs["year_min"], mine["year_min"]) == (2001, 1990) +def test_the_facets_count_titles_per_year_without_the_year_filter(db, make_user): + # The year picker lists every year with its count. Like each facet it drops its own filter (a + # picked year must not empty the list it was picked from) and keeps the others; movies and shows + # of one year add up, newest first. + me = make_user() + movies, shows = _lib(db), _lib(db, "2", kind="show") + _movie(db, movies, "a", genres=["Drama"], year=2001) + _movie(db, movies, "b", genres=["Drama"], year=2001) + _movie(db, movies, "c", genres=["Drama"], year=1990) + _movie(db, movies, "d", genres=["Horror"], year=1985) + _movie(db, movies, "e", genres=["Drama"]) # no year: not a year to pick + db.add(PlexShow(rating_key="s1", library_id=shows.id, title="S", genres=["Drama"], year=1990)) + db.commit() + + f = LibraryFilters(genres="Drama", year_min=2001, year_max=2001) + got = library.facets(db, me, scope="both", show="all", lang="en", f=f) + assert got["years"] == [{"year": 2001, "count": 2}, {"year": 1990, "count": 2}] + assert (got["year_min"], got["year_max"]) == (1990, 2001) + movie_only = library.facets(db, me, scope="movie", show="all", lang="en", f=f) + assert movie_only["years"] == [{"year": 2001, "count": 2}, {"year": 1990, "count": 1}] + + def test_an_episode_search_joins_the_callers_state(db, make_user): me, other = make_user(), make_user() lib = _lib(db, "2", kind="show") diff --git a/frontend/src/components/ui/README.md b/frontend/src/components/ui/README.md index a39e3893..fc96ed83 100644 --- a/frontend/src/components/ui/README.md +++ b/frontend/src/components/ui/README.md @@ -35,13 +35,13 @@ Counter-examples, deliberately NOT here: `PosterTile` (knows about Plex montage ## What lives here -| area | components | -| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| overlays & anchoring | `AnchoredPopover` + `anchoredPopoverHooks` (the popover engine: arrows, typeahead, Escape, anchor-flip), `ActionMenu`, `MembershipMenu` (the body of a "which sets is it in" menu: checkbox rows + a field naming a new set), `ContextMenu` + `useContextMenu` (the SAME menu, opened by right-click / long-press / Shift+F10 / a row's `MoreButton` "…" via `buttonProps`; `anchor` for a panel a row opens; `PageMenu` for a page's background menu), `Modal` (+ `ModalFooter`; `initialFocus` by purpose, C4), `Overlay`, `SidePanel`, `Tooltip`, `Toaster` (+ `toastLevels`, `overlayOrder`; `lib/toastDock` docks one control under the stack, in every host), `CommandPalette`, `KeyboardHelp`, `ConfirmProvider` + `confirmContext` (`useConfirm` / `useChoice`: the app's promise-based confirm, mounted once at the root; a decision with more than two outcomes is one `useChoice`, never two chained confirms), `dialogFocus` (the focus half of the dialog contract: `Modal`'s, and the wizard's own shell), `menuGroups` (one menu from its groups, a rule only between non-empty ones), `pageMenuSlot` + `pageMenuItems` (a page's background menu, mirroring its own toolbar) | -| actions & inputs | `Button`, `IconButton`, `Pressable` (`selectable` when its text is a title or a name), `Chip` + `chipStyles`, `RowSelectBox` (a table row's selecting box, Shift says a range), `DragHandle` + `OrderButtons` / `useOrderItems` (the non-drag reorder, WCAG 2.5.7) (one per button family, docs/DESIGN.md; a raw ` - ))} - - -
- {sorted.map((y) => { - const on = selected === y.year; - return ( - { - pick(on ? undefined : y.year); - setOpen(false); - }} - label={String(y.year)} - className={`flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm transition ${ - on ? "text-fg" : "text-muted" - }`} - > - - {y.year} - {y.count} - - ); - })} -
- - - )} - + + setFilters({ + ...filters, + year, + maxAgeDays: undefined, + dateFrom: undefined, + dateTo: undefined, + }) + } + /> ); } diff --git a/frontend/src/modules/plex/PlexSidebar.tsx b/frontend/src/modules/plex/PlexSidebar.tsx index 80ead6cd..12737e4e 100644 --- a/frontend/src/modules/plex/PlexSidebar.tsx +++ b/frontend/src/modules/plex/PlexSidebar.tsx @@ -21,6 +21,7 @@ import { usePlexSavedViews } from "./plexSavedViews"; import { usePlex, usePlexActions } from "./plexContext"; import { DismissibleChip } from "../../components/ui/Chip"; import { Input } from "../../components/ui/Input"; +import YearPicker from "../../components/ui/YearPicker"; import { useRail } from "../../lib/useRail"; import { msg } from "../../lib/notifications"; @@ -173,18 +174,33 @@ export default function PlexSidebar() { id: "year", title: t("plex.filter.year"), body: ( -
- patch({ yearMin: v })} - /> - – - patch({ yearMax: v })} - /> +
+ {/* One year is a range of one: picking it sets both ends, as the year on an info page does, + so the range fields below show it and a decade stays typeable there. */} + {facets && facets.years.length > 0 && ( + patch({ yearMin: year ?? null, yearMax: year ?? null })} + /> + )} +
+ patch({ yearMin: v })} + /> + – + patch({ yearMax: v })} + /> +
), }, From d265c84c8bfbdfe680ef6d71794192964752b69e Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 14 Nov 2026 15:23:16 +0100 Subject: [PATCH 03/13] fix(filters): the year dialog's sort caption keeps its distance from the toggle --- frontend/src/components/ui/YearPicker.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/ui/YearPicker.tsx b/frontend/src/components/ui/YearPicker.tsx index 962953d7..a66b393d 100644 --- a/frontend/src/components/ui/YearPicker.tsx +++ b/frontend/src/components/ui/YearPicker.tsx @@ -92,11 +92,12 @@ export default function YearPicker({ onClose={() => setOpen(false)} maxWidth="max-w-4xl" > -
+
+ Date: Sat, 14 Nov 2026 17:56:36 +0100 Subject: [PATCH 04/13] fix(auth): a cookie that lands after sign-out no longer signs the browser back in A renewal answers with the session its request started with. Each session now carries an id; signing out, or signing in as someone else, revokes it (revoked_sessions, migration 0120), and a cookie carrying a revoked id is refused. A cookie from before session ids gets one drawn from the cookie itself. The race after log-out-everywhere fails closed and stays. --- .../alembic/versions/0120_revoked_sessions.py | 33 +++++++++++ backend/app/auth.py | 49 ++++++++++++++- backend/app/main.py | 2 + backend/app/models.py | 12 ++++ backend/app/routes/auth.py | 16 +++-- backend/tests/test_auth_session.py | 59 ++++++++++++++++++- backend/tests/test_unit_fences.py | 1 + 7 files changed, 164 insertions(+), 8 deletions(-) create mode 100644 backend/alembic/versions/0120_revoked_sessions.py diff --git a/backend/alembic/versions/0120_revoked_sessions.py b/backend/alembic/versions/0120_revoked_sessions.py new file mode 100644 index 00000000..e077e2f8 --- /dev/null +++ b/backend/alembic/versions/0120_revoked_sessions.py @@ -0,0 +1,33 @@ +"""revoked_sessions: a signed-out browser session's id, so a cookie that lands after the sign-out is refused + +Revision ID: 0120_revoked_sessions +Revises: 0119_plex_transcode_gpu +Create Date: 2026-11-14 + +The session slides: a request renews its cookie once a day. A request still in flight when the browser +signs out answers with the cookie it started with, and that cookie, landing after the sign-out cleared +it, signed the browser back in. Each session now carries an id (`sid`); signing out, or signing in as +someone else, stores the old id here, and `auth._session_user` refuses a cookie that carries one. +""" + +import sqlalchemy as sa +from alembic import op + +revision: str = "0120_revoked_sessions" +down_revision: str | None = "0119_plex_transcode_gpu" +branch_labels: str | None = None +depends_on: str | None = None + + +def upgrade() -> None: + op.create_table( + "revoked_sessions", + sa.Column("sid", sa.String(32), primary_key=True), + sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=False), + ) + op.create_index("ix_revoked_sessions_revoked_at", "revoked_sessions", ["revoked_at"]) + + +def downgrade() -> None: + op.drop_index("ix_revoked_sessions_revoked_at", table_name="revoked_sessions") + op.drop_table("revoked_sessions") diff --git a/backend/app/auth.py b/backend/app/auth.py index 4ebebad6..baf01969 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -1,8 +1,11 @@ -from datetime import datetime, timezone +import hashlib +import secrets +from datetime import datetime, timedelta, timezone import httpx from fastapi import BackgroundTasks, Depends, HTTPException, Request from sqlalchemy import delete, func, select +from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.orm import Session from starlette.requests import HTTPConnection @@ -14,7 +17,7 @@ from app import mailtext from app import sysconfig from app.config import settings from app.db import get_db -from app.models import Invite, LogEvent, User +from app.models import Invite, LogEvent, RevokedSession, User from app.security import decrypt_optional from app.utils import canonical_email from app.refusal import refused @@ -263,6 +266,7 @@ def stamp_session(request: Request, user: User) -> None: """Record the account's current `session_epoch` in the signed cookie, so `current_user` can later reject the cookie once the epoch moves (password reset/change, or "log out everywhere").""" request.session["epoch"] = user.session_epoch or 0 + request.session.setdefault("sid", new_session_id()) request.session["renewed"] = int(datetime.now(timezone.utc).timestamp()) @@ -272,6 +276,13 @@ def stamp_session(request: Request, user: User) -> None: # after the last renewal writes a fresh stamp, which re-signs the cookie for another 14 days. Only 14 # idle days end it; "log out everywhere" (the epoch) stays the way to revoke it. Once a day, not on # every request: a Set-Cookie on each response would race between parallel requests for nothing. +# +# A renewal answers with the session its request STARTED with. Landing after a sign-out, that cookie +# would sign the browser back in, so each session carries an id (`sid`) and a sign-out revokes it +# (`revoke_session`). Landing after "log out everywhere" or a password change re-stamped this +# browser's cookie, it carries the old epoch and signs this browser out: that race fails closed (sign +# in again; nothing is reached), and is left so. +SESSION_MAX_AGE = 14 * 86400 # the cookie's life; `main` hands it to SessionMiddleware _RENEW_AFTER_SECONDS = 86400 @@ -282,6 +293,36 @@ def _renew_session(request: Request) -> None: request.session["renewed"] = now +def new_session_id() -> str: + return secrets.token_urlsafe(16) + + +def _session_id(request: Request) -> str: + """This session's id. A cookie from before session ids gets one drawn from the cookie itself, so + parallel requests carrying it agree on the id a sign-out revokes, and two browsers do not.""" + sid = request.session.get("sid") + if isinstance(sid, str): + return sid + raw = request.cookies.get("session", "") + sid = hashlib.sha256(raw.encode()).hexdigest()[:22] + request.session["sid"] = sid + return sid + + +def revoke_session(db: Session, request: Request) -> None: + """Revoke this browser's session id, so a cookie carrying it that lands later is refused. Not + committed — the caller commits. A row is dropped once no cookie with its id can still be valid: + a refused cookie is never renewed, so it dies `SESSION_MAX_AGE` after it was signed, at most a + request's time after the revoke (a day of margin).""" + sid = request.session.get("sid") + if not isinstance(sid, str): + return + now = datetime.now(timezone.utc) + cutoff = now - timedelta(seconds=SESSION_MAX_AGE + 86400) + db.execute(delete(RevokedSession).where(RevokedSession.revoked_at < cutoff)) + db.execute(pg_insert(RevokedSession).values(sid=sid, revoked_at=now).on_conflict_do_nothing()) + + # The account a tab was built for, sent with every request it makes (`identityHeaders`, the # frontend). The cookie alone says who is asking, and one browser holds one account: a sign-in as # someone else in another tab replaces it under every open tab. A tab still showing the previous @@ -327,6 +368,10 @@ def _session_user(request: Request, db: Session, user_id: int | None) -> User: if epoch is None or int(epoch) != (user.session_epoch or 0): request.session.clear() raise refused(401, "not_authenticated") + # A session signed out (or replaced by a sign-in as someone else) whose cookie landed late. + if db.get(RevokedSession, _session_id(request)) is not None: + request.session.clear() + raise refused(401, "not_authenticated") _renew_session(request) # Attribute this request's log lines to the account. Here, not in the request middleware: # this is the one place a session becomes an account, and the middleware runs outside both the diff --git a/backend/app/main.py b/backend/app/main.py index a84431fd..18115276 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -26,6 +26,7 @@ configure_logging("api") log = logs.get_logger(__name__) from app import bodylimit, collation, logsink, state, sysconfig # noqa: E402 — logging is configured first +from app import auth as auth_core # noqa: E402 — logging is configured first from app.config import settings # noqa: E402 — logging is configured first from app.db import SessionLocal, engine # noqa: E402 — logging is configured first import app.tables # noqa: E402 — every table, before the first query @@ -221,6 +222,7 @@ async def _nul_in_text(request: Request, exc: DataError) -> JSONResponse: app.add_middleware( SessionMiddleware, secret_key=settings.secret_key, + max_age=auth_core.SESSION_MAX_AGE, # the revoked-session rows outlive it (`auth.revoke_session`) same_site="lax", # required so the cookie rides the OAuth redirect back from Google https_only=settings.session_https_only, # Secure flag when served over HTTPS (prod) ) diff --git a/backend/app/models.py b/backend/app/models.py index 2eeee5ba..56d5f1be 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -190,6 +190,18 @@ class AuthToken(Base, TimestampMixin): used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) +class RevokedSession(Base): + """The id of a browser session that signed out, or that a sign-in as someone else replaced + (`auth.revoke_session`). A request still in flight then can answer with that session's cookie, + re-signed by its renewal, after the sign-out cleared it; a cookie whose id is here is refused. A + row outlives every cookie that could carry its id, then `revoke_session` drops it.""" + + __tablename__ = "revoked_sessions" + + sid: Mapped[str] = mapped_column(String(32), primary_key=True) + revoked_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True) + + 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).""" diff --git a/backend/app/routes/auth.py b/backend/app/routes/auth.py index b90791dd..b440af61 100644 --- a/backend/app/routes/auth.py +++ b/backend/app/routes/auth.py @@ -40,6 +40,7 @@ from app.auth import ( log, operator_contact, optional_current_user, + revoke_session, session_user_id, stamp_session, ) @@ -531,7 +532,7 @@ async def callback( db.flush() _store_token(db, user, token) - _establish_session(request, user) + _establish_session(db, request, user) db.commit() log.info( "signed in", @@ -641,7 +642,7 @@ def _complete_link( @router.post("/logout") -async def logout(request: Request): +def logout(request: Request, db: Session = Depends(get_db)): """Sign this browser out: the session holds one account, and it ends — unless the tab asking was built for another account than the one signed in now (a sign-in as someone else in another tab): its Sign out would end THAT person's session (see `EXPECTED_ACCOUNT_HEADER`).""" @@ -650,14 +651,19 @@ async def logout(request: Request): signed_in, request.headers.get(EXPECTED_ACCOUNT_HEADER) ): raise refused(401, "account_changed") + revoke_session(db, request) # a request still in flight must not sign it back in + db.commit() request.session.clear() return JSONResponse({"ok": True}) -def _establish_session(request: Request, user: User) -> None: +def _establish_session(db: Session, request: Request, user: User) -> None: """Sign `user` in for this browser session — the ONE sign-in path, shared by the Google callback and password login, so both stamp `last_login_at` (the caller commits). One account per session: - the sign-in replaces whoever was signed in here before.""" + the sign-in replaces whoever was signed in here before, and revokes that session's id, so a request + of theirs still in flight cannot land it back.""" + revoke_session(db, request) + request.session.pop("sid", None) request.session["user_id"] = user.id stamp_session(request, user) user.last_login_at = datetime.now(timezone.utc) @@ -1040,7 +1046,7 @@ def password_login( raise _login_refused(403, "email_unverified", user) if not user.is_active: raise _login_refused(403, "pending_approval", user) - _establish_session(request, user) + _establish_session(db, request, user) db.commit() log.info( "signed in", diff --git a/backend/tests/test_auth_session.py b/backend/tests/test_auth_session.py index 4dc63654..b33e5f8f 100644 --- a/backend/tests/test_auth_session.py +++ b/backend/tests/test_auth_session.py @@ -172,9 +172,66 @@ def test_a_session_renewed_today_is_not_re_signed_on_every_request(client, make_ assert "set-cookie" not in r.headers +def test_a_session_with_no_renewal_stamp_is_renewed(client, make_user, monkeypatch): + # A cookie signed before the session slid carries no `renewed`: its first request stamps one. + make_user(email="slide3@example.com", password=_PW) + assert _login(client, "slide3@example.com", _PW).status_code == 200 + payload = {k: v for k, v in _session(client).items() if k != "renewed"} + now = int(TimestampSigner(str(settings.secret_key)).get_timestamp()) + _hold(client, _signed_at(monkeypatch, now - 13 * _DAY, payload)) + r = client.get(_PROTECTED) + assert r.status_code == 200 + assert isinstance(_decode(r.cookies.get("session")).get("renewed"), int) + + +# A request still in flight when the browser signs out (or signs in as someone else) answers with the +# session it started with — re-signed, if it renewed it — and that late cookie lands after the one the +# sign-out cleared. Holding it must not sign the browser back in. +def test_a_cookie_that_lands_after_sign_out_is_refused(client, make_user): + make_user(email="late1@example.com", password=_PW) + assert _login(client, "late1@example.com", _PW).status_code == 200 + in_flight = client.cookies.get("session") + assert client.post("/auth/logout").status_code == 200 + _hold(client, in_flight) + assert client.get(_PROTECTED).status_code == 401 + assert client.get("/api/me").json() == {"authenticated": False} + + +def test_a_cookie_from_before_a_sign_in_as_another_account_is_refused(client, make_user): + make_user(email="late2a@example.com", password=_PW) + make_user(email="late2b@example.com", password=_PW) + assert _login(client, "late2a@example.com", _PW).status_code == 200 + in_flight = client.cookies.get("session") + assert _login(client, "late2b@example.com", _PW).status_code == 200 + bob = client.cookies.get("session") + _hold(client, in_flight) + assert client.get(_PROTECTED).status_code == 401 + _hold(client, bob) # Bob's own session is untouched + assert client.get("/api/me").json()["email"] == "late2b@example.com" + + +def test_a_session_from_before_session_ids_gets_one_and_its_sign_out_holds(client, make_user): + make_user(email="late3@example.com", password=_PW) + assert _login(client, "late3@example.com", _PW).status_code == 200 + old = _cookie({k: v for k, v in _session(client).items() if k != "sid"}) + _hold(client, old) + r = client.get(_PROTECTED) + assert r.status_code == 200 + in_flight = r.cookies.get("session") + assert _decode(in_flight).get("sid") + _hold(client, in_flight) + assert client.post("/auth/logout").status_code == 200 + _hold(client, in_flight) + assert client.get(_PROTECTED).status_code == 401 + + def _session(client) -> dict: """The decoded payload of the client's session cookie.""" - raw = client.cookies.get("session") + return _decode(client.cookies.get("session")) + + +def _decode(raw: str | None) -> dict: + """The payload of a session cookie's value.""" if not raw: return {} data = TimestampSigner(str(settings.secret_key)).unsign(raw.encode()) diff --git a/backend/tests/test_unit_fences.py b/backend/tests/test_unit_fences.py index 985df0d2..bbe57f02 100644 --- a/backend/tests/test_unit_fences.py +++ b/backend/tests/test_unit_fences.py @@ -75,6 +75,7 @@ CORE_MODELS = { "User", "OAuthToken", "AuthToken", + "RevokedSession", "Invite", "ApiQuotaUsage", "QuotaEvent", From 0eca18bcb47940e6316a6c8a4c1fb5671351cdda Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 14 Nov 2026 17:56:37 +0100 Subject: [PATCH 05/13] fix(filters): the year dialog fits a tablet, walks by arrow keys, names one year once - a decade cell is a 36-high box with its count under the year (owner A) - arrow keys walk the cells: along a decade, then to the nearest year below - clearing the kept older year hands the focus on instead of dropping it - More... shows while a year with titles is hidden, as a plain chip - the decade caption is not read twice; the Plex range fields have names - one Plex year is labelled 1999, not 1999-1999, via oneYear/singleYear - tests for the Plex wiring and the feed year clearing the upload date --- docs/DESIGN.md | 4 + frontend/src/components/ui/README.md | 2 +- .../src/components/ui/YearPicker.dom.test.tsx | 59 +++++++- frontend/src/components/ui/YearPicker.tsx | 127 +++++++++++------- frontend/src/components/ui/chipStyles.ts | 13 +- .../ui/tailwindScan.contract.test.ts | 2 +- frontend/src/i18n/locales/en/plex.json | 2 + frontend/src/i18n/locales/en/sidebar.json | 2 +- frontend/src/i18n/locales/hu/plex.json | 2 + frontend/src/i18n/locales/hu/sidebar.json | 2 +- frontend/src/lib/api.ts | 2 + frontend/src/lib/api/plex.ts | 11 ++ .../src/lib/plexActiveFilters.dom.test.ts | 26 ++++ frontend/src/lib/plexActiveFilters.ts | 15 ++- .../src/modules/feed/YearFilter.dom.test.tsx | 57 ++++++++ frontend/src/modules/plex/PlexInfo.tsx | 8 +- frontend/src/modules/plex/PlexSidebar.tsx | 85 +++++++----- .../plex/PlexSidebar.year.dom.test.tsx | 72 ++++++++++ frontend/src/modules/plex/plexDetailAside.tsx | 4 +- frontend/src/modules/plex/plexViews.tsx | 7 +- 20 files changed, 393 insertions(+), 109 deletions(-) create mode 100644 frontend/src/lib/plexActiveFilters.dom.test.ts create mode 100644 frontend/src/modules/feed/YearFilter.dom.test.tsx create mode 100644 frontend/src/modules/plex/PlexSidebar.year.dom.test.tsx diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 7ee47296..71e7230e 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -232,6 +232,8 @@ Also in force: `FocusSafeButton`) outside the control primitives (eslint `BUTTON_PRIMITIVES`) is a lint error. A text-less button must have an accessible name, and the type enforces it. [WCAG 4.1.2] - **The chip family** (`Chip`, E28 S3b): rounded-full, never under 24 (A3 grows it to 44 on touch). + One exception, the `cell` size: a cell of a grid of choices (the year dialog's decades) is a 36-high + rounded box with its count under the label, so ten fit across a dialog at a tablet's width. `filter` is a selectable option, solid accent when `pressed`; the "+N" and "More…" triggers are filter chips too, and while their popover is open they show it as an open trigger does, the accent edge and text (not the solid fill, which would say "selected"); `token` is an applied narrowing, the accent tint, the whole chip removing @@ -894,3 +896,5 @@ of a full-width panel and nothing on the right. | 2026-11-11 | A GIF from the clip panel (owner D1 A) | A Format · Video / GIF toggle above the name row; GIF shows the size (long side 320 / 480 / 640), the expected size, a note over 15 MB or 30 MB, and over 30 s the reason with Make GIF unavailable; a GIF is never made from the download dialog | A GIF format in the download dialog with its own range picker; the size as a free number | The range is already marked in the panel; a size people can judge beats pixels to type, and a chat's link preview stops moving over 15 MB | | 2026-11-11 | A GIF from the Downloads editor (owner D2 A) | Its own Format row (a video only); with GIF each kept segment is its own GIF, Join unavailable with its reason, one line in place of precise / fast, a note per segment over a limit | One GIF of the joined segments; GIF as an output mode beside Separate / Join | A GIF longer than 30 s is refused anyway, and a joined GIF hides which segment is over the limit | | 2026-11-11 | A shared GIF shows itself (owner D3) | The share page shows the picture itself, no player; Download beside it, and a tip that the downloaded file moves in any chat; its failure is said only once its retry failed too | The watch player; a file row that only downloads | A GIF plays and repeats on its own and nobody pauses one; a chat's link preview is the chat's to draw, the dragged-in file moves everywhere | +| 2026-11-14 | One year filter for the feed and Plex (owner ★) | `ui/YearPicker`: the six newest years as chips (the selected one kept beside them), "More…" opens every year laid out by decade, no paging, sortable by count; Plex keeps its range fields under it, a year is a range of one | The feed's scrolling popover; Plex's range fields alone | A library spanning ninety years fits on one screen by decade, and both rails pick a year the same way | +| 2026-11-14 | The year dialog's cell (owner ★A) | The count under the year in a 36-high rounded box, compact ("17.2K"), one row per decade at every width | The year-and-count pill, five columns and two rows per decade on a narrow screen | The pill spilled out of its cell at a tablet's width (measured 74 px in 62); a decade on one row still reads at a glance | diff --git a/frontend/src/components/ui/README.md b/frontend/src/components/ui/README.md index fc96ed83..e69c8414 100644 --- a/frontend/src/components/ui/README.md +++ b/frontend/src/components/ui/README.md @@ -52,7 +52,7 @@ change at 1024x768, 820x1180 and a desktop size. That contract lives in these co - **Geometry is a ladder, not a free choice.** `Button`'s `SIZES`, `IconButton`'s and `Chip`'s sizes are the rungs docs/DESIGN.md decided (B1: 24 / 28 / 32, icons 28 / 32, chips 24 / 28; the C5 media - and player rungs). Pick a rung; do not hand-roll padding on a primitive. + and player rungs; the `cell` chip, 36, a grid of choices). Pick a rung; do not hand-roll padding on a primitive. - **Type, padding and box geometry are rem-derived**, so the Settings text-size slider (`--font-scale` on the root font-size) scales boxes and padding with the label. A `text-[Npx]` font size is an eslint error for exactly this reason. The deliberate exceptions are the layout-engine measurements diff --git a/frontend/src/components/ui/YearPicker.dom.test.tsx b/frontend/src/components/ui/YearPicker.dom.test.tsx index 243735c8..c78bb35c 100644 --- a/frontend/src/components/ui/YearPicker.dom.test.tsx +++ b/frontend/src/components/ui/YearPicker.dom.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom import { describe, it, expect, vi, afterEach } from "vitest"; -import { render, screen, fireEvent, cleanup, within } from "@testing-library/react"; +import { render, screen, fireEvent, cleanup, within, act } from "@testing-library/react"; vi.mock("react-i18next", async (importOriginal) => { const actual = await importOriginal(); @@ -9,6 +9,7 @@ vi.mock("react-i18next", async (importOriginal) => { useTranslation: () => ({ t: (k: string, o?: Record) => o && "from" in o ? `${k}:${String(o.from)}-${String(o.to)}` : k, + i18n: { language: "en" }, }), }; }); @@ -80,4 +81,60 @@ describe("YearPicker", () => { fireEvent.click(screen.getByRole("button", { pressed: true })); expect(onPick).toHaveBeenLastCalledWith(undefined); }); + + it("offers More… while a year with titles is not among the chips, the kept selection aside", () => { + // Seven years, and a selected year with no titles kept beside the six newest: 2013 is hidden. + render( {}} />); + expect(screen.getByRole("button", { name: "sidebar.year.more" })).toBeTruthy(); + }); + + it("More… is a plain chip: the ellipsis says a dialog follows, no chevron", () => { + render( {}} />); + expect( + screen.getByRole("button", { name: "sidebar.year.more" }).querySelector("svg"), + ).toBeNull(); + }); + + it("clearing the kept older year hands the focus on, not to ", () => { + const { rerender } = render( {}} />); + const kept = screen.getByRole("button", { pressed: true }); + kept.focus(); + fireEvent.click(kept); + rerender( {}} />); + expect(document.activeElement).toBe(screen.getByRole("button", { name: "sidebar.year.more" })); + }); + + it("a decade row is named once: its caption is not read again", () => { + render( {}} />); + open(); + const row = screen.getAllByRole("group", { name: /sidebar\.year\.decade/ })[0]!; + expect(row.firstElementChild?.getAttribute("aria-hidden")).toBe("true"); + }); + + it("a cell carries its count under the year, compact", () => { + render( {}} />); + open(); + const cell = screen.getByRole("dialog").querySelector('[data-year="2025"]')!; + expect(cell.textContent).toBe("202517.2K"); + }); + + it("the arrows walk the cells: along a decade, then down to the next one", () => { + render( {}} />); + open(); + const dialog = screen.getByRole("dialog"); + dialog.querySelector('[data-year="2020"]')!.focus(); + press("ArrowRight"); + expect((document.activeElement as HTMLElement).dataset.year).toBe("2021"); + press("ArrowDown"); + expect((document.activeElement as HTMLElement).dataset.year).toMatch(/^201/); + }); }); + +/** A keydown where a real one starts: on whatever holds focus, bubbling to the window listener. */ +function press(key: string): void { + act(() => { + (document.activeElement ?? document.body).dispatchEvent( + new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }), + ); + }); +} diff --git a/frontend/src/components/ui/YearPicker.tsx b/frontend/src/components/ui/YearPicker.tsx index a66b393d..c686ad4f 100644 --- a/frontend/src/components/ui/YearPicker.tsx +++ b/frontend/src/components/ui/YearPicker.tsx @@ -1,10 +1,13 @@ import { useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; -import { ChevronDown } from "lucide-react"; import { Chip } from "./Chip"; import CountBadge from "./CountBadge"; import Modal from "./Modal"; +import NavGrid from "./NavGrid"; import SegmentedToggle from "./SegmentedToggle"; +import { formatCompact } from "../../lib/format"; +import { navItemAttrs } from "../../lib/listNav"; +import { handOffFocus } from "../../lib/useFocusKeeper"; export interface YearCount { year: number; @@ -12,13 +15,15 @@ export interface YearCount { } // A filter for one calendar year, shared by the feed and Plex rails: chips for the most recent years -// and a "More" dialog with every year and its count. The dialog lays the years out by decade — a row -// per decade, a cell per year — so a library spanning ninety years fits on one screen without paging, -// and a decade reads at a glance; sorted by count it is one grid, the busiest year first. A year with -// no titles is a blank cell, not a choice. Picking the selected year again clears it. What a pick -// means (a whole-year date range, a min = max range) is the caller's: this only names the year. +// and a "More…" dialog with every year and its count. The dialog lays the years out by decade — a row +// per decade, a cell per year, the count under the year so ten fit across at a tablet's width — and a +// library spanning ninety years fits on one screen without paging; sorted by count it is one grid, +// the busiest year first. A year with no titles is a blank cell, not a choice. The arrows walk the +// cells (lib/listNav): ←→ along a decade, ↑↓ to the nearest year of the next one. Picking the +// selected year again clears it. What a pick means (a whole-year date range, a min = max range) is +// the caller's: this only names the year. -const QUICK = 6; // recent-year chips before "More" +const QUICK = 6; // recent-year chips before "More…" type Sort = "year" | "count"; export default function YearPicker({ @@ -31,20 +36,21 @@ export default function YearPicker({ selected?: number; onPick: (year?: number) => void; }) { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const [open, setOpen] = useState(false); const [sort, setSort] = useState("year"); const newestFirst = useMemo(() => [...years].sort((a, b) => b.year - a.year), [years]); + const recent = useMemo(() => newestFirst.slice(0, QUICK), [newestFirst]); // The selected year stays visible among the chips, even when it is older than the recent ones. const quick = useMemo(() => { - const top = newestFirst.slice(0, QUICK); - if (selected != null && !top.some((y) => y.year === selected)) { - const sel = newestFirst.find((y) => y.year === selected) ?? { year: selected, count: 0 }; - return [...top, sel].sort((a, b) => b.year - a.year); - } - return top; - }, [newestFirst, selected]); + if (selected == null || recent.some((y) => y.year === selected)) return recent; + const sel = newestFirst.find((y) => y.year === selected) ?? { year: selected, count: 0 }; + return [...recent, sel].sort((a, b) => b.year - a.year); + }, [newestFirst, recent, selected]); + // "More…" while a year with titles is not among the chips — the one kept for the selection counts + // only if it is one of them. + const more = years.some((y) => !quick.some((q) => q.year === y.year)); const toggle = (year: number) => onPick(selected === year ? undefined : year); const pickAndClose = (year: number) => { @@ -52,18 +58,22 @@ export default function YearPicker({ setOpen(false); }; - const cell = (y: YearCount) => { + const cell = (y: YearCount, index: number) => { const on = selected === y.year; return ( pickAndClose(y.year)} data-year={y.year} + {...navItemAttrs(index)} > {y.year} - + + {formatCompact(y.count, i18n.language)} + ); }; @@ -72,17 +82,27 @@ export default function YearPicker({
{quick.map((y) => { const on = selected === y.year; + // The chip kept only for the selection leaves with it: from the keyboard, the focus goes to + // its neighbour first, not to (which also folded an unpinned rail). + const leaves = on && !recent.some((r) => r.year === y.year); return ( - toggle(y.year)}> + { + if (leaves && e.currentTarget === document.activeElement) + handOffFocus(e.currentTarget, () => toggle(y.year)); + else toggle(y.year); + }} + > {y.year} {y.count > 0 && } ); })} - {years.length > quick.length && ( + {more && ( setOpen(true)}> - {t("sidebar.year.more")} - )} {open && ( @@ -109,9 +129,9 @@ export default function YearPicker({ {sort === "year" ? ( ) : ( -
+ {[...years].sort((a, b) => b.count - a.count || b.year - a.year).map(cell)} -
+ )} )} @@ -119,43 +139,52 @@ export default function YearPicker({ ); } -/** One row per decade, newest first, with a cell per year of it; a year without titles is blank. */ +/** One row per decade, newest first, with a cell per year of it; a year without titles is blank. + * Each decade is a list of its own, so ↑↓ land on the nearest year of the next decade. */ function DecadeGrid({ years, cell, }: { years: readonly YearCount[]; - cell: (y: YearCount) => React.ReactNode; + cell: (y: YearCount, index: number) => React.ReactNode; }) { const { t } = useTranslation(); const byYear = new Map(years.map((y) => [y.year, y])); const decades = [...new Set(years.map((y) => y.year - (y.year % 10)))].sort((a, b) => b - a); return (
- {decades.map((from) => ( -
- {from} - {Array.from({ length: 10 }, (_, i) => { - const y = byYear.get(from + i); - return y ? ( - cell(y) - ) : ( - - ); - })} -
- ))} + {decades.map((from) => { + const row = Array.from({ length: 10 }, (_, i) => byYear.get(from + i)); + return ( +
+ {/* The group's name says the decade; read twice, it was noise. */} + + + {row.map((y, i) => + y ? ( + // Its index among the decade's years: a blank cell is not an item. + cell(y, row.slice(0, i).filter(Boolean).length) + ) : ( + + ), + )} + +
+ ); + })}
); } diff --git a/frontend/src/components/ui/chipStyles.ts b/frontend/src/components/ui/chipStyles.ts index cae3df12..0d0c39db 100644 --- a/frontend/src/components/ui/chipStyles.ts +++ b/frontend/src/components/ui/chipStyles.ts @@ -7,7 +7,8 @@ export type ChipVariant = "filter" | "token" | "tag" | "suggest" | "ghost"; export type ChipSize = | "md" // 28, text-sm — the applied-filter row above the feed | "sm" // 24, text-xs — a filter row - | "xs"; // 24, text-2xs, tight — a tag in a table row + | "xs" // 24, text-2xs, tight — a tag in a table row + | "cell"; // 36, two lines (a label over its count), a rounded box — a cell of a grid of choices const PAINT: Record = { // `.pill` owns the resting, hover AND pressed colours; a colour utility here would beat the pressed @@ -23,13 +24,15 @@ const PAINT: Record = { }; const SIZES: Record = { - md: "h-7 px-2.5 text-sm gap-1", - sm: "h-6 px-2.5 text-xs gap-1.5", - xs: "h-6 px-1.5 text-2xs gap-1", + md: "h-7 px-2.5 text-sm gap-1 rounded-full", + sm: "h-6 px-2.5 text-xs gap-1.5 rounded-full", + xs: "h-6 px-1.5 text-2xs gap-1 rounded-full", + // Narrow enough for ten across a dialog at a tablet's width: the count goes under the label. + cell: "h-9 px-1 text-xs leading-tight flex-col rounded-lg", }; const BASE = - "inline-flex min-w-6 shrink-0 items-center justify-center rounded-full whitespace-nowrap transition touch-target disabled:opacity-50 aria-disabled:opacity-50 [&>svg]:shrink-0"; + "inline-flex min-w-6 shrink-0 items-center justify-center whitespace-nowrap transition touch-target disabled:opacity-50 aria-disabled:opacity-50 [&>svg]:shrink-0"; /** The chip's geometry + paint as a class string: for the hidden measuring mirror of an overflowing * chip lane, which must lay out exactly like the chips it measures but must not be buttons. */ diff --git a/frontend/src/components/ui/tailwindScan.contract.test.ts b/frontend/src/components/ui/tailwindScan.contract.test.ts index 0f8381fe..e3bfa561 100644 --- a/frontend/src/components/ui/tailwindScan.contract.test.ts +++ b/frontend/src/components/ui/tailwindScan.contract.test.ts @@ -35,7 +35,7 @@ const IV = keys({ }); // prettier-ignore const IS = keys({ sm: true, md: true, lg: true, player: true }); const CV = keys({ filter: true, token: true, tag: true, suggest: true, ghost: true }); -const CS = keys({ md: true, sm: true, xs: true }); +const CS = keys({ md: true, sm: true, xs: true, cell: true }); const missing = (emitted: string[], file: string) => { const found = tokens(file); diff --git a/frontend/src/i18n/locales/en/plex.json b/frontend/src/i18n/locales/en/plex.json index 72ff1ecf..62f1a2f3 100644 --- a/frontend/src/i18n/locales/en/plex.json +++ b/frontend/src/i18n/locales/en/plex.json @@ -76,6 +76,8 @@ "rating": "IMDb rating", "genre": "Genre", "year": "Year", + "yearFrom": "From year", + "yearTo": "To year", "duration": "Length", "durationUnit": "min", "durationOpt": { diff --git a/frontend/src/i18n/locales/en/sidebar.json b/frontend/src/i18n/locales/en/sidebar.json index 818307f3..3aae23f1 100644 --- a/frontend/src/i18n/locales/en/sidebar.json +++ b/frontend/src/i18n/locales/en/sidebar.json @@ -34,7 +34,7 @@ "year": "Year" }, "year": { - "more": "More", + "more": "More…", "moreLabel": "All years", "decade": "{{from}}–{{to}}", "none": "No years yet", diff --git a/frontend/src/i18n/locales/hu/plex.json b/frontend/src/i18n/locales/hu/plex.json index a7e17d40..9f0c7554 100644 --- a/frontend/src/i18n/locales/hu/plex.json +++ b/frontend/src/i18n/locales/hu/plex.json @@ -76,6 +76,8 @@ "rating": "IMDb pont", "genre": "Műfaj", "year": "Év", + "yearFrom": "Ettől az évtől", + "yearTo": "Eddig az évig", "duration": "Hossz", "durationUnit": "perc", "durationOpt": { diff --git a/frontend/src/i18n/locales/hu/sidebar.json b/frontend/src/i18n/locales/hu/sidebar.json index bce083d9..b61e14eb 100644 --- a/frontend/src/i18n/locales/hu/sidebar.json +++ b/frontend/src/i18n/locales/hu/sidebar.json @@ -34,7 +34,7 @@ "year": "Év" }, "year": { - "more": "Több", + "more": "Több…", "moreLabel": "Összes év", "decade": "{{from}}–{{to}}", "none": "Még nincs év", diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 8f27f625..92d7ddfa 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -52,6 +52,8 @@ import { plexApi } from "./api/plex"; export type * from "./api/plex"; export { EMPTY_PLEX_FILTERS, + oneYear, + singleYear, plexFilterCount, coercePlexFilters, plexFiltersSignature, diff --git a/frontend/src/lib/api/plex.ts b/frontend/src/lib/api/plex.ts index bf031975..197d6011 100644 --- a/frontend/src/lib/api/plex.ts +++ b/frontend/src/lib/api/plex.ts @@ -261,6 +261,17 @@ export interface PlexPersonDetail { movies: PlexCard[]; // enabled-library movies whose cast OR directors include this person shows: PlexCard[]; // enabled-library shows, same } +/** One year as a Plex filter: a range of one, both ends set (the year picker, a year on an info page). + * `null` clears it. */ +export const oneYear = (year?: number | null): Pick => ({ + yearMin: year ?? null, + yearMax: year ?? null, +}); + +/** The one year a filter narrows to, when its range is a single year. */ +export const singleYear = (f: Pick): number | undefined => + f.yearMin != null && f.yearMin === f.yearMax ? f.yearMin : undefined; + // The expanded Plex browse filters (movie libraries). Persisted per-account as JSON. export interface PlexFilters { genres: string[]; diff --git a/frontend/src/lib/plexActiveFilters.dom.test.ts b/frontend/src/lib/plexActiveFilters.dom.test.ts new file mode 100644 index 00000000..9fc63b51 --- /dev/null +++ b/frontend/src/lib/plexActiveFilters.dom.test.ts @@ -0,0 +1,26 @@ +// @vitest-environment jsdom +import { describe, it, expect } from "vitest"; +import { buildPlexActiveFilters } from "./plexActiveFilters"; +import { EMPTY_PLEX_FILTERS, type PlexFilters } from "./api"; + +const t = (key: string) => key; +const yearLabel = (f: Partial) => + buildPlexActiveFilters( + { ...EMPTY_PLEX_FILTERS, ...f }, + "", + () => {}, + () => {}, + t, + ).find((c) => c.key === "year")?.label; + +describe("the Plex year chip", () => { + it("names one year as the year, not a range of one", () => { + expect(yearLabel({ yearMin: 1999, yearMax: 1999 })).toBe("plex.filter.year: 1999"); + }); + + it("names a range and an open end as before", () => { + expect(yearLabel({ yearMin: 1990, yearMax: 1999 })).toBe("plex.filter.year: 1990–1999"); + expect(yearLabel({ yearMin: 1990 })).toBe("plex.filter.year: ≥1990"); + expect(yearLabel({ yearMax: 1999 })).toBe("plex.filter.year: ≤1999"); + }); +}); diff --git a/frontend/src/lib/plexActiveFilters.ts b/frontend/src/lib/plexActiveFilters.ts index 4bf461fb..9b040707 100644 --- a/frontend/src/lib/plexActiveFilters.ts +++ b/frontend/src/lib/plexActiveFilters.ts @@ -1,6 +1,6 @@ import { vocabI18nKey } from "./vocab"; import { type ActiveFilter } from "./useActiveFilters"; -import { type PlexFilters } from "./api"; +import { singleYear, type PlexFilters } from "./api"; type TFn = (key: string, options?: Record) => string; @@ -42,12 +42,15 @@ export function buildPlexActiveFilters( }); } if (has(filters.yearMin) || has(filters.yearMax)) { + const one = singleYear(filters); const range = - has(filters.yearMin) && has(filters.yearMax) - ? `${filters.yearMin}–${filters.yearMax}` - : has(filters.yearMin) - ? `≥${filters.yearMin}` - : `≤${filters.yearMax}`; + one != null + ? String(one) + : has(filters.yearMin) && has(filters.yearMax) + ? `${filters.yearMin}–${filters.yearMax}` + : has(filters.yearMin) + ? `≥${filters.yearMin}` + : `≤${filters.yearMax}`; out.push({ key: "year", label: `${t("plex.filter.year")}: ${range}`, diff --git a/frontend/src/modules/feed/YearFilter.dom.test.tsx b/frontend/src/modules/feed/YearFilter.dom.test.tsx new file mode 100644 index 00000000..a14f773b --- /dev/null +++ b/frontend/src/modules/feed/YearFilter.dom.test.tsx @@ -0,0 +1,57 @@ +// @vitest-environment jsdom +// +// The feed rail's Year: a year and the Upload-date control are the same narrowing, so picking a year +// clears an upload-date window or range. +import { describe, it, expect, vi, afterEach } from "vitest"; +import { render, cleanup, screen, fireEvent } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +vi.mock("react-i18next", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }), + }; +}); + +import YearFilter from "./YearFilter"; +import { api, type FeedFilters } from "../../lib/api"; + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +const BASE: FeedFilters = { + tags: [], + tagModes: { language: "or", topic: "or", other: "or" }, + q: "", + sort: "newest", + scope: "my", + includeNormal: true, + includeShorts: false, + includeLive: false, + show: "unwatched", + channelIds: [], +}; + +describe("the feed rail's Year", () => { + it("picking a year clears the Upload-date window and range", async () => { + vi.spyOn(api, "feedYears").mockResolvedValue({ years: [{ year: 2010, count: 3 }] }); + const setFilters = vi.fn(); + const filters = { ...BASE, maxAgeDays: 7, dateFrom: "2020-01-01", dateTo: "2020-02-01" }; + render( + + + , + ); + fireEvent.click(await screen.findByRole("button", { name: /2010/ })); + expect(setFilters).toHaveBeenCalledWith({ + ...filters, + year: 2010, + maxAgeDays: undefined, + dateFrom: undefined, + dateTo: undefined, + }); + }); +}); diff --git a/frontend/src/modules/plex/PlexInfo.tsx b/frontend/src/modules/plex/PlexInfo.tsx index c1f99b96..a78e75ab 100644 --- a/frontend/src/modules/plex/PlexInfo.tsx +++ b/frontend/src/modules/plex/PlexInfo.tsx @@ -3,7 +3,7 @@ import { qk } from "../../lib/queryKeys"; import { useTranslation } from "react-i18next"; import { useQueryClient } from "@tanstack/react-query"; import { Check, ExternalLink, FolderPlus, ListPlus, Play, RotateCcw, Star, X } from "lucide-react"; -import { api, type PlexCard, type PlexFilters, type PlexItemDetail } from "../../lib/api"; +import { api, oneYear, type PlexCard, type PlexFilters, type PlexItemDetail } from "../../lib/api"; import { formatBytes, formatCompact, formatDecimal, formatRuntime } from "../../lib/format"; import { notifyActionError } from "../../lib/actionErrors"; import { DetailCustomizeMenu, Filterable, PrefToggle } from "./plexDetailUi"; @@ -427,11 +427,7 @@ export default function PlexInfo({ title={ onFilter ? t("plex.info.filterYear", { year: detail.year }) : undefined } - onClick={ - onFilter - ? () => onFilter({ yearMin: detail.year, yearMax: detail.year }) - : undefined - } + onClick={onFilter ? () => onFilter(oneYear(detail.year)) : undefined} > {detail.year} diff --git a/frontend/src/modules/plex/PlexSidebar.tsx b/frontend/src/modules/plex/PlexSidebar.tsx index 12737e4e..bc90f2fd 100644 --- a/frontend/src/modules/plex/PlexSidebar.tsx +++ b/frontend/src/modules/plex/PlexSidebar.tsx @@ -6,7 +6,14 @@ import { vocab } from "../../lib/vocab"; import { plexLang } from "../../i18n"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { ListMusic, Plus, SlidersHorizontal } from "lucide-react"; -import { api, plexFilterCount, type PlexFilters } from "../../lib/api"; +import { + api, + oneYear, + plexFilterCount, + singleYear, + type PlexFacets, + type PlexFilters, +} from "../../lib/api"; import { notifyActionError } from "../../lib/actionErrors"; import { useDebounced } from "../../lib/useDebounced"; import FilterRail from "../../components/ui/FilterRail"; @@ -173,36 +180,7 @@ export default function PlexSidebar() { { id: "year", title: t("plex.filter.year"), - body: ( -
- {/* One year is a range of one: picking it sets both ends, as the year on an info page does, - so the range fields below show it and a decade stays typeable there. */} - {facets && facets.years.length > 0 && ( - patch({ yearMin: year ?? null, yearMax: year ?? null })} - /> - )} -
- patch({ yearMin: v })} - /> - – - patch({ yearMax: v })} - /> -
-
- ), + body: , }, ...(scope === "movie" ? [ @@ -499,17 +477,62 @@ function RemovableChip({ label, onRemove }: { label: string; onRemove: () => voi ); } +/** The rail's Year section: the year picker over the range fields. One year is a range of one: + * picking it sets both ends, as the year on an info page does, so the range fields show it and a + * decade stays typeable there. */ +export function PlexYearFilter({ + filters, + facets, + patch, +}: { + filters: PlexFilters; + facets: PlexFacets | undefined; + patch: (p: Partial) => void; +}) { + const { t } = useTranslation(); + return ( +
+ {facets && facets.years.length > 0 && ( + patch(oneYear(year))} + /> + )} +
+ patch({ yearMin: v })} + /> + – + patch({ yearMax: v })} + /> +
+
+ ); +} + function NumberInput({ + label, value, placeholder, onChange, }: { + /** Its accessible name: the placeholder (a year) is not one. */ + label: string; value: number | null; placeholder?: number; onChange: (v: number | null) => void; }) { return ( { + const actual = await importOriginal(); + return { + ...actual, + useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }), + }; +}); +vi.mock("./plexContext", () => ({ + usePlex: () => ({}), + usePlexActions: () => ({ setPlaylistOpen: vi.fn() }), +})); + +import { PlexYearFilter } from "./PlexSidebar"; +import { EMPTY_PLEX_FILTERS, type PlexFacets } from "../../lib/api"; + +afterEach(cleanup); + +const FACETS = { + years: [ + { year: 2001, count: 4 }, + { year: 1999, count: 2 }, + ], + year_min: 1999, + year_max: 2001, +} as unknown as PlexFacets; + +describe("the Plex rail's Year section", () => { + it("picks a year as a range of one, and clears both ends again", () => { + const patch = vi.fn(); + const { rerender } = render( + , + ); + fireEvent.click(screen.getByRole("button", { name: /1999/ })); + expect(patch).toHaveBeenLastCalledWith({ yearMin: 1999, yearMax: 1999 }); + + rerender( + , + ); + const on = screen.getByRole("button", { pressed: true }); + expect(on.textContent).toContain("1999"); + fireEvent.click(on); + expect(patch).toHaveBeenLastCalledWith({ yearMin: null, yearMax: null }); + }); + + it("a wider range selects no year", () => { + render( + {}} + />, + ); + expect(screen.queryByRole("button", { pressed: true })).toBeNull(); + }); + + it("names the range fields", () => { + render( {}} />); + expect(screen.getByRole("spinbutton", { name: "plex.filter.yearFrom" })).toBeTruthy(); + expect(screen.getByRole("spinbutton", { name: "plex.filter.yearTo" })).toBeTruthy(); + }); +}); diff --git a/frontend/src/modules/plex/plexDetailAside.tsx b/frontend/src/modules/plex/plexDetailAside.tsx index 3e0fcd57..07448cf2 100644 --- a/frontend/src/modules/plex/plexDetailAside.tsx +++ b/frontend/src/modules/plex/plexDetailAside.tsx @@ -1,6 +1,6 @@ import { useState, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; -import type { PlexFilters } from "../../lib/api"; +import { oneYear, type PlexFilters } from "../../lib/api"; import { formatMoney, formatRuntime } from "../../lib/format"; import type { AsideSection, DetailAsideState, DetailFacts } from "../../lib/plexDetail"; import { regionName, vocab } from "../../lib/vocab"; @@ -75,7 +75,7 @@ function FactsSection({ facts, onFilter }: { facts: DetailFacts; onFilter?: OnFi onFilter({ yearMin: year, yearMax: year }) : undefined} + onClick={onFilter ? () => onFilter(oneYear(year)) : undefined} > {year} diff --git a/frontend/src/modules/plex/plexViews.tsx b/frontend/src/modules/plex/plexViews.tsx index 2ee74481..8dda49a7 100644 --- a/frontend/src/modules/plex/plexViews.tsx +++ b/frontend/src/modules/plex/plexViews.tsx @@ -16,6 +16,7 @@ import { import { qk } from "../../lib/queryKeys"; import { api, + oneYear, type PlexCard, type PlexFilters, type PlexPersonDetail, @@ -613,11 +614,7 @@ export function PlexShowView({ onFilter({ yearMin: show.year, yearMax: show.year }) - : undefined - } + onClick={onFilter ? () => onFilter(oneYear(show.year)) : undefined} > {show.year} From 36f9aefb76ab18f6b599e4a3cfc94116ac602712 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 14 Nov 2026 19:05:43 +0100 Subject: [PATCH 06/13] fix(downloads): a file asked for again during an over-cap pass stays whatever the clock says The eviction pass called a file freshly used only when its last-access stamp was later than the pass's own clock. A hit stamped just before the pass began but committed after its SELECT, or stamped by a clock that had stepped back (the test VM's does), read as old, and the file the user had just asked for was evicted. The pass now keeps the stamp its SELECT saw and skips a file whose stamp changed since. --- backend/app/downloads/gc.py | 29 ++++++++++++++--------------- backend/tests/test_file_folders.py | 22 ++++++++++++++++++++++ 2 files changed, 36 insertions(+), 15 deletions(-) diff --git a/backend/app/downloads/gc.py b/backend/app/downloads/gc.py index 9cfd052f..3759dd10 100644 --- a/backend/app/downloads/gc.py +++ b/backend/app/downloads/gc.py @@ -282,33 +282,32 @@ def run_download_gc(db: Session) -> dict: kept_assets = select(DownloadJob.asset_id).where( DownloadJob.kept.is_(True), DownloadJob.asset_id.is_not(None) ) - candidate_ids = ( - db.execute( - select(MediaAsset.id) - .where( - MediaAsset.status == "ready", - MediaAsset.source_kind != "upload", - MediaAsset.id.not_in(kept_assets), - ) - .order_by(MediaAsset.last_access_at.asc().nulls_first()) + candidates = db.execute( + select(MediaAsset.id, MediaAsset.last_access_at) + .where( + MediaAsset.status == "ready", + MediaAsset.source_kind != "upload", + MediaAsset.id.not_in(kept_assets), ) - .scalars() - .all() - ) + .order_by(MediaAsset.last_access_at.asc().nulls_first()) + ).all() db.rollback() - for asset_id in candidate_ids: + for asset_id, seen_access in candidates: # Counted afresh before each: a file deleted since (this one, an upload, a candidate # further down) has freed its space, and no other user's file goes for that space. if _on_disk(db) <= cap: break # Locked and re-read like an expiry: since the SELECT a holder may have kept it, - # every holder deleted it, or someone asked for it again (a fresh cache hit). + # every holder deleted it, or someone asked for it again (a fresh cache hit). A hit + # is a CHANGED stamp, not one later than this pass's clock: a hit stamped just + # before the pass began but committed after the SELECT is still a hit, and a clock + # that steps back would make a fresh one look old. asset, holders = _relock_evictable(db, asset_id) if ( asset is None or asset.status != "ready" or any(j.kept for j in holders) - or (asset.last_access_at is not None and asset.last_access_at > now) + or asset.last_access_at != seen_access ): db.commit() continue diff --git a/backend/tests/test_file_folders.py b/backend/tests/test_file_folders.py index b13b6f79..46813297 100644 --- a/backend/tests/test_file_folders.py +++ b/backend/tests/test_file_folders.py @@ -739,6 +739,28 @@ def test_a_file_asked_for_again_while_the_cache_is_over_its_cap_stays( assert (_root / "old.mp4").exists() +@pytest.mark.parametrize("stamped", ["before_the_pass", "by_a_clock_that_stepped_back"]) +def test_a_cache_hit_stamped_earlier_than_the_pass_still_keeps_the_file( + db, make_user, _root, monkeypatch, stamped +): + # A hit is a stamp that changed since the pass's SELECT. Compared with the pass's own clock, a + # hit stamped before the pass began (committed after its SELECT), or by a clock that had stepped + # back, read as old and the file the user just asked for went. + jold, jnew = _two_over_the_cap(db, make_user, _root) + seen = NOW - timedelta(days=9) + stamp = seen + (timedelta(hours=1) if stamped == "before_the_pass" else timedelta(days=9)) + + def asked_again(other, asset_id): + if asset_id == jold.asset_id: + other.get(MediaAsset, asset_id).last_access_at = stamp - timedelta(seconds=1) + + _meanwhile(monkeypatch, asked_again, step="_relock_evictable") + _gc(db) + db.expire_all() + assert db.get(DownloadJob, jold.id) is not None + assert (_root / "old.mp4").exists() + + def test_a_file_asked_for_while_its_cached_copy_is_freed_gets_a_fresh_one(db, make_user): # The enqueue read the cached copy unlocked: freed meanwhile, its job pointed at a row that was gone # (a foreign-key failure, a 500). From be6bd586d72b5a7f8b1a4e1c57fbe910111ce428 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 14 Nov 2026 19:32:01 +0100 Subject: [PATCH 07/13] =?UTF-8?q?fix(filters):=20a=20pick=20that=20hides?= =?UTF-8?q?=20More=E2=80=A6=20keeps=20the=20focus,=20counts=20read=20alike?= =?UTF-8?q?,=20Plex=20facets=20say=20they=20failed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Picking in the year dialog the one year the chips hid took More… away with the dialog, and the focus fell to : it now goes to the picked year's chip. A count badge is compact in the UI language everywhere (a chip read "17200" beside its cell's "17.2K"). While the Plex facets load or fail, the sections they fill say so instead of vanishing as if the library had none. --- frontend/src/components/ui/CountBadge.tsx | 9 +- .../src/components/ui/YearPicker.dom.test.tsx | 39 ++++ frontend/src/components/ui/YearPicker.tsx | 17 +- frontend/src/modules/plex/PlexSidebar.tsx | 194 +++++++++++------- .../plex/PlexSidebar.year.dom.test.tsx | 21 ++ .../modules/settings/MyLogViewer.dom.test.tsx | 1 + 6 files changed, 198 insertions(+), 83 deletions(-) diff --git a/frontend/src/components/ui/CountBadge.tsx b/frontend/src/components/ui/CountBadge.tsx index a99265bb..bbb1c283 100644 --- a/frontend/src/components/ui/CountBadge.tsx +++ b/frontend/src/components/ui/CountBadge.tsx @@ -1,15 +1,20 @@ +import { useTranslation } from "react-i18next"; +import { formatCompact } from "../../lib/format"; + // A small count pill shown beside a tab label or filter chip (pending requests, item counts). One // source for the count-badge token so Tabs and FilterChipGroup can't drift. The `active` variant sits // on an accent-filled parent (a pressed tab/chip), so it tints against accent-fg; the resting variant -// carries its own accent tint. Callers guard on `count > 0` — this just renders the pill. +// carries its own accent tint. Callers guard on `count > 0` — this just renders the pill. The count is +// compact in the UI language ("17.2K" / "17,2 E"), the same as every other count in the app. export default function CountBadge({ count, active }: { count: number; active?: boolean }) { + const { i18n } = useTranslation(); return ( - {count} + {formatCompact(count, i18n.language)} ); } diff --git a/frontend/src/components/ui/YearPicker.dom.test.tsx b/frontend/src/components/ui/YearPicker.dom.test.tsx index c78bb35c..2f47b17e 100644 --- a/frontend/src/components/ui/YearPicker.dom.test.tsx +++ b/frontend/src/components/ui/YearPicker.dom.test.tsx @@ -1,4 +1,5 @@ // @vitest-environment jsdom +import { useState } from "react"; import { describe, it, expect, vi, afterEach } from "vitest"; import { render, screen, fireEvent, cleanup, within, act } from "@testing-library/react"; @@ -88,6 +89,44 @@ describe("YearPicker", () => { expect(screen.getByRole("button", { name: "sidebar.year.more" })).toBeTruthy(); }); + it("offers no More… once every year with titles is a chip", () => { + const { rerender } = render( {}} />); + expect(screen.queryByRole("button", { name: "sidebar.year.more" })).toBeNull(); + // Seven years, the seventh selected: it is kept as a chip, so nothing is left for the dialog. + rerender( {}} />); + expect(screen.queryByRole("button", { name: "sidebar.year.more" })).toBeNull(); + }); + + it("picking the one year the chips hid moves the focus to its chip, not to ", () => { + // Seven years: the pick keeps 2013 as a chip, so More… leaves with the dialog it opened. + function Picker() { + const [year, setYear] = useState(); + return ; + } + vi.useFakeTimers(); + try { + render(); + const more = screen.getByRole("button", { name: "sidebar.year.more" }); + more.focus(); + fireEvent.click(more); + const cell = screen.getByRole("dialog").querySelector('[data-year="2013"]')!; + cell.focus(); + fireEvent.click(cell); + act(() => { + vi.runAllTimers(); + }); + expect(screen.queryByRole("button", { name: "sidebar.year.more" })).toBeNull(); + expect(document.activeElement).toBe(screen.getByRole("button", { pressed: true })); + } finally { + vi.useRealTimers(); + } + }); + + it("a chip's count is compact too, the same as its cell's", () => { + render( {}} />); + expect(screen.getByRole("button").textContent).toBe("202517.2K"); + }); + it("More… is a plain chip: the ellipsis says a dialog follows, no chevron", () => { render( {}} />); expect( diff --git a/frontend/src/components/ui/YearPicker.tsx b/frontend/src/components/ui/YearPicker.tsx index c686ad4f..ae9e4184 100644 --- a/frontend/src/components/ui/YearPicker.tsx +++ b/frontend/src/components/ui/YearPicker.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from "react"; +import { useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { Chip } from "./Chip"; import CountBadge from "./CountBadge"; @@ -52,10 +52,22 @@ export default function YearPicker({ // only if it is one of them. const more = years.some((y) => !quick.some((q) => q.year === y.year)); + const chipsRef = useRef(null); const toggle = (year: number) => onPick(selected === year ? undefined : year); const pickAndClose = (year: number) => { toggle(year); setOpen(false); + // A pick that leaves the dialog nothing to show takes More… away with it, and the dialog then has + // nowhere to give the focus back: it fell to , which also folded an unpinned rail. So it + // goes to the picked year's chip (the first chip if the pick cleared it). A timer, not a frame: a + // page the browser is not painting runs no requestAnimationFrame. + window.setTimeout(() => { + const now = document.activeElement; + const row = chipsRef.current; + if ((now && now !== document.body && now.isConnected) || !row) return; + const chip = row.querySelector(`[data-year="${year}"]`); + (chip ?? row.querySelector("button"))?.focus({ preventScroll: true }); + }, 0); }; const cell = (y: YearCount, index: number) => { @@ -79,7 +91,7 @@ export default function YearPicker({ }; return ( -
+
{quick.map((y) => { const on = selected === y.year; // The chip kept only for the selection leaves with it: from the keyboard, the focus goes to @@ -89,6 +101,7 @@ export default function YearPicker({ { if (leaves && e.currentTarget === document.activeElement) handOffFocus(e.currentTarget, () => toggle(y.year)); diff --git a/frontend/src/modules/plex/PlexSidebar.tsx b/frontend/src/modules/plex/PlexSidebar.tsx index bc90f2fd..a8eafbe7 100644 --- a/frontend/src/modules/plex/PlexSidebar.tsx +++ b/frontend/src/modules/plex/PlexSidebar.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { type ReactNode, useState } from "react"; import { qk } from "../../lib/queryKeys"; import { usePlexCollectionTitles } from "../../lib/usePlexCollectionTitles"; import { useTranslation } from "react-i18next"; @@ -85,6 +85,27 @@ export default function PlexSidebar() { // just lists them as removable chips. Studios now have their own picker section (below). const peopleActive = filters.directors.length + filters.actors.length > 0; + // A section the facets fill, shown when its facet has values. While the facets are not here, it + // says why it is empty: a first load or a failed one must not read as a library with no genres + // (honest states). The Year section announces it; these only show it, so it is read once. + const facetsState: FacetsState = facets + ? "ready" + : facetsQ.isError + ? "error" + : facetsQ.isLoading + ? "loading" + : "ready"; + const facetSection = ( + id: string, + title: string, + has: (f: PlexFacets) => boolean, + body: (f: PlexFacets) => ReactNode, + ): PanelItem[] => { + if (facets) return has(facets) ? [{ id, title, body: body(facets) }] : []; + if (facetsState === "ready") return []; + return [{ id, title, body: }]; + }; + // Group bodies (no title — the PanelGroup card supplies it). Only available groups are pushed, // so facet-gated / scope-gated sections drop out cleanly; PanelGroups orders them by the saved // layout and lets the user reorder / hide / collapse them. @@ -154,33 +175,32 @@ export default function PlexSidebar() { /> ), }, - ...(facets && facets.genres.length > 0 - ? [ - { - id: "genre", - title: t("plex.filter.genre"), - body: ( - <> - patch({ genreMode: m })} - /> - patch({ genres: next })} - /> - - ), - }, - ] - : []), + ...facetSection( + "genre", + t("plex.filter.genre"), + (f) => f.genres.length > 0, + (f) => ( + <> + patch({ genreMode: m })} + /> + patch({ genres: next })} + /> + + ), + ), { id: "year", title: t("plex.filter.year"), - body: , + body: ( + + ), }, ...(scope === "movie" ? [ @@ -227,62 +247,55 @@ export default function PlexSidebar() { /> ), }, - ...(facets && facets.content_ratings.length > 0 - ? [ - { - id: "contentrating", - title: t("plex.filter.contentRating"), - body: ( - ({ - ...o, - label: vocab(t, "certificate", o.value), - }))} - selected={filters.contentRatings} - onChange={(next) => patch({ contentRatings: next })} - /> - ), - }, - ] - : []), - ...(facets && facets.studios.length > 0 - ? [ - { - id: "studio", - title: t("plex.filter.studio"), - body: ( - patch({ studios: next })} - /> - ), - }, - ] - : []), + ...facetSection( + "contentrating", + t("plex.filter.contentRating"), + (f) => f.content_ratings.length > 0, + (f) => ( + ({ + ...o, + label: vocab(t, "certificate", o.value), + }))} + selected={filters.contentRatings} + onChange={(next) => patch({ contentRatings: next })} + /> + ), + ), + ...facetSection( + "studio", + t("plex.filter.studio"), + (f) => f.studios.length > 0, + (f) => ( + patch({ studios: next })} + /> + ), + ), // 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: ( - ({ - value: p.value, - label: t(`plex.filter.playableOpt.${p.value}`), - count: p.count, - }))} - selected={filters.playable} - onChange={(next) => patch({ playable: next })} - /> - ), - }, - ] + ...(scope === "movie" + ? facetSection( + "playable", + t("plex.filter.playable"), + (f) => f.playable.length > 0, + (f) => ( + ({ + value: p.value, + label: t(`plex.filter.playableOpt.${p.value}`), + count: p.count, + }))} + selected={filters.playable} + onChange={(next) => patch({ playable: next })} + /> + ), + ) : []), ]; @@ -483,15 +496,18 @@ function RemovableChip({ label, onRemove }: { label: string; onRemove: () => voi export function PlexYearFilter({ filters, facets, + facetsState = "ready", patch, }: { filters: PlexFilters; facets: PlexFacets | undefined; + facetsState?: FacetsState; patch: (p: Partial) => void; }) { const { t } = useTranslation(); return (
+ {facetsState !== "ready" && } {facets && facets.years.length > 0 && ( ; + announce?: boolean; +}) { + const { t } = useTranslation(); + const role = announce ? (state === "error" ? "alert" : "status") : undefined; + return ( +

+ {t(state === "error" ? "common.loadError" : "common.loading")} +

+ ); +} + function NumberInput({ label, value, diff --git a/frontend/src/modules/plex/PlexSidebar.year.dom.test.tsx b/frontend/src/modules/plex/PlexSidebar.year.dom.test.tsx index 25a9a54e..056d4de8 100644 --- a/frontend/src/modules/plex/PlexSidebar.year.dom.test.tsx +++ b/frontend/src/modules/plex/PlexSidebar.year.dom.test.tsx @@ -64,6 +64,27 @@ describe("the Plex rail's Year section", () => { expect(screen.queryByRole("button", { pressed: true })).toBeNull(); }); + it("says the years are loading, or failed to load, rather than showing none", () => { + const { rerender } = render( + {}} + />, + ); + expect(screen.getByRole("status").textContent).toBe("common.loading"); + rerender( + {}} + />, + ); + expect(screen.getByRole("alert").textContent).toBe("common.loadError"); + }); + it("names the range fields", () => { render( {}} />); expect(screen.getByRole("spinbutton", { name: "plex.filter.yearFrom" })).toBeTruthy(); diff --git a/frontend/src/modules/settings/MyLogViewer.dom.test.tsx b/frontend/src/modules/settings/MyLogViewer.dom.test.tsx index d9772099..24fc67c1 100644 --- a/frontend/src/modules/settings/MyLogViewer.dom.test.tsx +++ b/frontend/src/modules/settings/MyLogViewer.dom.test.tsx @@ -14,6 +14,7 @@ vi.mock("react-i18next", async (importOriginal) => { useTranslation: () => ({ t: (key: string, opts?: { count?: number }) => opts?.count != null ? `${key}:${opts.count}` : key, + i18n: { language: "en" }, }), }; }); From f1d084592323ba1ae9e8582177bec92d5f0ceb32 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 14 Nov 2026 19:32:01 +0100 Subject: [PATCH 08/13] fix(auth): refusing a revoked session no longer deletes a newer sign-in's cookie The refusal cleared the session, so a request in flight with the previous account's cookie answered with a cookie deletion that could land after a sign-in as someone else. A revoked id is refused on every request anyway. Also a test for the pruning of old revocations. --- backend/app/auth.py | 6 ++++-- backend/tests/test_auth_session.py | 27 ++++++++++++++++++++++++++- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/backend/app/auth.py b/backend/app/auth.py index baf01969..b5f3b3e4 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -368,9 +368,11 @@ def _session_user(request: Request, db: Session, user_id: int | None) -> User: if epoch is None or int(epoch) != (user.session_epoch or 0): request.session.clear() raise refused(401, "not_authenticated") - # A session signed out (or replaced by a sign-in as someone else) whose cookie landed late. + # A session signed out (or replaced by a sign-in as someone else) whose cookie landed late. Not + # cleared: this request may have left before a sign-in as someone else answered, and a cookie + # deletion landing after that sign-in's cookie would sign the browser out of it. A revoked id is + # refused on every request and never renewed, so leaving its cookie costs nothing. if db.get(RevokedSession, _session_id(request)) is not None: - request.session.clear() raise refused(401, "not_authenticated") _renew_session(request) # Attribute this request's log lines to the account. Here, not in the request middleware: diff --git a/backend/tests/test_auth_session.py b/backend/tests/test_auth_session.py index b33e5f8f..971149b5 100644 --- a/backend/tests/test_auth_session.py +++ b/backend/tests/test_auth_session.py @@ -205,7 +205,10 @@ def test_a_cookie_from_before_a_sign_in_as_another_account_is_refused(client, ma assert _login(client, "late2b@example.com", _PW).status_code == 200 bob = client.cookies.get("session") _hold(client, in_flight) - assert client.get(_PROTECTED).status_code == 401 + r = client.get(_PROTECTED) + assert r.status_code == 401 + # The refusal deletes no cookie: landing after Bob's, a deletion would sign Bob out. + assert "set-cookie" not in r.headers _hold(client, bob) # Bob's own session is untouched assert client.get("/api/me").json()["email"] == "late2b@example.com" @@ -225,6 +228,28 @@ def test_a_session_from_before_session_ids_gets_one_and_its_sign_out_holds(clien assert client.get(_PROTECTED).status_code == 401 +def test_a_revoke_drops_only_the_revocations_no_cookie_can_outlive(db): + # Each revoke prunes the table: a row goes once no cookie carrying its id can still be valid, and + # never sooner — a later sign-out must not undo an earlier one. + from datetime import datetime, timezone + from types import SimpleNamespace + + from sqlalchemy import select + + from app.auth import SESSION_MAX_AGE, revoke_session + from app.models import RevokedSession + + now = datetime.now(timezone.utc) + life = timedelta(seconds=SESSION_MAX_AGE) + db.add(RevokedSession(sid="outlived", revoked_at=now - life - timedelta(days=2))) + db.add(RevokedSession(sid="still-live", revoked_at=now - life + timedelta(hours=1))) + db.commit() + revoke_session(db, SimpleNamespace(session={"user_id": 1, "sid": "this-one"}, cookies={})) + db.commit() + left = set(db.scalars(select(RevokedSession.sid)).all()) + assert left == {"still-live", "this-one"} + + def _session(client) -> dict: """The decoded payload of the client's session cookie.""" return _decode(client.cookies.get("session")) From 4dc9846eba1e6e221e9cb9e1279f7d5e1ea8a6fb Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 14 Nov 2026 19:32:02 +0100 Subject: [PATCH 09/13] test: the cookie signer and test modules read the never-back clock too The docker VM's clock steps back: itsdangerous refused a cookie signed in the next second (a random 401 right after a sign-in), and a test compared an app stamp with the raw wall clock. --- backend/tests/conftest.py | 29 ++++++++++++++++-- backend/tests/test_test_clock.py | 51 ++++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 2 deletions(-) create mode 100644 backend/tests/test_test_clock.py diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index ba9f4f6d..0aca6420 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -337,20 +337,45 @@ class _NeverBack(_dt.datetime): return t +_last_signature_second = [0] + + +def _signature_second_never_back(self) -> int: + """The session cookie's signing clock (itsdangerous, whole seconds), never going back: it refuses + a signature from the future, so a step back across a second boundary refused the cookie a + sign-in had just set. Read through the module at call time, so a probe can step it.""" + import itsdangerous.timed + + t = max(int(itsdangerous.timed.time.time()), _last_signature_second[0]) + _last_signature_second[0] = t + return t + + @pytest.fixture(autouse=True) def _clock_never_goes_back(monkeypatch): """Rows a test makes one after another are stamped in that order. The wall clock of the docker VM the suite runs in steps BACK now and then (measured: 2 steps in 20 s, up to 0.5 s), and a test that makes three files and reads them newest first then got them out of order. Every loaded `app.*` module that took `datetime` from the datetime module reads this clock instead (a sweep, - as the limiter reset below does, so a new module is covered the day it lands). A database + as the limiter reset below does, so a new module is covered the day it lands) — and so does every + test module, so a test comparing a stamp the app wrote with "now" reads the same clock. The + session cookie's signature has a clock of its own (`_signature_second_never_back`). A database default (`server_default=func.now()`) is Postgres's clock and is not covered: a test that orders such rows made within a second of each other gives them distinct times itself.""" import sys + from itsdangerous import TimestampSigner + + tests_dir = os.path.dirname(os.path.abspath(__file__)) for name, mod in list(sys.modules.items()): - if (name == "app" or name.startswith("app.")) and vars(mod).get("datetime") is _dt.datetime: + if vars(mod).get("datetime") is not _dt.datetime: + continue + mod_file = getattr(mod, "__file__", None) or "" + if name == "app" or name.startswith("app.") or mod_file.startswith(tests_dir + os.sep): monkeypatch.setattr(mod, "datetime", _NeverBack) + # Per test: a cookie never outlives its test, and a probe's stepped clock must not carry over. + _last_signature_second[0] = 0 + monkeypatch.setattr(TimestampSigner, "get_timestamp", _signature_second_never_back) @pytest.fixture(autouse=True) diff --git a/backend/tests/test_test_clock.py b/backend/tests/test_test_clock.py new file mode 100644 index 00000000..71a599fa --- /dev/null +++ b/backend/tests/test_test_clock.py @@ -0,0 +1,51 @@ +"""The suite's clock (conftest `_clock_never_goes_back`), DB-lane. + +The wall clock of the docker VM the suite runs in steps BACK now and then (measured: up to 0.5 s). +These hold the two places that read it besides the app: the session cookie's signature, and a test +comparing a stamp the app wrote with "now". +""" + +import time +from datetime import datetime, timezone +from types import SimpleNamespace + +import itsdangerous.timed + +_PW = "correct horse battery" + + +def test_a_cookie_signed_just_before_the_clock_steps_back_still_signs_in( + client, make_user, monkeypatch +): + # The signature carries whole seconds and itsdangerous refuses one from the future: a step back + # across a second boundary refused the cookie a sign-in had just set (a 401 at random). + clock = [int(time.time()) + 0.2] + monkeypatch.setattr(itsdangerous.timed, "time", SimpleNamespace(time=lambda: clock[0])) + make_user(email="clock@example.com", password=_PW) + r = client.post("/auth/password-login", json={"email": "clock@example.com", "password": _PW}) + assert r.status_code == 200 + clock[0] -= 0.5 + assert client.get("/api/me/notifications").status_code == 200 + + +def test_a_test_reads_the_clock_the_app_stamps_with(monkeypatch): + # A test asserting "the app's stamp is not in the future" compared it with the wall clock, which + # may have stepped back since the app read its own. + import conftest + from app import diagnostics + + # The app reads 12:00:01, then the wall clock steps back half a second (a time past any real one, + # so a test reading the wall clock instead is behind the stamp). + later = datetime(2099, 1, 1, 12, 0, 1, tzinfo=timezone.utc) + stepped_back = datetime(2099, 1, 1, 12, 0, 0, 500000, tzinfo=timezone.utc) + readings = iter([later]) + + class Stepping(datetime): + @classmethod + def now(cls, tz=None): + return next(readings, stepped_back) + + monkeypatch.setattr(conftest._NeverBack, "clock", Stepping) + monkeypatch.setattr(conftest._NeverBack, "last", None) + stamped = diagnostics.datetime.now(timezone.utc) + assert datetime.now(timezone.utc) >= stamped From 8d6aa05bc801b5d64fcff994c99c400ab0d84eee Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 14 Nov 2026 19:46:35 +0100 Subject: [PATCH 10/13] fix(auth): a cookie from before session ids signs in once more; a session's age is its renewal A sign-out sent with a cookie that had no session id revoked nothing, so a request of its still in flight signed the browser back in; its id, drawn from the raw cookie, also changed whenever the cookie was re-signed. Such a cookie is now refused, like one with no epoch: every one would have run out within 14 days anyway. And any route that writes the session re-signs the cookie (the anonymous Google sign-in start too), so a refused one could be kept fresh past the row revoking it: a session is now as old as its renewed stamp, which only a sign-in and a renewal write. --- backend/app/auth.py | 48 +++++++++++++++------------- backend/tests/test_auth_session.py | 50 +++++++++++++++++++----------- 2 files changed, 58 insertions(+), 40 deletions(-) diff --git a/backend/app/auth.py b/backend/app/auth.py index b5f3b3e4..b9df7e51 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -1,4 +1,3 @@ -import hashlib import secrets from datetime import datetime, timedelta, timezone @@ -282,14 +281,24 @@ def stamp_session(request: Request, user: User) -> None: # (`revoke_session`). Landing after "log out everywhere" or a password change re-stamped this # browser's cookie, it carries the old epoch and signs this browser out: that race fails closed (sign # in again; nothing is reached), and is left so. +# +# The session's age is its `renewed` stamp, not the cookie's signature: any route that writes the +# session re-signs the cookie (an anonymous one too — the Google sign-in start stores its state +# there), so a refused cookie could be kept fresh forever. `renewed` is written only by a sign-in and +# by a renewal, and a refused session gets neither, so it dies `SESSION_MAX_AGE` after its last one. SESSION_MAX_AGE = 14 * 86400 # the cookie's life; `main` hands it to SessionMiddleware _RENEW_AFTER_SECONDS = 86400 +def _session_expired(request: Request) -> bool: + renewed = request.session.get("renewed") + now = int(datetime.now(timezone.utc).timestamp()) + return not isinstance(renewed, int) or now - renewed > SESSION_MAX_AGE + + def _renew_session(request: Request) -> None: now = int(datetime.now(timezone.utc).timestamp()) - renewed = request.session.get("renewed") - if not isinstance(renewed, int) or now - renewed >= _RENEW_AFTER_SECONDS: + if now - request.session["renewed"] >= _RENEW_AFTER_SECONDS: request.session["renewed"] = now @@ -297,23 +306,11 @@ def new_session_id() -> str: return secrets.token_urlsafe(16) -def _session_id(request: Request) -> str: - """This session's id. A cookie from before session ids gets one drawn from the cookie itself, so - parallel requests carrying it agree on the id a sign-out revokes, and two browsers do not.""" - sid = request.session.get("sid") - if isinstance(sid, str): - return sid - raw = request.cookies.get("session", "") - sid = hashlib.sha256(raw.encode()).hexdigest()[:22] - request.session["sid"] = sid - return sid - - def revoke_session(db: Session, request: Request) -> None: """Revoke this browser's session id, so a cookie carrying it that lands later is refused. Not committed — the caller commits. A row is dropped once no cookie with its id can still be valid: - a refused cookie is never renewed, so it dies `SESSION_MAX_AGE` after it was signed, at most a - request's time after the revoke (a day of margin).""" + a refused session is never renewed, so it expires `SESSION_MAX_AGE` after its `renewed` stamp, + which is no later than the revoke (a day of margin).""" sid = request.session.get("sid") if not isinstance(sid, str): return @@ -368,11 +365,18 @@ def _session_user(request: Request, db: Session, user_id: int | None) -> User: if epoch is None or int(epoch) != (user.session_epoch or 0): request.session.clear() raise refused(401, "not_authenticated") - # A session signed out (or replaced by a sign-in as someone else) whose cookie landed late. Not - # cleared: this request may have left before a sign-in as someone else answered, and a cookie - # deletion landing after that sign-in's cookie would sign the browser out of it. A revoked id is - # refused on every request and never renewed, so leaving its cookie costs nothing. - if db.get(RevokedSession, _session_id(request)) is not None: + # A session signed out (or replaced by a sign-in as someone else) whose cookie landed late; one + # with no id, which only a build before session ids wrote (its holder signs in once more); or one + # not renewed for its whole life. Not cleared: this request may have left before a sign-in as + # someone else answered, and a cookie deletion landing after that sign-in's cookie would sign the + # browser out of it. Such a session is refused on every request and never renewed, so leaving + # its cookie costs nothing. + sid = request.session.get("sid") + if ( + not isinstance(sid, str) + or _session_expired(request) + or db.get(RevokedSession, sid) is not None + ): raise refused(401, "not_authenticated") _renew_session(request) # Attribute this request's log lines to the account. Here, not in the request middleware: diff --git a/backend/tests/test_auth_session.py b/backend/tests/test_auth_session.py index 971149b5..a4657432 100644 --- a/backend/tests/test_auth_session.py +++ b/backend/tests/test_auth_session.py @@ -172,16 +172,26 @@ def test_a_session_renewed_today_is_not_re_signed_on_every_request(client, make_ assert "set-cookie" not in r.headers -def test_a_session_with_no_renewal_stamp_is_renewed(client, make_user, monkeypatch): - # A cookie signed before the session slid carries no `renewed`: its first request stamps one. +def test_a_session_with_no_renewal_stamp_signs_in_once_more(client, make_user): + # The session's age is its `renewed` stamp; a cookie without one cannot say how old it is. make_user(email="slide3@example.com", password=_PW) assert _login(client, "slide3@example.com", _PW).status_code == 200 - payload = {k: v for k, v in _session(client).items() if k != "renewed"} - now = int(TimestampSigner(str(settings.secret_key)).get_timestamp()) - _hold(client, _signed_at(monkeypatch, now - 13 * _DAY, payload)) + _hold(client, _cookie({k: v for k, v in _session(client).items() if k != "renewed"})) r = client.get(_PROTECTED) - assert r.status_code == 200 - assert isinstance(_decode(r.cookies.get("session")).get("renewed"), int) + assert r.status_code == 401 + assert "set-cookie" not in r.headers + + +def test_a_fresh_signature_does_not_make_an_old_session_young(client, make_user): + # Any route that writes the session re-signs the cookie — an anonymous one too (the Google + # sign-in start stores its state there) — so a refused cookie could be kept freshly signed for + # ever, and outlive the row that revoked it. Its age is its `renewed` stamp instead. + make_user(email="slide4@example.com", password=_PW) + assert _login(client, "slide4@example.com", _PW).status_code == 200 + now = int(TimestampSigner(str(settings.secret_key)).get_timestamp()) + stale = {**_session(client), "renewed": now - 15 * _DAY} + _hold(client, _cookie(stale)) # signed just now + assert client.get(_PROTECTED).status_code == 401 # A request still in flight when the browser signs out (or signs in as someone else) answers with the @@ -213,19 +223,16 @@ def test_a_cookie_from_before_a_sign_in_as_another_account_is_refused(client, ma assert client.get("/api/me").json()["email"] == "late2b@example.com" -def test_a_session_from_before_session_ids_gets_one_and_its_sign_out_holds(client, make_user): +def test_a_cookie_from_before_session_ids_signs_in_once_more(client, make_user): + # Its sign-out could revoke nothing, so a request of its still in flight signed the browser back + # in. Every such cookie would have run out within 14 days anyway: its holder signs in once more. make_user(email="late3@example.com", password=_PW) assert _login(client, "late3@example.com", _PW).status_code == 200 - old = _cookie({k: v for k, v in _session(client).items() if k != "sid"}) - _hold(client, old) + _hold(client, _cookie({k: v for k, v in _session(client).items() if k != "sid"})) r = client.get(_PROTECTED) - assert r.status_code == 200 - in_flight = r.cookies.get("session") - assert _decode(in_flight).get("sid") - _hold(client, in_flight) - assert client.post("/auth/logout").status_code == 200 - _hold(client, in_flight) - assert client.get(_PROTECTED).status_code == 401 + assert r.status_code == 401 + assert "set-cookie" not in r.headers + assert client.get("/api/me").json() == {"authenticated": False} def test_a_revoke_drops_only_the_revocations_no_cookie_can_outlive(db): @@ -272,7 +279,14 @@ def test_a_google_link_lands_only_on_the_account_still_signed_in(db, make_user, from app.routes.auth import _complete_link starter, other = make_user(), make_user() - session = {"user_id": starter.id if signed_in_as == "starter" else other.id, "epoch": 0} + import time + + session = { + "user_id": starter.id if signed_in_as == "starter" else other.id, + "epoch": 0, + "sid": "a-session", + "renewed": int(time.time()), + } request = Request({"type": "http", "session": session, "headers": []}) resp = _complete_link(request, db, starter.id, True, "sub-123", {"email": ""}, {"scope": ""}) db.refresh(starter) From 7dd50db9b7287136d0837c663e70c7044fbc7090 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 14 Nov 2026 20:07:13 +0100 Subject: [PATCH 11/13] fix(plex): the rail says once that its facets failed, with Retry A failed facets load put the same muted line in up to five sections with nothing to press (the query never retries by itself), and only the Year section announced it, so a reader who hid that section heard nothing. The rail now shows one StateMessage with Retry, in the Year section or in its foot when Year is hidden; the facet sections wait for their data as before. --- frontend/src/modules/plex/PlexSidebar.tsx | 68 +++++++++---------- .../plex/PlexSidebar.year.dom.test.tsx | 27 +++----- 2 files changed, 42 insertions(+), 53 deletions(-) diff --git a/frontend/src/modules/plex/PlexSidebar.tsx b/frontend/src/modules/plex/PlexSidebar.tsx index a8eafbe7..e567fe2a 100644 --- a/frontend/src/modules/plex/PlexSidebar.tsx +++ b/frontend/src/modules/plex/PlexSidebar.tsx @@ -29,6 +29,7 @@ import { usePlex, usePlexActions } from "./plexContext"; import { DismissibleChip } from "../../components/ui/Chip"; import { Input } from "../../components/ui/Input"; import YearPicker from "../../components/ui/YearPicker"; +import { StateMessage } from "../../components/ui/QueryState"; import { useRail } from "../../lib/useRail"; import { msg } from "../../lib/notifications"; @@ -85,26 +86,21 @@ export default function PlexSidebar() { // just lists them as removable chips. Studios now have their own picker section (below). const peopleActive = filters.directors.length + filters.actors.length > 0; - // A section the facets fill, shown when its facet has values. While the facets are not here, it - // says why it is empty: a first load or a failed one must not read as a library with no genres - // (honest states). The Year section announces it; these only show it, so it is read once. - const facetsState: FacetsState = facets - ? "ready" - : facetsQ.isError - ? "error" - : facetsQ.isLoading - ? "loading" - : "ready"; + // A section the facets fill, shown when its facet has values. const facetSection = ( id: string, title: string, has: (f: PlexFacets) => boolean, body: (f: PlexFacets) => ReactNode, - ): PanelItem[] => { - if (facets) return has(facets) ? [{ id, title, body: body(facets) }] : []; - if (facetsState === "ready") return []; - return [{ id, title, body: }]; - }; + ): PanelItem[] => (facets && has(facets) ? [{ id, title, body: body(facets) }] : []); + // While the facets are not here, the rail says so ONCE — a first load or a failed one must not + // read as a library with no genres (honest states) — with a way to try again. In the Year section, + // which every library has; in the rail's foot if the reader hid it. + const facetsStatus = + facets || (!facetsQ.isError && !facetsQ.isLoading) ? null : ( + void facetsQ.refetch()} /> + ); + const yearHidden = !!layout.hidden.year; // Group bodies (no title — the PanelGroup card supplies it). Only available groups are pushed, // so facet-gated / scope-gated sections drop out cleanly; PanelGroups orders them by the saved @@ -199,7 +195,12 @@ export default function PlexSidebar() { id: "year", title: t("plex.filter.year"), body: ( - + ), }, ...(scope === "movie" @@ -319,7 +320,9 @@ export default function PlexSidebar() { iconExpanded={} />} onTitleClick={openModal} titleActionLabel={t("views.manageTitle")} - /> + > + {yearHidden && facetsStatus} + {/* The manager modal lives here (rail is always mounted on plex); Modal portals to . */} {modalOpen && } @@ -496,18 +499,19 @@ function RemovableChip({ label, onRemove }: { label: string; onRemove: () => voi export function PlexYearFilter({ filters, facets, - facetsState = "ready", + status, patch, }: { filters: PlexFilters; facets: PlexFacets | undefined; - facetsState?: FacetsState; + /** The rail's word on facets still loading or failed, when it shows here. */ + status?: ReactNode; patch: (p: Partial) => void; }) { const { t } = useTranslation(); return (
- {facetsState !== "ready" && } + {status} {facets && facets.years.length > 0 && ( ; - announce?: boolean; -}) { +/** The facets still loading, or failed with a way to try again — said once for the whole rail. */ +export function FacetsStatus({ failed, onRetry }: { failed: boolean; onRetry: () => void }) { const { t } = useTranslation(); - const role = announce ? (state === "error" ? "alert" : "status") : undefined; + if (failed) + return ( + + {t("common.loadError")} + + ); return ( -

- {t(state === "error" ? "common.loadError" : "common.loading")} +

+ {t("common.loading")}

); } diff --git a/frontend/src/modules/plex/PlexSidebar.year.dom.test.tsx b/frontend/src/modules/plex/PlexSidebar.year.dom.test.tsx index 056d4de8..7f88849e 100644 --- a/frontend/src/modules/plex/PlexSidebar.year.dom.test.tsx +++ b/frontend/src/modules/plex/PlexSidebar.year.dom.test.tsx @@ -17,7 +17,7 @@ vi.mock("./plexContext", () => ({ usePlexActions: () => ({ setPlaylistOpen: vi.fn() }), })); -import { PlexYearFilter } from "./PlexSidebar"; +import { FacetsStatus, PlexYearFilter } from "./PlexSidebar"; import { EMPTY_PLEX_FILTERS, type PlexFacets } from "../../lib/api"; afterEach(cleanup); @@ -64,25 +64,14 @@ describe("the Plex rail's Year section", () => { expect(screen.queryByRole("button", { pressed: true })).toBeNull(); }); - it("says the years are loading, or failed to load, rather than showing none", () => { - const { rerender } = render( - {}} - />, - ); + it("says the facets are loading, or failed with a way to try again, rather than showing none", () => { + const retry = vi.fn(); + const { rerender } = render(); expect(screen.getByRole("status").textContent).toBe("common.loading"); - rerender( - {}} - />, - ); - expect(screen.getByRole("alert").textContent).toBe("common.loadError"); + rerender(); + expect(screen.getByRole("alert").textContent).toContain("common.loadError"); + fireEvent.click(screen.getByRole("button", { name: "common.retry" })); + expect(retry).toHaveBeenCalledOnce(); }); it("names the range fields", () => { From 7a1487f554f6b478395f3c049d41b5941a0855be Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 14 Nov 2026 20:07:13 +0100 Subject: [PATCH 12/13] fix(auth): a cancel at Google lands where the browser is; the probe says why it refused A refused session keeps its cookie, so the cancel branch, keyed on the cookie's account, sent a signed-out browser to the in-app toast the login page never shows: it now asks whether the session holds. The probe's log line named every refusal 'its account no longer holds'; it now carries the reason. The pruning docstring says again that a renewal may land a request's time after the revoke, and the test pins the day of margin. The test clock clamps in the zone asked for. --- backend/app/auth.py | 43 +++++++++++++++++---------- backend/app/routes/auth.py | 5 ++-- backend/tests/conftest.py | 4 ++- backend/tests/test_auth_session.py | 19 +++++++++++- backend/tests/test_google_callback.py | 23 ++++++++++++++ backend/tests/test_test_clock.py | 23 ++++++++++++++ 6 files changed, 98 insertions(+), 19 deletions(-) diff --git a/backend/app/auth.py b/backend/app/auth.py index b9df7e51..5a61618b 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -309,8 +309,9 @@ def new_session_id() -> str: def revoke_session(db: Session, request: Request) -> None: """Revoke this browser's session id, so a cookie carrying it that lands later is refused. Not committed — the caller commits. A row is dropped once no cookie with its id can still be valid: - a refused session is never renewed, so it expires `SESSION_MAX_AGE` after its `renewed` stamp, - which is no later than the revoke (a day of margin).""" + a refused session is never renewed, so it expires `SESSION_MAX_AGE` after its `renewed` stamp — + and that stamp is at most a request's time after the revoke (a request past its revoked-id check + can still renew), which the day of margin covers.""" sid = request.session.get("sid") if not isinstance(sid, str): return @@ -350,6 +351,12 @@ def current_user(request: Request, db: Session = Depends(get_db)) -> User: return user +def _refuse(request: Request, reason: str) -> HTTPException: + """The one answer to a refused session, its reason kept for the probe's log line.""" + request.state.session_refused = reason + return refused(401, "not_authenticated") + + def _session_user(request: Request, db: Session, user_id: int | None) -> User: if not user_id: raise refused(401, "not_authenticated") @@ -357,27 +364,29 @@ def _session_user(request: Request, db: Session, user_id: int | None) -> User: if user is None or not user.is_active or user.is_suspended: # Suspended/deactivated mid-session → end it so the block takes effect at once. request.session.clear() - raise refused(401, "not_authenticated") + raise _refuse(request, "account") # A cookie whose epoch is behind the account's (revoked by a password reset/change or "log out # everywhere") is refused — and so is one with no epoch at all, which only a build before the # one-account session wrote: its holder signs in once more. epoch = request.session.get("epoch") if epoch is None or int(epoch) != (user.session_epoch or 0): request.session.clear() - raise refused(401, "not_authenticated") + raise _refuse(request, "epoch") # A session signed out (or replaced by a sign-in as someone else) whose cookie landed late; one # with no id, which only a build before session ids wrote (its holder signs in once more); or one # not renewed for its whole life. Not cleared: this request may have left before a sign-in as # someone else answered, and a cookie deletion landing after that sign-in's cookie would sign the - # browser out of it. Such a session is refused on every request and never renewed, so leaving - # its cookie costs nothing. + # browser out of it. Such a session is refused here on every request and never renewed. What + # reads the cookie alone (`session_user_id`) still sees its account: the poster proxy, by design + # (a signed cookie is enough for a poster); anything that must know the browser is signed in + # asks `optional_current_user`. sid = request.session.get("sid") - if ( - not isinstance(sid, str) - or _session_expired(request) - or db.get(RevokedSession, sid) is not None - ): - raise refused(401, "not_authenticated") + if not isinstance(sid, str): + raise _refuse(request, "no_session_id") + if _session_expired(request): + raise _refuse(request, "expired") + if db.get(RevokedSession, sid) is not None: + raise _refuse(request, "revoked") _renew_session(request) # Attribute this request's log lines to the account. Here, not in the request middleware: # this is the one place a session becomes an account, and the middleware runs outside both the @@ -404,10 +413,14 @@ def optional_current_user(request: Request, db: Session = Depends(get_db)) -> Us return _session_user(request, db, user_id) except HTTPException: # A session that named an account, refused: the browser is signed out without being told - # why, so say it here. + # why, so say it here — and why, since only some reasons mean the account itself changed. log.info( - "a session was refused: its account no longer holds", - extra=logs.event("auth.session.probe_rejected", user_id=user_id), + "a session was refused", + extra=logs.event( + "auth.session.probe_rejected", + user_id=user_id, + reason=getattr(request.state, "session_refused", None), + ), ) return None diff --git a/backend/app/routes/auth.py b/backend/app/routes/auth.py index b440af61..355db046 100644 --- a/backend/app/routes/auth.py +++ b/backend/app/routes/auth.py @@ -392,9 +392,10 @@ async def callback( # or connecting via /auth/google/connect) never sees the pre-auth login banner, so # surface a neutral toast via ?oauth=cancelled; a fresh sign-in cancel lands on the login page # with the oauth_cancelled banner instead. Keyed on the live session, not on link_uid, so a - # sign-in started while signed in (no marker) is covered too. + # sign-in started while signed in (no marker) is covered too — a session that still HOLDS: + # a refused one keeps its cookie (`auth._session_user`), and its browser is on the login page. log.info("Google OAuth cancelled/failed: %s", exc.error) - if session_user_id(request) is not None: + if optional_current_user(request, db) is not None: return RedirectResponse(url="/?oauth=cancelled") return RedirectResponse(url="/?login=oauth_cancelled") diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 0aca6420..8f7bd8e8 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -332,7 +332,9 @@ class _NeverBack(_dt.datetime): t = cls.clock.now(tz) last = cls.last if last is not None and (last.tzinfo is None) == (t.tzinfo is None) and t <= last: - t = last + _dt.timedelta(microseconds=1) + # In the zone asked for: a Pacific reading clamped to a UTC one gave the UTC date. + nudged = last + _dt.timedelta(microseconds=1) + t = nudged.astimezone(t.tzinfo) if t.tzinfo is not None else nudged cls.last = t return t diff --git a/backend/tests/test_auth_session.py b/backend/tests/test_auth_session.py index a4657432..956bd81b 100644 --- a/backend/tests/test_auth_session.py +++ b/backend/tests/test_auth_session.py @@ -223,6 +223,20 @@ def test_a_cookie_from_before_a_sign_in_as_another_account_is_refused(client, ma assert client.get("/api/me").json()["email"] == "late2b@example.com" +def test_the_probe_says_why_a_session_was_refused(client, make_user, caplog): + # Only some refusals mean the account changed; a cookie with no session id is not one of them. + make_user(email="why@example.com", password=_PW) + assert _login(client, "why@example.com", _PW).status_code == 200 + _hold(client, _cookie({k: v for k, v in _session(client).items() if k != "sid"})) + with caplog.at_level("INFO", logger="siftlode.auth"): + assert client.get("/api/me").json() == {"authenticated": False} + [line] = [ + r for r in caplog.records if getattr(r, "event", None) == "auth.session.probe_rejected" + ] + assert line.getMessage() == "a session was refused" + assert line.attrs["reason"] == "no_session_id" + + def test_a_cookie_from_before_session_ids_signs_in_once_more(client, make_user): # Its sign-out could revoke nothing, so a request of its still in flight signed the browser back # in. Every such cookie would have run out within 14 days anyway: its holder signs in once more. @@ -250,11 +264,14 @@ def test_a_revoke_drops_only_the_revocations_no_cookie_can_outlive(db): life = timedelta(seconds=SESSION_MAX_AGE) db.add(RevokedSession(sid="outlived", revoked_at=now - life - timedelta(days=2))) db.add(RevokedSession(sid="still-live", revoked_at=now - life + timedelta(hours=1))) + # Past the cookie's life but inside the day of margin: a renewal a request's time after the + # revoke may still be valid. + db.add(RevokedSession(sid="in-the-margin", revoked_at=now - life - timedelta(hours=12))) db.commit() revoke_session(db, SimpleNamespace(session={"user_id": 1, "sid": "this-one"}, cookies={})) db.commit() left = set(db.scalars(select(RevokedSession.sid)).all()) - assert left == {"still-live", "this-one"} + assert left == {"still-live", "in-the-margin", "this-one"} def _session(client) -> dict: diff --git a/backend/tests/test_google_callback.py b/backend/tests/test_google_callback.py index 4390d403..bfcaa63d 100644 --- a/backend/tests/test_google_callback.py +++ b/backend/tests/test_google_callback.py @@ -59,6 +59,29 @@ def _sign_in(client, google, state: str | None = None, **userinfo): ) +@pytest.mark.parametrize("session", ["holds", "refused"]) +def test_a_cancel_at_google_lands_where_the_browser_is(client, google, make_user, session): + # Signed in, the cancel is a toast in the app; signed out, the login page's banner. A refused + # session keeps its cookie and its account id, and its browser is on the login page. + import base64 + import json + + from itsdangerous import TimestampSigner + + make_user(email="cancel@example.com", password=_PW) + _password_login(client, "cancel@example.com") + if session == "refused": + signer = TimestampSigner(str(settings.secret_key)) + payload = json.loads(base64.b64decode(signer.unsign(client.cookies["session"]))) + payload.pop("sid") # a cookie from before session ids + refused = signer.sign(base64.b64encode(json.dumps(payload).encode())).decode() + client.cookies.clear() + client.cookies.set("session", refused) + r = _sign_in(client, google, state="not-this-flow") + expected = "/?oauth=cancelled" if session == "holds" else "/?login=oauth_cancelled" + assert r.headers["location"] == expected + + def _rows(db, model, column, value): db.expire_all() return db.execute(select(model).where(column == value)).scalars().all() diff --git a/backend/tests/test_test_clock.py b/backend/tests/test_test_clock.py index 71a599fa..0008be5b 100644 --- a/backend/tests/test_test_clock.py +++ b/backend/tests/test_test_clock.py @@ -49,3 +49,26 @@ def test_a_test_reads_the_clock_the_app_stamps_with(monkeypatch): monkeypatch.setattr(conftest._NeverBack, "last", None) stamped = diagnostics.datetime.now(timezone.utc) assert datetime.now(timezone.utc) >= stamped + + +def test_a_clamped_reading_is_in_the_zone_asked_for(monkeypatch): + # The Pacific quota day read through a clamp came back in UTC: its date was the next day's. + from zoneinfo import ZoneInfo + + import conftest + + pacific = ZoneInfo("America/Los_Angeles") + utc_reading = datetime(2099, 1, 2, 3, 0, 0, tzinfo=timezone.utc) # 1 January 19:00 in Pacific + stepped_back = datetime(2099, 1, 1, 18, 59, 59, tzinfo=pacific) # a second earlier + + class Stepping(datetime): + @classmethod + def now(cls, tz=None): + return utc_reading if tz is timezone.utc else stepped_back + + monkeypatch.setattr(conftest._NeverBack, "clock", Stepping) + monkeypatch.setattr(conftest._NeverBack, "last", None) + conftest._NeverBack.now(timezone.utc) + clamped = conftest._NeverBack.now(pacific) + assert clamped.tzinfo is pacific + assert clamped.date().isoformat() == "2099-01-01" From 5a9ac019ae59d9aa017262dd6970cb40acb692b1 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 14 Nov 2026 20:53:01 +0100 Subject: [PATCH 13/13] release: 0.120.0 --- CHANGELOG.md | 10 ++++++++++ VERSION | 2 +- frontend/src/content/release-notes/en.json | 10 ++++++++++ frontend/src/content/release-notes/hu.json | 10 ++++++++++ 4 files changed, 31 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 94a3b74f..fb0e371d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ edit the release notes and re-run the generator. Upgrading, pinning a version and rolling back: [docs/self-hosting.md](docs/self-hosting.md#upgrading). +## 0.120.0 — 2026-11-14 + +### New + +- Filter by year in the feed and in Plex: the recent years sit as chips, and More… opens every year in a decade grid, with how many items each one holds. + +### Fixed + +- A session you keep using stays signed in instead of ending on day 14, and signing out holds even when another tab answers late. After this update everyone signs in once more. + ## 0.119.2 — 2026-11-14 ### Fixed diff --git a/VERSION b/VERSION index 6c17fd52..99e0d1ed 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.119.2 +0.120.0 diff --git a/frontend/src/content/release-notes/en.json b/frontend/src/content/release-notes/en.json index 96840d2d..36f823ec 100644 --- a/frontend/src/content/release-notes/en.json +++ b/frontend/src/content/release-notes/en.json @@ -1,4 +1,14 @@ [ + { + "version": "0.120.0", + "date": "2026-11-14", + "new": [ + "Filter by year in the feed and in Plex: the recent years sit as chips, and More… opens every year in a decade grid, with how many items each one holds." + ], + "fixed": [ + "A session you keep using stays signed in instead of ending on day 14, and signing out holds even when another tab answers late. After this update everyone signs in once more." + ] + }, { "version": "0.119.2", "date": "2026-11-14", diff --git a/frontend/src/content/release-notes/hu.json b/frontend/src/content/release-notes/hu.json index ccdf3bca..8a1b0419 100644 --- a/frontend/src/content/release-notes/hu.json +++ b/frontend/src/content/release-notes/hu.json @@ -1,4 +1,14 @@ [ + { + "version": "0.120.0", + "date": "2026-11-14", + "new": [ + "Év szerint is szűrhetsz a hírfolyamban és a Plexben: a legutóbbi évek chipként látszanak, a Több… pedig évtizedes rácsban mutatja az összes évet, mindegyiknél azzal, hány elem tartozik hozzá." + ], + "fixed": [ + "A használatban lévő munkamenet bejelentkezve marad, nem ér véget a 14. napon, és a kijelentkezés akkor is érvényes, ha egy másik lap későn válaszol. A frissítés után mindenkinek egyszer újra be kell lépnie." + ] + }, { "version": "0.119.2", "date": "2026-11-14",