Compare commits

...
24 Commits
Author SHA1 Message Date
peter e51e348610 release: 0.120.1 (bug/files-thumb-webkit) 2026-11-14 21:55:05 +01:00
peter 3a09f0d78d release: 0.120.1 2026-11-14 21:34:49 +01:00
peter 06c0ab5fb6 fix(files): a row's picture fits its box on iPad
In a table cell WebKit resolved the picture's h-full against the aspect-video box as the poster's own height, so the box showed a native-size sliver of it. The box now has a fixed 16:9 height, as the other pictures in tables do; an e2e check runs in the WebKit and iPad projects.
2026-11-14 21:34:33 +01:00
peter 903e56fba5 release: 0.120.0 (bug/sliding-session) 2026-11-14 21:12:12 +01:00
peter 5a9ac019ae release: 0.120.0 2026-11-14 20:53:01 +01:00
peter 7a1487f554 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.
2026-11-14 20:07:13 +01:00
peter 7dd50db9b7 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.
2026-11-14 20:07:13 +01:00
peter 8d6aa05bc8 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.
2026-11-14 19:46:35 +01:00
peter 4dc9846eba 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.
2026-11-14 19:32:02 +01:00
peter f1d0845923 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.
2026-11-14 19:32:01 +01:00
peter be6bd586d7 fix(filters): a pick that hides More… keeps the focus, counts read alike, Plex facets say they failed
Picking in the year dialog the one year the chips hid took More… away with the dialog, and the
focus fell to <body>: 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.
2026-11-14 19:32:01 +01:00
peter 36f9aefb76 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.
2026-11-14 19:05:43 +01:00
peter 0eca18bcb4 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
2026-11-14 17:56:37 +01:00
peter 02714e4986 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.
2026-11-14 17:56:36 +01:00
peter d265c84c8b fix(filters): the year dialog's sort caption keeps its distance from the toggle 2026-11-14 15:23:16 +01:00
peter 1dff809268 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.
2026-11-14 15:08:50 +01:00
peter 94f9ffdb1c 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.
2026-11-14 14:43:04 +01:00
peter 3bb71a4bc3 release: 0.119.2 (bug/fb-date-translit) 2026-11-14 01:34:13 +01:00
peter 87ab33b03c release: 0.119.2 2026-11-14 01:21:11 +01:00
peter 452ff042ff fix(downloads): Facebook files get their upload date, non-Latin names their letters
Since 2026-10-28 the signed-in Facebook read gives no upload date, so
every Facebook file since was named 0000-00-00 in Season_0 (31 on prod,
none before). The signed-out read that already titles these downloads
carries the date, so it now lends that too when the signed-in read has
none (backfill.public_title_info is now public_page_info).

On-disk names dropped every letter outside the Latin script, so a Hebrew,
Cyrillic, Greek or CJK uploader went to one shared "untitled" folder
(prod asset 503). anyascii now writes them in Latin letters
(Сергей Иванов -> Sergei_Ivanov); Latin and accented names are unchanged.
2026-11-14 01:15:17 +01:00
peter 25198a936f release: 0.119.1 (bug/fb-share-group-post) 2026-11-14 00:49:24 +01:00
peter 9d979a5202 release: 0.119.1 2026-11-14 00:36:34 +01:00
peter fb3aca4c9c fix(downloads): a shared or feed-opened Facebook group post downloads its own video
A Facebook share link (/share/v/…) redirects to a group post, but only the
permalink form went through the post-page read, so yt-dlp followed the
share link itself and downloaded an ad from the signed-in group feed
(prod job 471). The link of a post opened from the group's feed
(/groups/<g>/?multi_permalinks=<id>) was not recognised at all, and yt-dlp
answers it with "Unsupported URL" (prod job 472).

