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/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 c21de036..5a61618b 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -1,6 +1,10 @@ +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 @@ -12,7 +16,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 @@ -261,6 +265,60 @@ 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()) + + +# 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. +# +# 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. +# +# 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()) + if now - request.session["renewed"] >= _RENEW_AFTER_SECONDS: + request.session["renewed"] = now + + +def new_session_id() -> str: + return secrets.token_urlsafe(16) + + +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 — + 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 + 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 @@ -293,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") @@ -300,14 +364,30 @@ 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 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): + 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 # session middleware and this task's context. `request.state` is shared across middleware layers @@ -333,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/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/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/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/app/routes/auth.py b/backend/app/routes/auth.py index b90791dd..355db046 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, ) @@ -391,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") @@ -531,7 +533,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 +643,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 +652,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 +1047,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/conftest.py b/backend/tests/conftest.py index ba9f4f6d..8f7bd8e8 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -332,25 +332,52 @@ 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 +_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_auth_session.py b/backend/tests/test_auth_session.py index 5053ecbc..956bd81b 100644 --- a/backend/tests/test_auth_session.py +++ b/backend/tests/test_auth_session.py @@ -122,9 +122,165 @@ 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 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 + _hold(client, _cookie({k: v for k, v in _session(client).items() if k != "renewed"})) + r = client.get(_PROTECTED) + 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 +# 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) + 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" + + +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. + make_user(email="late3@example.com", password=_PW) + assert _login(client, "late3@example.com", _PW).status_code == 200 + _hold(client, _cookie({k: v for k, v in _session(client).items() if k != "sid"})) + r = client.get(_PROTECTED) + 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): + # 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))) + # 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", "in-the-margin", "this-one"} + + 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()) @@ -140,7 +296,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) 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). 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_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/backend/tests/test_test_clock.py b/backend/tests/test_test_clock.py new file mode 100644 index 00000000..0008be5b --- /dev/null +++ b/backend/tests/test_test_clock.py @@ -0,0 +1,74 @@ +"""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 + + +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" 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", 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/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/README.md b/frontend/src/components/ui/README.md index a39e3893..e69c8414 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/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 80ead6cd..e567fe2a 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"; @@ -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"; @@ -21,6 +28,8 @@ 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 { StateMessage } from "../../components/ui/QueryState"; import { useRail } from "../../lib/useRail"; import { msg } from "../../lib/notifications"; @@ -77,6 +86,22 @@ 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. + const facetSection = ( + id: string, + title: string, + has: (f: PlexFacets) => boolean, + body: (f: PlexFacets) => ReactNode, + ): 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 // layout and lets the user reorder / hide / collapse them. @@ -146,46 +171,36 @@ 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: ( -
- patch({ yearMin: v })} - /> - – - patch({ yearMax: v })} - /> -
+ ), }, ...(scope === "movie" @@ -233,62 +248,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 })} + /> + ), + ) : []), ]; @@ -312,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 && } @@ -483,17 +493,82 @@ 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, + status, + patch, +}: { + filters: PlexFilters; + facets: PlexFacets | undefined; + /** The rail's word on facets still loading or failed, when it shows here. */ + status?: ReactNode; + patch: (p: Partial) => void; +}) { + const { t } = useTranslation(); + return ( +
+ {status} + {facets && facets.years.length > 0 && ( + patch(oneYear(year))} + /> + )} +
+ patch({ yearMin: v })} + /> + – + patch({ yearMax: v })} + /> +
+
+ ); +} + +/** 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(); + if (failed) + return ( + + {t("common.loadError")} + + ); + return ( +

+ {t("common.loading")} +

+ ); +} + 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 { FacetsStatus, 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("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).toContain("common.loadError"); + fireEvent.click(screen.getByRole("button", { name: "common.retry" })); + expect(retry).toHaveBeenCalledOnce(); + }); + + 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} 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" }, }), }; });