With a cookie, a share link is now followed in the same request that reads
the post page, and a feed link is read as the post it names. Without one,
the feed link goes to yt-dlp in the permalink form it knows. A post page's
"no single video" verdict now names the post for a share link too.
2026-11-14 00:29:59 +01:00
peter 4f78877201 test: the app's clock never goes back in the test suite
The docker VM the suite runs in steps its wall clock back now and then (measured: 2 steps in 20 s, up to 0.5 s), so rows a test made one after another could read out of order (a files list sorted newest first, a scheduler's last runs). Every app module that took datetime from the datetime module reads a clock that never repeats or goes back; the scheduler test's own fixture for this goes.
2026-11-13 03:02:00 +01:00
55 changed files with 1730 additions and 366 deletions
+28
View File
@@ -7,6 +7,34 @@ 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.1 — 2026-11-14
### Fixed
- On an iPad, the pictures in the Files list show the whole poster again, not a blurred close-up of a small part of it.
## 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
- A Facebook download gets its upload date again, so its file and season are named by it, not 0000-00-00. A channel name in another script, such as Hebrew or Cyrillic, is written in Latin letters in file names instead of becoming "untitled".
## 0.119.1 — 2026-11-14
### Fixed
- A Facebook group post pasted as its Share link, or opened from the group's feed, now downloads the post's own video. Before, a Share link could bring down another video from the feed, and a feed link failed with "Site not supported".
## 0.119.0 — 2026-11-13
### New
+1 -1
View File
@@ -1 +1 @@
0.119.0
0.120.1
@@ -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")
+90 -6
View File
@@ -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
+45 -6
View File
@@ -12,7 +12,7 @@ Resolution works both before extraction (from source_kind / URL host) and after
"""
import re
from urllib.parse import urlparse
from urllib.parse import parse_qs, urlparse
from yt_dlp.networking.impersonate import ImpersonateTarget
@@ -183,10 +183,23 @@ class FacebookAdapter(SourceAdapter):
# in the page scripts that carry the post id; its watch link downloads (plain first, then the
# Chrome retry above). Needs the cookie: signed out, the page is the login wall.
post_id = facebook_group_post_id(url)
if post_id is None or not ydl_opts.get("cookiefile"):
if not ydl_opts.get("cookiefile"):
# yt-dlp knows the permalink form and answers the feed's form with "Unsupported URL".
if post_id is not None and not _FB_GROUP_POST_PATH.match(urlparse(url).path):
return _group_permalink(url, post_id)
return url
# A share link is a post only once followed: handed to yt-dlp, it was followed to the group
# page and an ad from the signed-in feed came down (prod job 471). One request does both — the
# redirect lands on the post page, which is then read as any post link's.
if post_id is None and not _is_share_link(url):
return url
with egress.open_ydl(ydl_opts) as ydl:
raw = ydl.urlopen(url).read(self._POST_PAGE_MAX_BYTES)
response = ydl.urlopen(url)
if post_id is None:
post_id = facebook_group_post_id(response.url)
if post_id is None:
return url # a shared reel or video: yt-dlp follows the link as it always has
raw = response.read(self._POST_PAGE_MAX_BYTES)
page = raw.decode("utf-8", "replace")
# Measured: a refused or expired cookie gets the login page (~450 KB, a login
# form, no post id at all), which is not a post without a video — the user's cookie is the cause.
@@ -214,7 +227,7 @@ class FacebookAdapter(SourceAdapter):
# signed-out read titles the page with that same text behind the engagement counts, cut short.
# Measured (2026-11-01, yt-dlp 2026.08.19): since 2026-10-28 every signed-in reel read gives
# "Facebook" and NO description, while a signed-out read of the same reel still carries the text —
# so a placeholder left with nothing behind it gets one signed-out read (`backfill.public_title_info`).
# so a placeholder left with nothing behind it gets one signed-out read (`backfill.public_page_info`).
_PLACEHOLDER_TITLES = frozenset({"video", "facebook"})
def is_placeholder_title(self, title: str | None) -> bool:
@@ -246,6 +259,7 @@ class FacebookAdapter(SourceAdapter):
_FB_GROUP_POST_PATH = re.compile(r"^/groups/[^/]+/(?:permalink|posts)/(\d+)/?$")
_FB_GROUP_PATH = re.compile(r"^/groups/[^/]+/?$")
_SCRIPT_BLOCK = re.compile(r"<script[^>]*>(.*?)</script>", re.S)
_VIDEO_NODE = re.compile(r'"__typename":"Video","id":"(\d+)"')
@@ -256,12 +270,37 @@ def _quoted(post_id: str) -> str:
def facebook_group_post_id(url: str) -> str | None:
"""The post id of a Facebook group post link (`/groups/<group>/permalink|posts/<id>/`), else None."""
"""The post id of a Facebook group post link, else None: `/groups/<group>/permalink|posts/<id>/`,
or a post opened from the group's feed, `/groups/<group>/?multi_permalinks=<id>` (one id only —
with several, which one was meant is not ours to guess)."""
parsed = urlparse(url)
if not _host_is(parsed.netloc.lower(), "facebook.com"):
return None
match = _FB_GROUP_POST_PATH.match(parsed.path)
return match.group(1) if match else None
if match:
return match.group(1)
if not _FB_GROUP_PATH.match(parsed.path):
return None
ids = parse_qs(parsed.query).get("multi_permalinks", [])
return ids[0] if len(ids) == 1 and ids[0].isdigit() else None
def _group_permalink(url: str, post_id: str) -> str:
"""The permalink form of a group post link — the one form yt-dlp itself accepts."""
group = urlparse(url).path.split("/")[2]
return f"https://www.facebook.com/groups/{group}/permalink/{post_id}/"
def facebook_post_link(url: str) -> bool:
"""A link that `resolve_url` may read as a group post: a post link, or a share link (which may
lead to one) — what the failure catalog accepts for the post page's own verdicts."""
return facebook_group_post_id(url) is not None or _is_share_link(url)
def _is_share_link(url: str) -> bool:
# facebook.com/share/<code>/ and /share/v|r|p/<code>/ — a redirect to the shared object.
parsed = urlparse(url)
return _host_is(parsed.netloc.lower(), "facebook.com") and parsed.path.startswith("/share/")
def facebook_post_video_id(page: str, post_id: str) -> str | None:
+17 -10
View File
@@ -8,7 +8,7 @@ Facebook sources (a direct media URL like a reddit HLS manifest carries no chann
URL may have expired — those just get a locally-generated poster instead). A Facebook download
titled with the source's placeholder ("Facebook", "Video") is retitled from a signed-out read of its
page, and so is each download name that only repeated the placeholder. That page read
(`public_title_info`) also titles every new download, from the worker.
(`public_page_info`) also titles and dates every new download, from the worker.
Run once per environment:
docker exec siftlode-worker-1 python -c "from app.downloads import backfill; print(backfill.run())"
@@ -71,16 +71,23 @@ def _extract_meta_scoped(url: str, adapter: adapters.SourceAdapter, asset_id: in
return None
def public_title_info(info: dict, url: str, adapter: adapters.SourceAdapter, asset_id: int) -> dict:
"""`info` to title a download from: itself, unless its title is a source's placeholder with
nothing behind it (a signed-in Facebook read: "Facebook", no description). Then one signed-out,
metadata-only read of the page lends its text — the description, else its title — as the
description the adapter takes a placeholder's title from. Nothing better there: `info` as it was."""
def public_page_info(info: dict, url: str, adapter: adapters.SourceAdapter, asset_id: int) -> dict:
"""`info` to title and date a download from: itself, unless its title is a source's placeholder
with nothing behind it (a signed-in Facebook read: "Facebook", no description). Then one
signed-out, metadata-only read of the page lends its text — the description, else its title — as
the description the adapter takes a placeholder's title from, and its upload date when `info` has
none (measured on prod: the signed-in read lost both on 2026-10-28, so every Facebook file since
was named 0000-00-00 in Season_0). Nothing better there: `info` as it was."""
if not adapter.is_placeholder_title(adapter.title(info)):
return info
public = extract_meta(url, adapter, asset_id)
text = public and (public.get("description") or public.get("title"))
return {**info, "description": text} if text else info
public = extract_meta(url, adapter, asset_id) or {}
lent: dict = {}
text = public.get("description") or public.get("title")
if text:
lent["description"] = text
if not info.get("upload_date") and public.get("upload_date"):
lent["upload_date"] = public["upload_date"]
return {**info, **lent} if lent else info
def _retitle(db: Session, a: MediaAsset, adapter: adapters.SourceAdapter, url: str) -> bool:
@@ -88,7 +95,7 @@ def _retitle(db: Session, a: MediaAsset, adapter: adapters.SourceAdapter, url: s
names that only repeated it. A name its owner gave (anything else) stays."""
if not adapter.is_placeholder_title(a.title):
return False
info = public_title_info({"title": a.title, "description": None}, url, adapter, a.id)
info = public_page_info({"title": a.title, "description": None}, url, adapter, a.id)
named = normalize_title(adapter.title(info))
if not named or adapter.is_placeholder_title(named):
return False
+4 -2
View File
@@ -32,6 +32,7 @@ from app.downloads.adapters import (
POST_NOT_SHOWN_MESSAGE,
POST_WITHOUT_VIDEO_MESSAGE,
facebook_group_post_id,
facebook_post_link,
)
from app.downloads.credentials import host_key_for_url
@@ -227,13 +228,14 @@ _CATALOG: tuple[_Entry, ...] = (
# answers "No video formats found" (measured on prod jobs 66 and 145, whose videos are reshared
# clips). With a cookie yt-dlp only ever gets the post's VIDEO link, so the same text there is about
# that video, not the post — the category says it. Only for group post links (the adapter's own
# test of one): a reel with no formats is a different failure.
# test of one): a reel with no formats is a different failure. The adapter's own text also comes
# from a share link that led to a post, so its test takes those too.
_Entry(
host="facebook",
markers=(POST_WITHOUT_VIDEO_MESSAGE.lower(),),
code="format_unavailable",
detail="facebook.group_post_video",
link=facebook_group_post_id,
link=facebook_post_link,
),
_Entry(
host="facebook",
+14 -15
View File
@@ -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
+4 -3
View File
@@ -591,15 +591,16 @@ def _download(
# too. Same probe the upload ingest uses.
vcodec, acodec, width, height, duration_s = probe.resolve_media_facts(produced, info)
# A placeholder title with nothing behind it ("Facebook" from a signed-in read) borrows the
# page's text from one signed-out read; the download itself stays the signed-in one. The page
# page's text (and a missing upload date) from one signed-out read; the download itself stays
# the signed-in one. The page
# yt-dlp read, not the link queued: a group post's link is rewritten to its video, and
# signed out the post itself is the login wall.
titled = backfill.public_title_info(info, info.get("webpage_url") or url, adapter, asset_id)
titled = backfill.public_page_info(info, info.get("webpage_url") or url, adapter, asset_id)
meta = storage.MediaMeta(
video_id=info.get("id") or source_ref,
title=normalize_title(adapter.title(titled)) or source_ref,
uploader=info.get("uploader") or info.get("channel") or "Unknown Channel",
upload_date=_parse_upload_date(info.get("upload_date")),
upload_date=_parse_upload_date(titled.get("upload_date")),
uniqueid_type=adapter.nfo_uniqueid_type(),
duration_s=duration_s,
description=info.get("description"),
+7 -3
View File
@@ -20,6 +20,8 @@ from datetime import date
from pathlib import Path
from xml.sax.saxutils import escape
from anyascii import anyascii
from app import logs
from app.downloads import mediakind
@@ -121,12 +123,14 @@ def files_root_problem(
def _ascii_fold(text: str) -> str:
"""Transliterate to pure ASCII: apply the explicit map, NFKD-split accented letters and drop
the combining marks (á→a, ő→o), then strip any leftover non-ASCII (non-Latin scripts that have
no ASCII base)."""
the combining marks (á→a, ő→o), then write what is left of other scripts in Latin letters
(`anyascii`: Сергей→Sergey). Before it, a non-Latin name was dropped whole, and every Hebrew,
Cyrillic, Greek or CJK uploader shared one "untitled" folder (prod asset 503). Anything still
not ASCII after that is stripped."""
text = text.translate(_FOLD_MAP)
text = unicodedata.normalize("NFKD", text)
text = "".join(ch for ch in text if unicodedata.category(ch) != "Mn")
return text.encode("ascii", "ignore").decode("ascii")
return anyascii(text).encode("ascii", "ignore").decode("ascii")
@dataclass
+2
View File
@@ -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)
)
+12
View File
@@ -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)."""
+14 -11
View File
@@ -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,
+14 -7
View File
@@ -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",
+3 -1
View File
@@ -7,7 +7,8 @@
# `pip install -r requirements.lock && pip check` must pass. In place so far: urllib3 2.7.0 -> 2.8.0 (PYSEC-2026-4175/4176/4177); yt-dlp 2026.7.4 -> 2026.8.19
# (it marks the clients that need a stream PO token, so the downloader stops fetching into a 403);
# certifi 2026.7.22 -> 2026.10.19 (GHSA-6q48-6c7w-9gw2); brotli 1.2.0 -> 1.2.1 (PYSEC-2026-4322);
# Authlib 1.7.2 -> 1.9.1 (CVE-2026-96760, CVE-2026-114616); joserfc 1.7.4 -> 1.7.6 (CVE-2026-119283, CVE-2026-119284).
# Authlib 1.7.2 -> 1.9.1 (CVE-2026-96760, CVE-2026-114616); joserfc 1.7.4 -> 1.7.6 (CVE-2026-119283, CVE-2026-119284);
# anyascii 0.3.3 added (no dependencies of its own, so the rest of the lock is unchanged).
APScheduler==3.11.3
Authlib==1.9.1
Mako==1.4.1
@@ -18,6 +19,7 @@ SQLAlchemy==2.0.52
alembic==1.19.1
annotated-doc==0.0.5
annotated-types==0.8.0
anyascii==0.3.3
anyio==4.14.2
argon2-cffi-bindings==25.1.0
argon2-cffi==25.1.0
+4
View File
@@ -13,6 +13,10 @@ py3langid>=0.3,<1.0
itsdangerous>=2.1,<3.0
cryptography>=50.0.0,<51
argon2-cffi>=23.1,<26
# On-disk names are ASCII (storage._ascii_fold): a Hebrew, Cyrillic, Greek or CJK uploader name is
# written in Latin letters instead of dropped whole into one shared "untitled" folder. Pure Python,
# no dependencies of its own.
anyascii>=0.3.3,<0.4
# Download center: yt-dlp does the extraction/download; ffmpeg (OS-level in the Dockerfile)
# does the merge + postprocessing. The [default] extra pulls yt-dlp-ejs (the challenge-solver
# scripts Deno runs for YouTube's n-signature). yt-dlp is release-often (YouTube changes), so
+63
View File
@@ -13,6 +13,7 @@ that the migration chain applies cleanly. Each test then gets a clean database v
tests that COMMIT (the whole point of this lane — transaction-ownership behaviour) don't leak.
"""
import datetime as _dt
import os
import shutil
from urllib.parse import quote
@@ -317,6 +318,68 @@ def _reset_rate_limiters():
obj.reset()
class _NeverBack(_dt.datetime):
"""The app's clock in tests: the wall clock, but never repeating and never going back — each
reading at least a microsecond after the last one in this process."""
last: _dt.datetime | None = None
clock: type[_dt.datetime] = (
_dt.datetime
) # what it reads; a probe can put a stepping one under it
@classmethod
def now(cls, tz=None):
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:
# 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) — 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 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)
def _reset_log_erasures():
"""`logs._erased` is module-level and remembers an erased account for an hour of monotonic time
+165 -2
View File
@@ -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)
+6
View File
@@ -218,6 +218,12 @@ def test_our_post_without_a_video_verdict_names_the_group_link():
"format_unavailable",
"facebook.group_post_video",
)
# The share link a post was reached by (prod job 471's kind) names the post too.
share = "https://www.facebook.com/share/v/19bCAHKyrd/"
assert dlerrors.diagnose(msg, url=share, cookie_attached=True) == (
"format_unavailable",
"facebook.group_post_video",
)
assert dlerrors.diagnose(msg, url=_REEL, cookie_attached=True) == ("format_unavailable", None)
@@ -27,6 +27,18 @@ _GROUP_LINK = f"https://www.facebook.com/groups/983554479435596/permalink/{_POST
_POST_VIDEO = "1408015447857753"
_FEED_VIDEO = "1056963063816966" # what yt-dlp downloaded from this post's link: a feed video
_CHROME = ImpersonateTarget.from_str("chrome")
# The two other ways a group post reaches us (prod jobs 471 and 472, the same post): Facebook's "Share"
# link, which redirects to the post's permalink (measured), and the link of a post opened from the
# group's feed, which yt-dlp does not know at all ("Unsupported URL").
_SHARE_LINK = "https://www.facebook.com/share/v/19bCAHKyrd/"
_LANDED = (
f"https://www.facebook.com/groups/it.humor.and.memes/permalink/{_POST}/"
"?rdid=y9HAMntF7aCoAKZE&share_url=https%3A%2F%2Fwww.facebook.com%2Fshare%2Fv%2F19bCAHKyrd%2F"
)
_MULTI_LINK = (
f"https://www.facebook.com/groups/983554479435596/?multi_permalinks={_POST}"
"&hoisted_section_header_type=recently_seen&__cft__[0]=AZgK&__tn__=%2CO%2CP-R"
)
def _video_node(video_id: str) -> str:
@@ -86,6 +98,9 @@ def ydl(monkeypatch):
body = web["page"].encode()
class Response:
# Where the request ended up after its redirects (yt-dlp's `Response.url`).
url = web.get("landed") or web["opened"][-1]
def read(self, amt=None):
web["reads"].append(amt)
return body
@@ -228,12 +243,79 @@ def test_without_a_cookie_the_group_post_link_goes_to_ytdlp_unchanged(ydl, tmp_p
assert web["extracted"] == [_GROUP_LINK]
def test_a_share_link_to_a_group_post_downloads_the_posts_own_video(ydl, tmp_path):
# Prod job 471: handed the share link, yt-dlp followed it to the group page and downloaded an
# ad from the signed-in feed. Followed here first, it is the group post it leads to.
_, outcomes, web = ydl
web["page"], web["landed"] = _page(_POST_VIDEO), _LANDED
outcomes += [{"id": _POST_VIDEO}]
assert _run(tmp_path, "/cookies.txt", _SHARE_LINK) == {"id": _POST_VIDEO}
assert web["opened"] == [_SHARE_LINK] # one request: the redirect and the post page at once
assert web["reads"] == [adapters.FacebookAdapter._POST_PAGE_MAX_BYTES]
assert web["extracted"] == [f"https://www.facebook.com/watch/?v={_POST_VIDEO}"]
def test_a_share_link_to_a_reel_goes_to_ytdlp_unchanged(ydl, tmp_path):
_, outcomes, web = ydl
web["landed"] = _URL
outcomes += [{"id": "806379462330494"}]
_run(tmp_path, "/cookies.txt", _SHARE_LINK)
assert web["reads"] == [] # not a post: its page is not read
assert web["extracted"] == [_SHARE_LINK] # the path that already downloads shared reels
def test_a_share_link_that_fails_as_a_group_post_says_why(ydl, tmp_path):
_, _, web = ydl
web["page"], web["landed"] = _page(), _LANDED
with pytest.raises(RuntimeError) as exc:
_run(tmp_path, "/cookies.txt", _SHARE_LINK)
assert web["extracted"] == []
# The job's own link is the share link: the verdict still names the post.
assert dlerrors.diagnose(str(exc.value), url=_SHARE_LINK, cookie_attached=True) == (
"format_unavailable",
"facebook.group_post_video",
)
def test_a_feed_opened_group_post_downloads_the_posts_own_video(ydl, tmp_path):
# Prod job 472: yt-dlp answers this link with "Unsupported URL".
_, outcomes, web = ydl
web["page"] = _page(_POST_VIDEO)
outcomes += [{"id": _POST_VIDEO}]
assert _run(tmp_path, "/cookies.txt", _MULTI_LINK) == {"id": _POST_VIDEO}
assert web["opened"] == [_MULTI_LINK]
assert web["extracted"] == [f"https://www.facebook.com/watch/?v={_POST_VIDEO}"]
def test_without_a_cookie_a_feed_opened_group_post_goes_as_its_permalink(ydl, tmp_path):
# Signed out there is no page to read, but yt-dlp knows the permalink form and not this one.
_, outcomes, web = ydl
outcomes += [{"id": _POST_VIDEO}]
_run(tmp_path, None, _MULTI_LINK)
assert web["opened"] == []
assert web["extracted"] == [
f"https://www.facebook.com/groups/983554479435596/permalink/{_POST}/"
]
def test_the_group_post_link_forms_are_recognised():
for url in (
_GROUP_LINK,
f"https://www.facebook.com/groups/983554479435596/posts/{_POST}/",
f"https://m.facebook.com/groups/983554479435596/permalink/{_POST}/?",
f"https://www.facebook.com/groups/chatgptmagyarul/posts/{_POST}",
_MULTI_LINK,
f"https://www.facebook.com/groups/983554479435596?multi_permalinks={_POST}",
):
assert adapters.facebook_group_post_id(url) == _POST, url
for url in (
@@ -241,6 +323,10 @@ def test_the_group_post_link_forms_are_recognised():
f"https://www.facebook.com/watch/?v={_POST_VIDEO}",
f"https://facebook.com.evil/groups/x/permalink/{_POST}/",
"https://www.facebook.com/groups/983554479435596/",
# More than one post in the link: which one was meant is not ours to guess.
f"https://www.facebook.com/groups/983554479435596/?multi_permalinks={_POST},{_POST_VIDEO}",
f"https://www.facebook.com/groups/983554479435596/members/?multi_permalinks={_POST}",
_SHARE_LINK, # a share link is a post only once it is followed (`resolve_url`)
):
assert adapters.facebook_group_post_id(url) is None, url
assert adapters.SourceAdapter().resolve_url(_GROUP_LINK, {"cookiefile": "/c"}) == _GROUP_LINK
+41 -9
View File
@@ -22,6 +22,9 @@ _PUBLIC = {
"description": "sorry not helping\n\n#workhumor #officelife",
"uploader": "Work dot exe",
}
# A signed-out read carries the upload date too (measured 2026-11-14 on watch?v=1140126121708919:
# "20261113"), which the signed-in read no longer gives.
_PUBLIC_DATED = {**_PUBLIC, "upload_date": "20261113"}
def _fake_ydl(monkeypatch, outcome):
@@ -54,7 +57,7 @@ def test_a_placeholder_with_nothing_behind_it_takes_the_signed_out_pages_text(mo
seen = _fake_ydl(monkeypatch, _PUBLIC)
fb = adapters.FacebookAdapter()
info = backfill.public_title_info({"title": "Facebook", "description": None}, _URL, fb, 7)
info = backfill.public_page_info({"title": "Facebook", "description": None}, _URL, fb, 7)
assert fb.title(info) == "sorry not helping"
# One metadata-only read, and signed out: no cookie goes with it.
@@ -64,11 +67,36 @@ def test_a_placeholder_with_nothing_behind_it_takes_the_signed_out_pages_text(mo
assert seen[0][egress.GUARDED] is True and seen[0]["proxy"] == egress.proxy_url()
def test_the_signed_out_read_also_lends_the_upload_date(monkeypatch):
# Measured on prod: since 2026-10-28 no Facebook download has an upload date (37 before had one,
# 31 since none) — the signed-in read lost it with the title. The signed-out read still has it.
_fake_ydl(monkeypatch, _PUBLIC_DATED)
info = backfill.public_page_info(
{"title": "Facebook", "description": None}, _URL, adapters.FacebookAdapter(), 7
)
assert info["upload_date"] == "20261113"
def test_a_date_the_signed_in_read_gave_stays(monkeypatch):
_fake_ydl(monkeypatch, _PUBLIC_DATED)
info = backfill.public_page_info(
{"title": "Facebook", "description": None, "upload_date": "20261001"},
_URL,
adapters.FacebookAdapter(),
7,
)
assert info["upload_date"] == "20261001"
def test_a_real_title_reads_nothing_more(monkeypatch):
seen = _fake_ydl(monkeypatch, _PUBLIC)
info = {"title": "Funny Dark Humor Dad Joke!", "description": None}
assert backfill.public_title_info(info, _URL, adapters.FacebookAdapter(), 7) is info
assert backfill.public_page_info(info, _URL, adapters.FacebookAdapter(), 7) is info
assert seen == []
@@ -76,7 +104,7 @@ def test_a_placeholder_with_the_posts_text_behind_it_reads_nothing_more(monkeypa
seen = _fake_ydl(monkeypatch, _PUBLIC)
info = {"title": "Video", "description": "Office jokes, part two"}
assert backfill.public_title_info(info, _URL, adapters.FacebookAdapter(), 7) is info
assert backfill.public_page_info(info, _URL, adapters.FacebookAdapter(), 7) is info
assert seen == []
@@ -84,7 +112,7 @@ def test_a_placeholder_the_signed_out_page_cannot_better_stays(monkeypatch):
_fake_ydl(monkeypatch, {"title": "Facebook", "description": None})
fb = adapters.FacebookAdapter()
info = backfill.public_title_info({"title": "Facebook", "description": None}, _URL, fb, 7)
info = backfill.public_page_info({"title": "Facebook", "description": None}, _URL, fb, 7)
assert fb.title(info) == "Facebook"
@@ -93,7 +121,7 @@ def test_another_source_is_never_read_again(monkeypatch):
seen = _fake_ydl(monkeypatch, _PUBLIC)
info = {"title": "Video", "description": None}
assert backfill.public_title_info(info, _URL, adapters.SourceAdapter(), 7) is info
assert backfill.public_page_info(info, _URL, adapters.SourceAdapter(), 7) is info
assert seen == []
@@ -180,7 +208,7 @@ def test_a_name_given_while_the_backfill_runs_stays(db, make_user, monkeypatch):
db.add(job)
db.commit()
read_page = backfill.public_title_info
read_page = backfill.public_page_info
def renamed_while_reading(*args):
with SessionLocal() as other:
@@ -192,7 +220,7 @@ def test_a_name_given_while_the_backfill_runs_stays(db, make_user, monkeypatch):
other.commit()
return read_page(*args)
monkeypatch.setattr(backfill, "public_title_info", renamed_while_reading)
monkeypatch.setattr(backfill, "public_page_info", renamed_while_reading)
monkeypatch.setattr(backfill.editmod, "ensure_poster", lambda a, root: None)
assert backfill.run()["retitled"] == 1
@@ -252,8 +280,8 @@ def test_a_download_titled_facebook_is_named_from_the_signed_out_page(
db, make_user, monkeypatch, tmp_path
):
# The whole download path with yt-dlp and the media tools faked: the signed-in read gives only
# the placeholder, and the file and the download's name get the post's text.
seen = _fake_ydl(monkeypatch, _PUBLIC)
# the placeholder, and the file and the download's name get the post's text — and its date.
seen = _fake_ydl(monkeypatch, _PUBLIC_DATED)
monkeypatch.setattr(settings, "files_root", str(tmp_path))
monkeypatch.setattr(pipeline.urlguard, "check_url", lambda url: None)
monkeypatch.setattr(pipeline, "_ensure_phone_safe", lambda _j, produced, *_a: produced)
@@ -301,3 +329,7 @@ def test_a_download_titled_facebook_is_named_from_the_signed_out_page(
assert db.get(DownloadJob, job.id).status == "done"
assert db.get(MediaAsset, asset.id).title == "sorry not helping"
assert db.get(DownloadJob, job.id).display_name == "sorry not helping"
done = db.get(MediaAsset, asset.id)
assert done.upload_date is not None and done.upload_date.isoformat() == "2026-11-13"
# The date names the file and its season, not 0000-00-00 / Season_0.
assert done.rel_path.startswith("Work_dot_exe/Season_2026/Work_dot_exe_-_2026-11-13_-_")
+22
View File
@@ -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).
+23
View File
@@ -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()
+15 -3
View File
@@ -26,9 +26,21 @@ def test_sanitize_output_is_pure_ascii_without_spaces():
assert _is_ascii_slug(out), out
def test_sanitize_non_latin_falls_back_not_crashes():
# A title with no ASCII base collapses to the untitled fallback rather than an empty name.
assert storage.sanitize("日本語") == "untitled"
def test_sanitize_writes_a_non_latin_name_in_latin_letters():
# Prod asset 503: a Hebrew uploader name was dropped whole, so the file went to "untitled/", the
# folder every Hebrew, Cyrillic, Greek or CJK name would share — one show in Plex for all of them.
names = ("מַלכָּה אָדוֹם", "Сергей Иванов", "Ελληνικά Νέα", "日本語", "محمد علي")
slugs = [storage.sanitize(n) for n in names]
for name, slug in zip(names, slugs, strict=True):
assert _is_ascii_slug(slug) and slug != "untitled", (name, slug)
assert len(set(slugs)) == len(slugs), slugs # two names, two folders
# The measured spellings (anyascii 0.3.3, the locked one).
assert slugs == ["mlkhh_dvm", "Sergei_Ivanov", "Ellinika_Nea", "RiBenYu", "mhmd_ly"]
def test_sanitize_falls_back_when_nothing_is_left():
# Emoji and symbols are dropped, and a name of nothing else still gets a folder.
assert storage.sanitize("🎉🎉") == "untitled"
def test_display_filename_uses_underscores_and_ascii():
+22
View File
@@ -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")
-25
View File
@@ -22,31 +22,6 @@ from app.runstatus import RunStatus, RunTrigger
_JOB = "unit_test_claim" # not a real job id — exercises the primitive without touching live jobs
class _StrictlyLater(datetime):
"""A clock that never repeats or goes back: each reading at least a microsecond after the last."""
last: datetime | None = None
clock: type[datetime] = datetime
@classmethod
def now(cls, tz=None):
t = cls.clock.now(tz)
if cls.last is not None and t <= cls.last:
t = cls.last + timedelta(microseconds=1)
cls.last = t
return t
@pytest.fixture(autouse=True)
def _runs_start_in_order(monkeypatch):
"""Runs started in a row start in that order. These tests open several runs within microseconds
and read them back newest first, by start time; the wall clock of a container on a VM can step
back between two readings (seen: the third of three runs sorted before the second)."""
_StrictlyLater.last = None
_StrictlyLater.clock = jobruns.datetime
monkeypatch.setattr(jobruns, "datetime", _StrictlyLater)
def _release():
scheduler._record(_JOB, running=False)
+74
View File
@@ -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"
+1
View File
@@ -75,6 +75,7 @@ CORE_MODELS = {
"User",
"OAuthToken",
"AuthToken",
"RevokedSession",
"Invite",
"ApiQuotaUsage",
"QuotaEvent",
+4
View File
@@ -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 |
+46
View File
@@ -24,6 +24,52 @@ test.describe("Files", () => {
await expect(dialog(page)).toBeHidden();
});
// WebKit (every iPad browser) cannot resolve a percentage height against a box whose height comes
// from aspect-ratio inside a table cell: the picture took its own full height and the 16:9 box
// showed a native-size sliver of it, a blur of colour. The library is stubbed with one file whose
// poster is a tall picture, the case that shows it most.
test("a row's picture fits its box", async ({ page }) => {
const file = {
id: 990001,
status: "done",
progress: 100,
created_at: "2026-11-14T10:00:00+00:00",
source_kind: "youtube",
source_ref: "e2e-thumb",
source_url: null,
poster_url: "/api/downloads/990001/poster.jpg",
display_name: "A tall poster",
display_uploader: null,
extra_links: [],
can_download: true,
job_kind: "download",
folder_id: null,
kept: false,
expires_at: null,
shared: false,
source_key: "youtube",
asset: { media_kind: "video", duration_s: 60, size_bytes: 2048 },
};
await page.route(/\/api\/files(\?|$)/, (route) => route.fulfill({ json: [file] }));
await page.route(/\/api\/downloads\/990001\/poster\.jpg/, (route) =>
route.fulfill({
contentType: "image/svg+xml",
body: '<svg xmlns="http://www.w3.org/2000/svg" width="1080" height="1920"><rect width="1080" height="1920" fill="#3a6"/></svg>',
}),
);
// Wide enough that the picture column shows beside a pinned rail (an iPad Pro in landscape).
await page.setViewportSize({ width: 1366, height: 1024 });
await openApp(page);
await gotoPage(page, "files");
const picture = page.getByRole("row", { name: /A tall poster/ }).locator("img");
await expect(picture).toBeVisible();
const fit = await picture.evaluate((img) => {
const box = img.parentElement!.getBoundingClientRect();
return { img: img.getBoundingClientRect().height, box: box.height };
});
expect(fit.img).toBeLessThanOrEqual(fit.box);
});
test("a link pasted onto a page (not into a field) opens the download dialog", async ({
page,
}) => {
+7 -2
View File
@@ -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 (
<span
className={`tabular-nums text-2xs leading-none px-1.5 py-0.5 rounded-full ${
active ? "bg-accent-fg/20" : "bg-accent/20 text-accent"
}`}
>
{count}
{formatCompact(count, i18n.language)}
</span>
);
}
+8 -8
View File
@@ -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 `<button>` or a bare `FocusSafeButton` outside the control primitives — eslint `BUTTON_PRIMITIVES` — is a lint error), `Input` + `fieldStyles`, `RenameField` (a name typed in place of the name: Enter or blur saves, Escape cancels; the caller hands the keyboard back), `DropZone` (the dashed place a file upload starts from: a drop, or a click that opens the file dialog), `Meter` (how full something is against a limit — one danger line for every place showing the same measure), `TimecodeField` + `useTimecodeField` / `TimecodeError` (a cut's time typed in: the draft, held by the caller, until Enter or blur takes it, then the value as it landed; unreadable stays once offered, marked invalid, and the caller places the line saying how to type one in its flow, outside a label; a draft goes when the value moves under it), `NativeControls`, `MenuCheck` (the drawn check of a menu row or a whole-row checkbox button; its tick is the `chosen` colour, which `media-marks` turns amber on the player chrome), `mediaChrome` (`MEDIA_CHOSEN`, a chosen tab or toggle on the chrome), `SelectTrigger` (the field family; a raw `<input>` is a lint error), `useReturnFocus`, `unavailable` (an off control's props: with a reason it stays focusable, `aria-disabled` and the reason in its tooltip; without one, `disabled`), `surfaces` (the B5 glass roles; a bare glass class is a lint error), `Badge` (a static label), `LinkButton` / `IconLink` (a link wearing the button face) over `buttonStyles`, `Skeleton` (B8 first loads, through `LoadingState shape`), `NavRail` (B7; `NavRailGroup` folds a set of rare pages into one row with a panel), `form`, `DraftSaveBar`, `Combobox`, `FilterSelect`, `SegmentedToggle`, `SortControl`, `ColumnSortControl`, `PriorityStepper`, `UndoToolbar`, `ViewSwitcher` (a list's display modes behind one toolbar icon), `copyItems` (`useCopyItems`: the "Copy title" / "Copy link" rows every object's menu ends with) + `useCopyFeedback` (a Copy button's "Copied" moment), `MatchToggle` (a multi-value filter's any/all switch), `LanguageSwitcher` (presentational: the caller decides what a change does), `sortableA11y` (what a screen reader hears during a keyboard reorder) |
| data display | `DataTable` (+ `progressiveRows`: a long page mounts in steps; `rowOpen`: a press on the row runs its default action), `tableHeader` + `tableAlign`, `HiddenFilterCue` (names a filter whose column is off screen), `Pager` + `pagerContent`, `VirtualGrid` + `gridColumns` (the column/width maths, incl. the ultrawide `maxCols` cap), `readAhead`, `Carousel`, `MediaCard` + `mediaCardStyles` (the one media card, docs/DESIGN.md "Media card") + `MediaPreview` / `useMediaPreview` (its mouse-rest preview), `NavGrid` (a plain grid, list or table body the keyboard walks — `DataTable`, `VirtualGrid` and `Carousel` take `keyboardNav`), `RemoteImage` (every network picture: no Referer, retries past the negative cache, a fallback instead of the broken-image mark — a bare `<img>` is linted out), `Avatar`, `CountBadge`, `ItemCount`, `FilterChipGroup`, `RelativeTime` ("3d ago" with the exact minute on hover, docs/DESIGN.md "Copy"), `ClampedText` (a long text clamped to a few lines; More / Less only when it is cut), `StatTiles` + `StatTile` / `StatBars` (number tiles, a breakdown as horizontal bars), `RangeTrack` + `trackPosition` + `RulerTicks` (a time track over a window of a media item: regions, slider ends the arrows step, a playhead, a background slot — the editor's cuts, the players' clip panel), `CropFrame` (a crop frame over a picture, on the picture and not its bars: drag, edges, corners, the arrows; the editor's crop, the players' clip crop), `reveal` (a card's hover chrome: hover or keyboard focus on a mouse, never on touch) |
| page & panel structure | `PageShell` + `pageToolbarSlot` + `pageHeaderSlot` (`PageHeader`: a page fills the floating header) + `pageHeading`, `PageScroller`, `Headings` (`PageHeading`, `SectionLabel`), `SkipLink` + `landmarks`, `FilterFold` (B4), `scrollFold` (`useScrollFold`: a fixed header that folds once its content scrolls), `PanelGroup`, `PanelGroups`, `Tabs` + `tabPanel`, `BackToTop` + `backToTopState` (PageScroller keeps the page's end clear of it), `FilterRail` (the filter-rail shell a rail fills with its groups: title bar, customize, reset), `stickyTop` (a sticky header at the scroller's top turns its top fade off) |
| state & messaging | `QueryState`, `Banner`, `InlineNotice`, `CrashBoundary` |
| 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 `<button>` or a bare `FocusSafeButton` outside the control primitives — eslint `BUTTON_PRIMITIVES` — is a lint error), `Input` + `fieldStyles`, `RenameField` (a name typed in place of the name: Enter or blur saves, Escape cancels; the caller hands the keyboard back), `DropZone` (the dashed place a file upload starts from: a drop, or a click that opens the file dialog), `Meter` (how full something is against a limit — one danger line for every place showing the same measure), `TimecodeField` + `useTimecodeField` / `TimecodeError` (a cut's time typed in: the draft, held by the caller, until Enter or blur takes it, then the value as it landed; unreadable stays once offered, marked invalid, and the caller places the line saying how to type one in its flow, outside a label; a draft goes when the value moves under it), `NativeControls`, `MenuCheck` (the drawn check of a menu row or a whole-row checkbox button; its tick is the `chosen` colour, which `media-marks` turns amber on the player chrome), `mediaChrome` (`MEDIA_CHOSEN`, a chosen tab or toggle on the chrome), `SelectTrigger` (the field family; a raw `<input>` is a lint error), `useReturnFocus`, `unavailable` (an off control's props: with a reason it stays focusable, `aria-disabled` and the reason in its tooltip; without one, `disabled`), `surfaces` (the B5 glass roles; a bare glass class is a lint error), `Badge` (a static label), `LinkButton` / `IconLink` (a link wearing the button face) over `buttonStyles`, `Skeleton` (B8 first loads, through `LoadingState shape`), `NavRail` (B7; `NavRailGroup` folds a set of rare pages into one row with a panel), `form`, `DraftSaveBar`, `Combobox`, `FilterSelect`, `SegmentedToggle`, `SortControl`, `ColumnSortControl`, `PriorityStepper`, `UndoToolbar`, `ViewSwitcher` (a list's display modes behind one toolbar icon), `copyItems` (`useCopyItems`: the "Copy title" / "Copy link" rows every object's menu ends with) + `useCopyFeedback` (a Copy button's "Copied" moment), `MatchToggle` (a multi-value filter's any/all switch), `YearPicker` (one calendar year from a list of years with counts: the recent ones as chips, every one in a decade-grid dialog; the feed and Plex rails), `LanguageSwitcher` (presentational: the caller decides what a change does), `sortableA11y` (what a screen reader hears during a keyboard reorder) |
| data display | `DataTable` (+ `progressiveRows`: a long page mounts in steps; `rowOpen`: a press on the row runs its default action), `tableHeader` + `tableAlign`, `HiddenFilterCue` (names a filter whose column is off screen), `Pager` + `pagerContent`, `VirtualGrid` + `gridColumns` (the column/width maths, incl. the ultrawide `maxCols` cap), `readAhead`, `Carousel`, `MediaCard` + `mediaCardStyles` (the one media card, docs/DESIGN.md "Media card") + `MediaPreview` / `useMediaPreview` (its mouse-rest preview), `NavGrid` (a plain grid, list or table body the keyboard walks — `DataTable`, `VirtualGrid` and `Carousel` take `keyboardNav`), `RemoteImage` (every network picture: no Referer, retries past the negative cache, a fallback instead of the broken-image mark — a bare `<img>` is linted out), `Avatar`, `CountBadge`, `ItemCount`, `FilterChipGroup`, `RelativeTime` ("3d ago" with the exact minute on hover, docs/DESIGN.md "Copy"), `ClampedText` (a long text clamped to a few lines; More / Less only when it is cut), `StatTiles` + `StatTile` / `StatBars` (number tiles, a breakdown as horizontal bars), `RangeTrack` + `trackPosition` + `RulerTicks` (a time track over a window of a media item: regions, slider ends the arrows step, a playhead, a background slot — the editor's cuts, the players' clip panel), `CropFrame` (a crop frame over a picture, on the picture and not its bars: drag, edges, corners, the arrows; the editor's crop, the players' clip crop), `reveal` (a card's hover chrome: hover or keyboard focus on a mouse, never on touch) |
| page & panel structure | `PageShell` + `pageToolbarSlot` + `pageHeaderSlot` (`PageHeader`: a page fills the floating header) + `pageHeading`, `PageScroller`, `Headings` (`PageHeading`, `SectionLabel`), `SkipLink` + `landmarks`, `FilterFold` (B4), `scrollFold` (`useScrollFold`: a fixed header that folds once its content scrolls), `PanelGroup`, `PanelGroups`, `Tabs` + `tabPanel`, `BackToTop` + `backToTopState` (PageScroller keeps the page's end clear of it), `FilterRail` (the filter-rail shell a rail fills with its groups: title bar, customize, reset), `stickyTop` (a sticky header at the scroller's top turns its top fade off) |
| state & messaging | `QueryState`, `Banner`, `InlineNotice`, `CrashBoundary` |
## The size contract (E10 S1c)
@@ -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
@@ -0,0 +1,179 @@
// @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";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return {
...actual,
useTranslation: () => ({
t: (k: string, o?: Record<string, unknown>) =>
o && "from" in o ? `${k}:${String(o.from)}-${String(o.to)}` : k,
i18n: { language: "en" },
}),
};
});
vi.mock("../../lib/history", () => ({ useBackToClose: () => {} }));
import YearPicker from "./YearPicker";
afterEach(cleanup);
// Eight years over three decades, 1987 and 2014 without titles.
const YEARS = [
{ year: 2026, count: 3 },
{ year: 2024, count: 9 },
{ year: 2023, count: 1 },
{ year: 2021, count: 4 },
{ year: 2020, count: 2 },
{ year: 2015, count: 5 },
{ year: 2013, count: 7 },
{ year: 1985, count: 6 },
];
const open = () => fireEvent.click(screen.getByRole("button", { name: /sidebar\.year\.more/ }));
describe("YearPicker", () => {
it("shows the six newest years as chips, the selected one kept beside them", () => {
render(<YearPicker years={YEARS} selected={1985} onPick={() => {}} />);
const chips = screen.getAllByRole("button", { pressed: false }).map((b) => b.textContent);
expect(chips.slice(0, 6)).toEqual(["20263", "20249", "20231", "20214", "20202", "20155"]);
expect(screen.getByRole("button", { pressed: true }).textContent).toBe("19856");
});
it("lays every year out by decade in the dialog; a year without titles is not a choice", () => {
render(<YearPicker years={YEARS} onPick={() => {}} />);
open();
const dialog = screen.getByRole("dialog");
const rows = within(dialog).getAllByRole("group", { name: /sidebar\.year\.decade/ });
expect(rows.map((r) => r.getAttribute("aria-label"))).toEqual([
"sidebar.year.decade:2020-2029",
"sidebar.year.decade:2010-2019",
"sidebar.year.decade:1980-1989",
]);
const in1980s = within(rows[2]!)
.getAllByRole("button")
.map((b) => b.dataset.year);
expect(in1980s).toEqual(["1985"]);
expect(rows[1]!.querySelector('[data-year="2014"]')).toBeNull();
});
it("sorted by count, the busiest year comes first", () => {
render(<YearPicker years={YEARS} onPick={() => {}} />);
open();
fireEvent.click(screen.getByRole("button", { name: "sidebar.year.sortCount" }));
const order = within(screen.getByRole("dialog"))
.getAllByRole("button")
.map((b) => b.dataset.year)
.filter(Boolean);
expect(order.slice(0, 3)).toEqual(["2024", "2013", "1985"]);
});
it("a pick in the dialog names the year and closes it; the selected year again clears it", () => {
const onPick = vi.fn();
const { rerender } = render(<YearPicker years={YEARS} onPick={onPick} />);
open();
fireEvent.click(screen.getByRole("dialog").querySelector('[data-year="1985"]')!);
expect(onPick).toHaveBeenLastCalledWith(1985);
expect(screen.queryByRole("dialog")).toBeNull();
rerender(<YearPicker years={YEARS} selected={2024} onPick={onPick} />);
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(<YearPicker years={YEARS.slice(0, 7)} selected={1999} onPick={() => {}} />);
expect(screen.getByRole("button", { name: "sidebar.year.more" })).toBeTruthy();
});
it("offers no More… once every year with titles is a chip", () => {
const { rerender } = render(<YearPicker years={YEARS.slice(0, 6)} onPick={() => {}} />);
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(<YearPicker years={YEARS.slice(0, 7)} selected={2013} onPick={() => {}} />);
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 <body>", () => {
// Seven years: the pick keeps 2013 as a chip, so More… leaves with the dialog it opened.
function Picker() {
const [year, setYear] = useState<number | undefined>();
return <YearPicker years={YEARS.slice(0, 7)} selected={year} onPick={setYear} />;
}
vi.useFakeTimers();
try {
render(<Picker />);
const more = screen.getByRole("button", { name: "sidebar.year.more" });
more.focus();
fireEvent.click(more);
const cell = screen.getByRole("dialog").querySelector<HTMLElement>('[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(<YearPicker years={[{ year: 2025, count: 17243 }]} onPick={() => {}} />);
expect(screen.getByRole("button").textContent).toBe("202517.2K");
});
it("More… is a plain chip: the ellipsis says a dialog follows, no chevron", () => {
render(<YearPicker years={YEARS} onPick={() => {}} />);
expect(
screen.getByRole("button", { name: "sidebar.year.more" }).querySelector("svg"),
).toBeNull();
});
it("clearing the kept older year hands the focus on, not to <body>", () => {
const { rerender } = render(<YearPicker years={YEARS} selected={1985} onPick={() => {}} />);
const kept = screen.getByRole("button", { pressed: true });
kept.focus();
fireEvent.click(kept);
rerender(<YearPicker years={YEARS} onPick={() => {}} />);
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(<YearPicker years={YEARS} onPick={() => {}} />);
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(<YearPicker years={[...YEARS, { year: 2025, count: 17243 }]} onPick={() => {}} />);
open();
const cell = screen.getByRole("dialog").querySelector<HTMLElement>('[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(<YearPicker years={YEARS} onPick={() => {}} />);
open();
const dialog = screen.getByRole("dialog");
dialog.querySelector<HTMLElement>('[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 }),
);
});
}
+203
View File
@@ -0,0 +1,203 @@
import { useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
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;
count: number;
}
// 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, 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…"
type Sort = "year" | "count";
export default function YearPicker({
years,
selected,
onPick,
}: {
/** Every year that has titles, with its count; any order. */
years: readonly YearCount[];
selected?: number;
onPick: (year?: number) => void;
}) {
const { t, i18n } = useTranslation();
const [open, setOpen] = useState(false);
const [sort, setSort] = useState<Sort>("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(() => {
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 chipsRef = useRef<HTMLDivElement>(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 <body>, 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<HTMLElement>(`[data-year="${year}"]`);
(chip ?? row.querySelector<HTMLElement>("button"))?.focus({ preventScroll: true });
}, 0);
};
const cell = (y: YearCount, index: number) => {
const on = selected === y.year;
return (
<Chip
key={y.year}
size="cell"
pressed={on}
className="w-full"
onClick={() => pickAndClose(y.year)}
data-year={y.year}
{...navItemAttrs(index)}
>
<span className="tabular-nums">{y.year}</span>
<span className={on ? "text-2xs tabular-nums" : "text-muted text-2xs tabular-nums"}>
{formatCompact(y.count, i18n.language)}
</span>
</Chip>
);
};
return (
<div ref={chipsRef} className="flex flex-wrap gap-1.5">
{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 <body> (which also folded an unpinned rail).
const leaves = on && !recent.some((r) => r.year === y.year);
return (
<Chip
key={y.year}
pressed={on}
data-year={y.year}
onClick={(e) => {
if (leaves && e.currentTarget === document.activeElement)
handOffFocus(e.currentTarget, () => toggle(y.year));
else toggle(y.year);
}}
>
<span className="tabular-nums">{y.year}</span>
{y.count > 0 && <CountBadge count={y.count} active={on} />}
</Chip>
);
})}
{more && (
<Chip aria-haspopup="dialog" onClick={() => setOpen(true)}>
{t("sidebar.year.more")}
</Chip>
)}
{open && (
<Modal
title={t("sidebar.year.moreLabel")}
initialFocus="close"
onClose={() => setOpen(false)}
maxWidth="max-w-4xl"
>
<div className="mb-3 flex items-center justify-end gap-2">
<span className="text-muted text-xs" aria-hidden="true">
{t("sidebar.year.sortBy")}
</span>
<SegmentedToggle
ariaLabel={t("sidebar.year.sortBy")}
value={sort}
onChange={setSort}
options={[
{ value: "year", label: t("sidebar.year.sortYear") },
{ value: "count", label: t("sidebar.year.sortCount") },
]}
/>
</div>
{sort === "year" ? (
<DecadeGrid years={newestFirst} cell={cell} />
) : (
<NavGrid className="grid grid-cols-[repeat(auto-fill,minmax(4rem,1fr))] gap-1.5">
{[...years].sort((a, b) => b.count - a.count || b.year - a.year).map(cell)}
</NavGrid>
)}
</Modal>
)}
</div>
);
}
/** 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, 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 (
<div className="flex flex-col gap-1.5">
{decades.map((from) => {
const row = Array.from({ length: 10 }, (_, i) => byYear.get(from + i));
return (
<div
key={from}
role="group"
aria-label={t("sidebar.year.decade", { from, to: from + 9 })}
className="grid grid-cols-[3rem_minmax(0,1fr)] items-center gap-1.5"
>
{/* The group's name says the decade; read twice, it was noise. */}
<span className="text-muted text-xs tabular-nums" aria-hidden="true">
{from}
</span>
<NavGrid className="grid grid-cols-10 items-center gap-1.5">
{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)
) : (
<span
key={from + i}
aria-hidden="true"
className="text-muted/50 text-center text-xs tabular-nums"
>
{from + i}
</span>
),
)}
</NavGrid>
</div>
);
})}
</div>
);
}
+8 -5
View File
@@ -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<ChipVariant, string> = {
// `.pill` owns the resting, hover AND pressed colours; a colour utility here would beat the pressed
@@ -23,13 +24,15 @@ const PAINT: Record<ChipVariant, string> = {
};
const SIZES: Record<ChipSize, string> = {
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. */
@@ -35,7 +35,7 @@ const IV = keys<IconButtonVariant>({
}); // prettier-ignore
const IS = keys<IconButtonSize>({ sm: true, md: true, lg: true, player: true });
const CV = keys<ChipVariant>({ filter: true, token: true, tag: true, suggest: true, ghost: true });
const CS = keys<ChipSize>({ md: true, sm: true, xs: true });
const CS = keys<ChipSize>({ md: true, sm: true, xs: true, cell: true });
const missing = (emitted: string[], file: string) => {
const found = tokens(file);
@@ -1,4 +1,38 @@
[
{
"version": "0.120.1",
"date": "2026-11-14",
"new": [],
"fixed": [
"On an iPad, the pictures in the Files list show the whole poster again, not a blurred close-up of a small part of it."
]
},
{
"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",
"new": [],
"fixed": [
"A Facebook download gets its upload date again, so its file and season are named by it, not 0000-00-00. A channel name in another script, such as Hebrew or Cyrillic, is written in Latin letters in file names instead of becoming \"untitled\"."
]
},
{
"version": "0.119.1",
"date": "2026-11-14",
"new": [],
"fixed": [
"A Facebook group post pasted as its Share link, or opened from the group's feed, now downloads the post's own video. Before, a Share link could bring down another video from the feed, and a feed link failed with \"Site not supported\"."
]
},
{
"version": "0.119.0",
"date": "2026-11-13",
@@ -1,4 +1,38 @@
[
{
"version": "0.120.1",
"date": "2026-11-14",
"new": [],
"fixed": [
"iPaden a Fájlok listájában a képek újra a teljes posztert mutatják, nem egy elmosódott, felnagyított részletet."
]
},
{
"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",
"new": [],
"fixed": [
"A Facebook-letöltések újra megkapják a feltöltés dátumát, így a fájl és az évad neve azt mutatja, nem 0000-00-00-t. A más írású, például héber vagy cirill csatornanév a fájlnevekben latin betűkkel jelenik meg, nem „untitled” lesz."
]
},
{
"version": "0.119.1",
"date": "2026-11-14",
"new": [],
"fixed": [
"Egy Facebook-csoport posztja a Megosztás linkjével vagy a csoport hírfolyamából megnyitva is a poszt saját videóját tölti le. Eddig a Megosztás linkje a hírfolyam egy másik videóját is hozhatta, a hírfolyam linkje pedig „Nem támogatott oldal” hibát adott."
]
},
{
"version": "0.119.0",
"date": "2026-11-13",
+2
View File
@@ -76,6 +76,8 @@
"rating": "IMDb rating",
"genre": "Genre",
"year": "Year",
"yearFrom": "From year",
"yearTo": "To year",
"duration": "Length",
"durationUnit": "min",
"durationOpt": {
+2 -1
View File
@@ -34,8 +34,9 @@
"year": "Year"
},
"year": {
"more": "More",
"more": "More…",
"moreLabel": "All years",
"decade": "{{from}}–{{to}}",
"none": "No years yet",
"sortBy": "Sort",
"sortYear": "Year",
+2
View File
@@ -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": {
+2 -1
View File
@@ -34,8 +34,9 @@
"year": "Év"
},
"year": {
"more": "Több",
"more": "Több…",
"moreLabel": "Összes év",
"decade": "{{from}}–{{to}}",
"none": "Még nincs év",
"sortBy": "Rendezés",
"sortYear": "Év",
+2
View File
@@ -52,6 +52,8 @@ import { plexApi } from "./api/plex";
export type * from "./api/plex";
export {
EMPTY_PLEX_FILTERS,
oneYear,
singleYear,
plexFilterCount,
coercePlexFilters,
plexFiltersSignature,
+13
View File
@@ -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<PlexFilters, "yearMin" | "yearMax"> => ({
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<PlexFilters, "yearMin" | "yearMax">): 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[];
@@ -410,6 +421,8 @@ export interface PlexFacets {
studios: { value: string; count: number }[]; // studio vocabulary for the sidebar picker
// Playback-class facet (movie-only): value ∈ direct|remux|transcode_hw|transcode_sw. Empty on shows.
playable: { value: string; count: number }[];
// Titles per year, newest first, the year filter itself left out (the sidebar's year picker).
years: { year: number; count: number }[];
year_min: number | null;
year_max: number | null;
rating_max: number | null;
@@ -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<PlexFilters>) =>
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");
});
});
+9 -6
View File
@@ -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, unknown>) => 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}`,
@@ -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<typeof import("react-i18next")>();
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(
<QueryClientProvider client={new QueryClient()}>
<YearFilter filters={filters} setFilters={setFilters} />
</QueryClientProvider>,
);
fireEvent.click(await screen.findByRole("button", { name: /2010/ }));
expect(setFilters).toHaveBeenCalledWith({
...filters,
year: 2010,
maxAgeDays: undefined,
dateFrom: undefined,
dateTo: undefined,
});
});
});
+20 -131
View File
@@ -1,31 +1,21 @@
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { ChevronDown } from "lucide-react";
import { MenuCheck } from "../../components/ui/MenuCheck";
import { qk } from "../../lib/queryKeys";
import { api, type FeedFilters, type YearCount } from "../../lib/api";
import { PopoverItem, PopoverSurface } from "../../components/ui/AnchoredPopover";
import { useAnchoredPopover } from "../../components/ui/anchoredPopoverHooks";
import { Chip } from "../../components/ui/Chip";
import { Button } from "../../components/ui/Button";
import CountBadge from "../../components/ui/CountBadge";
import YearPicker from "../../components/ui/YearPicker";
import { useDebounced } from "../../lib/useDebounced";
import { SURFACE } from "../../components/ui/surfaces";
// The feed rail's "Year" control: quick chips for the most recent years plus a
// "More…" popover listing ALL years with per-year counts, sortable by year or count. Selecting a year
// filters the feed to that calendar year (a whole-year published_after/before range, applied in the
// api layer); it's a single-select toggle (click the active year to clear it) and mutually exclusive
// with the Upload-date control — picking a year clears any maxAge/custom range, and vice versa.
// The feed rail's "Year" control (ui/YearPicker: recent-year chips and a "More" dialog of every
// year). Selecting a year filters the feed to that calendar year (a whole-year
// published_after/before range, applied in the api layer); it is mutually exclusive with the
// Upload-date control — picking a year clears any maxAge/custom range, and vice versa.
//
// The per-year counts come from /feed/years, which drops the publish-date narrowing itself, so the
// list is keyed on the date-stripped filters: picking a year re-queries the FEED but never this list
// (its numbers wouldn't change) — the same drill-down principle as the tag facets dropping their
// own category.
const QUICK = 6; // recent-year chips shown before the "More…" overflow
// One shared empty list while the counts load: a fresh `[]` per render made the memos below recompute.
// One shared empty list while the counts load: a fresh `[]` per render would change the picker's input.
const NO_YEARS: YearCount[] = [];
export default function YearFilter({
@@ -36,8 +26,6 @@ export default function YearFilter({
setFilters: (f: FeedFilters) => void;
}) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const [sortByCount, setSortByCount] = useState(false);
// Debounce the search term (like the sibling summary facet in Sidebar) so typing doesn't refire the
// per-year count query on every keystroke — each distinct q is a fresh cache key, so without this
@@ -58,39 +46,6 @@ export default function YearFilter({
placeholderData: keepPreviousData,
});
const years = yearsQuery.data?.years ?? NO_YEARS;
const selected = filters.year;
// A year pick and the Upload-date control are mutually exclusive publish-date narrowings.
const pick = (year?: number) =>
setFilters({ ...filters, year, maxAgeDays: undefined, dateFrom: undefined, dateTo: undefined });
// Quick chips = the most recent years, plus the selected year if it falls outside that window (so the
// active pick is always visible), re-sorted newest-first.
const quick = useMemo(() => {
const top = years.slice(0, QUICK);
if (selected != null && !top.some((y) => y.year === selected)) {
const sel = years.find((y) => y.year === selected) ?? { year: selected, count: 0 };
return [...top, sel].sort((a, b) => b.year - a.year);
}
return top;
}, [years, selected]);
const sorted = useMemo(
() =>
[...years].sort((a, b) =>
sortByCount ? b.count - a.count || b.year - a.year : b.year - a.year,
),
[years, sortByCount],
);
const pop = useAnchoredPopover({
open,
onOpenChange: setOpen,
mode: "menu",
placement: "bottom-start",
});
const { setReference } = pop.refs;
// Honest states: a first-load / errored fetch must not read as "no years" (an empty chip row).
if (years.length === 0) {
@@ -112,85 +67,19 @@ export default function YearFilter({
}
return (
<div className="flex flex-wrap gap-1.5">
{quick.map((y) => {
const on = selected === y.year;
return (
<Chip key={y.year} pressed={on} onClick={() => pick(on ? undefined : y.year)}>
<span className="tabular-nums">{y.year}</span>
{y.count > 0 && <CountBadge count={y.count} active={on} />}
</Chip>
);
})}
{years.length > quick.length && (
<>
<Chip
ref={setReference}
{...pop.getReferenceProps()}
aria-label={t("sidebar.year.moreLabel")}
>
<span>{t("sidebar.year.more")}</span>
<ChevronDown className="text-muted h-3.5 w-3.5" aria-hidden="true" />
</Chip>
<PopoverSurface
popover={pop}
ariaLabel={t("sidebar.year.moreLabel")}
className={`${SURFACE.menu} min-w-44 animate-[popIn_0.16s_ease]`}
>
<div className="mb-1 flex items-center gap-2 border-b border-border px-1 pb-2">
<span className="text-2xs text-muted">{t("sidebar.year.sortBy")}</span>
<div className="ml-auto flex gap-1">
{(
[
["sortYear", false],
["sortCount", true],
] as const
).map(([key, v]) => (
<Button
key={key}
variant="outline"
size="xs"
pressed={sortByCount === v}
onClick={() => setSortByCount(v)}
>
{t("sidebar.year." + key)}
</Button>
))}
</div>
</div>
<div
className="max-h-64 overflow-y-auto no-scrollbar"
role="group"
aria-label={t("sidebar.year.moreLabel")}
>
{sorted.map((y) => {
const on = selected === y.year;
return (
<PopoverItem
key={y.year}
role="menuitemradio"
checked={on}
onSelect={() => {
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"
}`}
>
<MenuCheck kind="one" on={on} />
<span className="flex-1 tabular-nums">{y.year}</span>
<span className="text-2xs text-muted shrink-0 tabular-nums">{y.count}</span>
</PopoverItem>
);
})}
</div>
</PopoverSurface>
</>
)}
</div>
<YearPicker
years={years}
selected={filters.year}
// A year pick and the Upload-date control are mutually exclusive publish-date narrowings.
onPick={(year) =>
setFilters({
...filters,
year,
maxAgeDays: undefined,
dateFrom: undefined,
dateTo: undefined,
})
}
/>
);
}
+4 -1
View File
@@ -465,10 +465,13 @@ export default function Files() {
// A narrow table (a pinned rail beside it) gives its fixed columns' room to the name: with the
// picture and the date in, the name had none left.
narrowHidden: true,
// A fixed height, not aspect-video: in a table cell WebKit (every iPad browser) resolves the
// picture's h-full against an aspect-ratio height as its own natural height, and the box then
// shows a native-size sliver of the poster. 20 × 11.25 is the same 16:9.
render: (f) => (
<JobThumb
job={f}
box="w-20 aspect-video shrink-0 rounded-md overflow-hidden bg-surface border border-border"
box="w-20 h-11.25 shrink-0 rounded-md overflow-hidden bg-surface border border-border"
/>
),
},
+2 -6
View File
@@ -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}
</Filterable>
+168 -93
View File
@@ -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 : (
<FacetsStatus failed={facetsQ.isError} onRetry={() => 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: (
<>
<MatchToggle
picked={filters.genres.length}
value={filters.genreMode ?? "any"}
onChange={(m) => patch({ genreMode: m })}
/>
<Combobox
label={t("plex.filter.genre")}
options={facets.genres}
selected={filters.genres}
onChange={(next) => patch({ genres: next })}
/>
</>
),
},
]
: []),
...facetSection(
"genre",
t("plex.filter.genre"),
(f) => f.genres.length > 0,
(f) => (
<>
<MatchToggle
picked={filters.genres.length}
value={filters.genreMode ?? "any"}
onChange={(m) => patch({ genreMode: m })}
/>
<Combobox
label={t("plex.filter.genre")}
options={f.genres}
selected={filters.genres}
onChange={(next) => patch({ genres: next })}
/>
</>
),
),
{
id: "year",
title: t("plex.filter.year"),
body: (
<div className="flex items-center gap-2">
<NumberInput
value={filters.yearMin ?? null}
placeholder={facets?.year_min ?? undefined}
onChange={(v) => patch({ yearMin: v })}
/>
<span className="text-muted text-xs">–</span>
<NumberInput
value={filters.yearMax ?? null}
placeholder={facets?.year_max ?? undefined}
onChange={(v) => patch({ yearMax: v })}
/>
</div>
<PlexYearFilter
filters={filters}
facets={facets}
status={yearHidden ? null : facetsStatus}
patch={patch}
/>
),
},
...(scope === "movie"
@@ -233,62 +248,55 @@ export default function PlexSidebar() {
/>
),
},
...(facets && facets.content_ratings.length > 0
? [
{
id: "contentrating",
title: t("plex.filter.contentRating"),
body: (
<Combobox
label={t("plex.filter.contentRating")}
options={facets.content_ratings.map((o) => ({
...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: (
<Combobox
label={t("plex.filter.studio")}
options={facets.studios}
selected={filters.studios}
onChange={(next) => patch({ studios: next })}
/>
),
},
]
: []),
...facetSection(
"contentrating",
t("plex.filter.contentRating"),
(f) => f.content_ratings.length > 0,
(f) => (
<Combobox
label={t("plex.filter.contentRating")}
options={f.content_ratings.map((o) => ({
...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) => (
<Combobox
label={t("plex.filter.studio")}
options={f.studios}
selected={filters.studios}
onChange={(next) => 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: (
<Combobox
label={t("plex.filter.playable")}
options={facets.playable.map((p) => ({
value: p.value,
label: t(`plex.filter.playableOpt.${p.value}`),
count: p.count,
}))}
selected={filters.playable}
onChange={(next) => patch({ playable: next })}
/>
),
},
]
...(scope === "movie"
? facetSection(
"playable",
t("plex.filter.playable"),
(f) => f.playable.length > 0,
(f) => (
<Combobox
label={t("plex.filter.playable")}
options={f.playable.map((p) => ({
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={<SavedViewsPopover trigger={<SlidersHorizontal className="w-4 h-4" />} />}
onTitleClick={openModal}
titleActionLabel={t("views.manageTitle")}
/>
>
{yearHidden && facetsStatus}
</FilterRail>
{/* The manager modal lives here (rail is always mounted on plex); Modal portals to <body>. */}
{modalOpen && <PersonalViewsModal />}
</SavedViewsScope>
@@ -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<PlexFilters>) => void;
}) {
const { t } = useTranslation();
return (
<div className="flex flex-col gap-2">
{status}
{facets && facets.years.length > 0 && (
<YearPicker
years={facets.years}
selected={singleYear(filters)}
onPick={(year) => patch(oneYear(year))}
/>
)}
<div className="flex items-center gap-2">
<NumberInput
label={t("plex.filter.yearFrom")}
value={filters.yearMin ?? null}
placeholder={facets?.year_min ?? undefined}
onChange={(v) => patch({ yearMin: v })}
/>
<span className="text-muted text-xs">–</span>
<NumberInput
label={t("plex.filter.yearTo")}
value={filters.yearMax ?? null}
placeholder={facets?.year_max ?? undefined}
onChange={(v) => patch({ yearMax: v })}
/>
</div>
</div>
);
}
/** 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 (
<StateMessage tone="error" compact onRetry={onRetry}>
{t("common.loadError")}
</StateMessage>
);
return (
<p className="text-muted px-1 py-1.5 text-xs" role="status">
{t("common.loading")}
</p>
);
}
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 (
<Input
aria-label={label}
className="w-20 tabular-nums"
size="sm"
type="number"
@@ -0,0 +1,82 @@
// @vitest-environment jsdom
//
// The rail's Year section: a year from the picker is a range of one (both ends), the picker shows a
// range of one as its selected year, and the two range fields carry a name of their own.
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, cleanup, screen, fireEvent } from "@testing-library/react";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return {
...actual,
useTranslation: () => ({ t: (key: string) => key, 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(
<PlexYearFilter filters={EMPTY_PLEX_FILTERS} facets={FACETS} patch={patch} />,
);
fireEvent.click(screen.getByRole("button", { name: /1999/ }));
expect(patch).toHaveBeenLastCalledWith({ yearMin: 1999, yearMax: 1999 });
rerender(
<PlexYearFilter
filters={{ ...EMPTY_PLEX_FILTERS, yearMin: 1999, yearMax: 1999 }}
facets={FACETS}
patch={patch}
/>,
);
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(
<PlexYearFilter
filters={{ ...EMPTY_PLEX_FILTERS, yearMin: 1999, yearMax: 2001 }}
facets={FACETS}
patch={() => {}}
/>,
);
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(<FacetsStatus failed={false} onRetry={retry} />);
expect(screen.getByRole("status").textContent).toBe("common.loading");
rerender(<FacetsStatus failed onRetry={retry} />);
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(<PlexYearFilter filters={EMPTY_PLEX_FILTERS} facets={FACETS} patch={() => {}} />);
expect(screen.getByRole("spinbutton", { name: "plex.filter.yearFrom" })).toBeTruthy();
expect(screen.getByRole("spinbutton", { name: "plex.filter.yearTo" })).toBeTruthy();
});
});
@@ -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
<Filterable
tag
title={onFilter ? t("plex.info.filterYear", { year }) : undefined}
onClick={onFilter ? () => onFilter({ yearMin: year, yearMax: year }) : undefined}
onClick={onFilter ? () => onFilter(oneYear(year)) : undefined}
>
{year}
</Filterable>
+2 -5
View File
@@ -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({
<Filterable
tag
title={onFilter ? t("plex.info.filterYear", { year: show.year }) : undefined}
onClick={
onFilter
? () => onFilter({ yearMin: show.year, yearMax: show.year })
: undefined
}
onClick={onFilter ? () => onFilter(oneYear(show.year)) : undefined}
>
{show.year}
</Filterable>
@@ -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" },
}),
};
});