Compare commits

..
35 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
peter 77f8170153 release: 0.119.0 (feature/e61-gif) 2026-11-13 02:32:25 +01:00
peter 0fd0f1f637 release: 0.119.0 2026-11-13 02:18:55 +01:00
peter 7371cdeb94 refactor(downloads): the GIF span helper stays inside lib/gif 2026-11-13 01:42:36 +01:00
peter abb18d4d8c fix(downloads): GIF review round 3 — every length readout, live regions in place
A refused GIF's length reads rounded up everywhere the editor shows it (the segment and the kept summary of one), and the GIF notes and estimates sit in live regions that are there before GIF is picked, so a note already true then is said. Tests hold the clip panel's readout and which segment a note names; DESIGN.md claims only what the contract test holds.
2026-11-13 01:16:21 +01:00
peter 5bf13c8701 test(scheduler): runs started in a row start in that order
The scheduler tests open several runs within microseconds and read them back newest first, by start time. A container's clock on a VM can step back between two readings, which sorted the third of three runs before the second; the tests now run on a clock that never repeats or goes back.
2026-11-13 00:31:57 +01:00
peter fe6af1445f fix(downloads): GIF review round 2 — cap as sent, shown length, retry, Join reason
The panels judge a GIF's length in whole milliseconds between the ends as sent, the server's own rule, and a refused length reads rounded up. A shared picture says it failed only once its retry failed too. Join's reason shows on a key's focus; the editor's failure toasts name GIFs. The edit route's cap is test-held, and DESIGN.md records the GIF UI.
2026-11-13 00:19:53 +01:00
peter 8845d1aa49 fix(downloads): GIF review round 1 — shape, HDR, caps, audio, copy
- A GIF squares its pixels before the size, so an anamorphic source keeps
  its shown shape (a 720x480 DVD at 16:9 was 480x320).
- An editor GIF of an HDR upload is tonemapped, as a clip GIF is.
- The length caps are judged in whole milliseconds: a range exactly at a
  cap is no longer refused for a float hair over it.
- A GIF is made of a video only: an audio file is refused with its reason
  and the editor offers no GIF for it.
- A GIF clip keeps no audio track: the track neither splits its cache key
  nor fails it.
- The editor's GIF notes keep their live region mounted; one size reads
  as one ("about 1 MB"), none is shown with nothing kept; the size choices
  show their unit; a shared picture that fails to load says so.
- Copy: the two-sentence GIF notes keep their last stop (a copy-rule test
  now holds it); the English warns of "only a still picture".
2026-11-12 23:24:25 +01:00
peter 0b92b40db5 fix(downloads): a GIF's size warning judges the size it shows 2026-11-11 02:19:56 +01:00
peter 27ccf253f5 feat(downloads): make a GIF in the clip panel and the editor, and show a shared one as itself
The clip panel and the editor get a Video / GIF choice with the GIF's size, the size it is expected
to come out at, and what that size means (no moving link preview over 15 MB, refused over 30 MB).
A range longer than a GIF may be leaves Make unavailable with its reason. The editor makes one GIF
per kept segment; Join says why it is unavailable instead of going dead. The share page shows a
picture as itself with its Download, and a picture in a folder opens above the list.
2026-11-11 02:10:07 +01:00
peter c221452a00 feat(downloads): a clip or an edit can be a GIF, shared as a picture
A clip and an editor range can come out as a silent GIF (long side
320/480/640, 12 fps, at most 30 s, one palette per GIF); one over 30 MB
is not kept. A shared picture is served inline under the preview's
sandbox, and a GIF's link card is the GIF itself up to 15 MB, the still
for Facebook's crawler.
2026-11-11 01:50:55 +01:00
peter fe38c07e7e test: the sub-view census reads each source once
Read three times, the scan ran past the 5 s test timeout when the gate
loaded the machine.
2026-11-11 01:50:54 +01:00
90 changed files with 4009 additions and 454 deletions
+35
View File
@@ -7,6 +7,41 @@ 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
- Make a GIF of up to 30 seconds: in a player's Clip panel, or in the Downloads editor, where each kept segment becomes a GIF of its own. Both say the expected size before it is made.
- A shared GIF's link opens the moving picture itself, with Download beside it. In Teams its preview moves when the GIF is 15 MB or less.
## 0.118.0 — 2026-11-08
### New
+1 -1
View File
@@ -1 +1 @@
0.118.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
+34 -4
View File
@@ -12,6 +12,9 @@ given in fractions of the box it was drawn in, with that box's aspect: the YouTu
tells its picture's size, so the frame there is drawn over the player's 16:9 box (a short's 9:16),
and the cut maps it onto the real picture (`crop_filter`) — what lies over a letterbox bar is dropped.
A clip may be a GIF instead (`spec["gif"]`, downloads/gif): the same range, crop and tonemap, then a
silent animated picture of at most 30 s rather than a video.
The spec is stored on the job (`DownloadJob.edit_spec`) and is the clip's whole identity: its
signature is the asset's cache key, so marking the same range again is an instant cache hit, and a
different range or audio track is a different clip.
@@ -24,6 +27,7 @@ import re
from pathlib import Path
from typing import Literal
from app.downloads import gif as gifmod
from app.downloads.edit import AAC_ARGS, EVEN_FRAME, INPUT_GUARD, X264_ARGS
from app.plex.sync import HDR_TRANSFERS
@@ -70,6 +74,8 @@ ClipReason = Literal[
"not_found",
"media_missing",
"past_end",
"bad_gif",
"gif_too_long",
]
@@ -90,9 +96,12 @@ def normalize(
end_s: float,
audio_track: int | None = None,
crop: dict | None = None,
gif_side: object = None,
) -> dict:
"""The validated, canonical clip spec. Times are rounded to the millisecond and a crop to four
decimals, so the same marks always give the same cache key. Raises ClipError."""
decimals, so the same marks always give the same cache key. A `gif_side` makes it a GIF of that
size (`downloads/gif`), held to the GIF's own length cap; without one it is the video clip it
always was, spec and cache key alike. Raises ClipError."""
if source not in SOURCES:
raise ClipError("bad_source")
pattern = _YOUTUBE_ID if source == "youtube" else _RATING_KEY
@@ -101,12 +110,27 @@ def normalize(
if not (math.isfinite(start_s) and math.isfinite(end_s)) or start_s < 0:
raise ClipError("bad_range")
start, end = round(start_s, 3), round(end_s, 3)
if end - start < MIN_CLIP_S:
# Judged in whole milliseconds, as the ends are kept: a float difference lands a hair off
# (60.529 - 30.529 = 30.000000000000004), which would refuse a range exactly at a cap.
span_ms = round((end - start) * 1000)
if span_ms < MIN_CLIP_S * 1000:
raise ClipError("too_short")
if end - start > MAX_CLIP_S:
side = None
if gif_side is not None:
try:
side = gifmod.normalize_side(gif_side)
except ValueError as e:
raise ClipError("bad_gif") from e
if side is not None and gifmod.too_long(start, end):
# Its own reason: a GIF's cap is said in seconds, a clip's in minutes.
raise ClipError("gif_too_long", limit_s=gifmod.MAX_S)
if span_ms > MAX_CLIP_S * 1000:
raise ClipError("too_long", limit_s=MAX_CLIP_S)
spec: dict = {"source": source, "ref": ref, "start_s": start, "end_s": end}
if source == "plex" and audio_track is not None:
if side is not None:
spec["gif"] = {"side": side}
# A GIF has no sound: the track the player was on is not part of what it is (its cache key).
if source == "plex" and audio_track is not None and side is None:
if not 0 <= audio_track <= _MAX_AUDIO_TRACK:
raise ClipError("bad_audio")
spec["audio_track"] = audio_track
@@ -257,6 +281,12 @@ def audio_track_count(probe: dict) -> int:
return sum(1 for s in probe.get("streams") or [] if s.get("codec_type") == "audio")
def gif_pre(crop: str, hdr: bool) -> str:
"""What a GIF clip's frames go through before the GIF's own size and palette: the crop, then an HDR
source's tonemap (a palette made from PQ frames is as washed out as the frames are)."""
return ",".join([*([crop] if crop else []), *([_TONEMAP] if hdr else [])])
def build_cmd(
src: Path,
dest: Path,
+23 -2
View File
@@ -109,6 +109,12 @@ def normalize_edit_spec(spec: dict | None) -> dict:
spec = spec or {}
out: dict = {}
# A GIF output (downloads/gif) keeps its size; one it cannot name is dropped here and refused by
# the service, which reads the raw spec for exactly that (`gif_side`).
side = gif_side(spec)
if side is not None:
out["gif"] = {"side": side}
# Crop applies to both a single trim and a multi-segment join.
crop = spec.get("crop") or {}
if crop:
@@ -197,6 +203,21 @@ def normalize_edit_spec(spec: dict | None) -> dict:
return out
def gif_side(spec: dict | None) -> int | None:
"""The GIF size a raw edit spec asks for, or None when it asks for none — or for one that is not
offered (the service tells the two apart by `"gif" in spec`)."""
# Imported here: `gif` builds its command on this module's input guard.
from app.downloads import gif as gifmod
raw = (spec or {}).get("gif")
if not isinstance(raw, dict):
return None
try:
return gifmod.normalize_side(raw.get("side"))
except ValueError: # silent-ok: an unoffered size is refused by the service (`bad_gif`)
return None
def edit_sig(spec: dict) -> str:
"""Stable 24-char signature of a (normalized) edit spec — the per-user cache identity."""
payload = json.dumps(normalize_edit_spec(spec), sort_keys=True, separators=(",", ":"))
@@ -208,8 +229,8 @@ def has_crop(spec: dict) -> bool:
def needs_reencode(spec: dict) -> bool:
"""A crop always re-encodes; a trim re-encodes only when the user asked for an accurate cut."""
return bool(spec.get("crop")) or bool(spec.get("accurate"))
"""A crop or a GIF always re-encodes; a trim only when the user asked for an accurate cut."""
return bool(spec.get("crop")) or bool(spec.get("gif")) or bool(spec.get("accurate"))
def clip_duration(spec: dict, source_duration: int | None) -> int | None:
+10 -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
@@ -68,6 +69,10 @@ CONVERTED_TOO_BIG_MESSAGE = (
"free up space, then try again."
)
UPLOAD_MISSING_MESSAGE = "The uploaded file is missing."
# A GIF over `gif.MAX_BYTES`: not the storage left, a cap of the GIF's own (its own category).
GIF_TOO_BIG_MESSAGE = (
"The GIF came out larger than a GIF may be — mark a shorter range or choose a smaller size."
)
NETWORK_REDACTED_MESSAGE = "The server could not reach that address."
# (marker, category) — first match wins, so order most-specific → most-general. Matching is
@@ -77,6 +82,8 @@ _RULES: tuple[tuple[str, str], ...] = (
# first — it is our wording, so it can never be a false match on an upstream message.
("download storage is full", "quota_exceeded"),
("larger than the download storage you have left", "quota_exceeded"),
# Our own refusal of a GIF over its size cap — the range or the size, not the storage left.
("larger than a gif may be", "gif_too_big"),
# Our own redaction of a transport failure (see NETWORK_REDACTED_MESSAGE) — our wording too.
(NETWORK_REDACTED_MESSAGE.lower(), "network"),
# Our own verdicts on a post link (adapters.py) — our wording too: its page holds no single video,
@@ -221,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
+139
View File
@@ -0,0 +1,139 @@
"""The GIF output of a clip or an edit: its limits and the one ffmpeg command every GIF goes through.
A GIF is a picture, not a video: no sound, a library `file` that previews as an image (ffprobe's `gif`
format is one of `probe._IMAGE_FORMATS`), and never the source of another edit. The numbers were
measured on real sources (2026-11-09/11):
- 12 frames a second, the long side capped (320 / 480 / 640): a width cap made a phone's portrait
video three times the size of a landscape one (44 MB for 21 s at "480").
- One palette per GIF, made from the GIF's own frames (`palettegen` → `paletteuse`, light ordered
dither): the generic 256-colour palette bands every gradient.
- 30 s at most. 480 px fills 12–29 MB in 30 s, depending on the picture; a GIF over `MAX_BYTES` is
not kept, and the owner is told to cut less or smaller.
- A chat link preview: Teams moves a GIF `og:image` up to 15 MB (22 MB showed nothing), so
`PREVIEW_MAX_BYTES` decides whether the share page offers the GIF itself or its still (`ensure_still`).
"""
import subprocess
from pathlib import Path
from app.downloads.edit import INPUT_GUARD
SIDES = (320, 480, 640)
DEFAULT_SIDE = 480
FPS = 12
MAX_S = 30
PREVIEW_MAX_BYTES = 15 * 1024 * 1024
MAX_BYTES = 30 * 1024 * 1024
# The still is cut from the GIF we made, read with the GIF demuxer alone: a library file named .gif
# whose bytes are a playlist must not open the paths inside it (see `edit.MEDIA_DEMUXERS`).
_GIF_GUARD = ["-format_whitelist", "gif"]
_STILL_TIMEOUT_S = 30
def normalize_side(side: object) -> int:
"""One of `SIDES`, or ValueError. A bool or a float is not a size, even when it equals one."""
if type(side) is not int or side not in SIDES:
raise ValueError(side)
return side
def too_long(start_s: float, end_s: float) -> bool:
"""Over `MAX_S`, counted in whole milliseconds: the ends are kept to the millisecond, and their
float difference may land a hair over (60.529 - 30.529 = 30.000000000000004), which would refuse
the very 30 s the panels allow."""
return round((end_s - start_s) * 1000) > MAX_S * 1000
# A GIF has no pixel shape a viewer honours (browsers ignore its aspect byte), so a source stored
# with wide or narrow pixels (an anamorphic DVD: 720x480 shown at 16:9) is first resampled to square
# ones, or it shows squeezed. `sar` is 1 for a source that names none; even, as a 4:2:0 frame wants.
_SQUARE_PIXELS = "scale=w='trunc(iw*sar/2)*2':h=ih,setsar=1"
def _scale(side: int) -> str:
"""Fit the long side into `side`, never upscaling a smaller picture; the short side keeps the
aspect (-1: a GIF has no chroma grid that wants it even)."""
return (
f"scale=w='if(gte(iw,ih),min({side},iw),-1)'"
f":h='if(gte(iw,ih),-1,min({side},ih))':flags=lanczos"
)
def filter_graph(pre: str, side: int) -> str:
"""The GIF's filters: `pre` (a crop, an HDR tonemap) on the source's own frame first, then the
frame rate, square pixels and the size, then a palette made from these frames and applied to them.
A crop counts the stored frame (both callers' crops are fractions of `iw`/`ih`), so it comes
before the pixels are squared."""
head = ",".join([*([pre] if pre else []), f"fps={FPS}", _SQUARE_PIXELS, _scale(side)])
return (
f"[0:v:0]{head},split[a][b];"
"[a]palettegen=stats_mode=diff[p];"
"[b][p]paletteuse=dither=bayer:bayer_scale=5:diff_mode=rectangle"
)
def build_cmd(
src: Path, dest: Path, start_s: float, dur_s: float, *, pre: str, side: int
) -> list[str]:
"""ffmpeg for one GIF: an input seek (the GIF starts on the marked frame), the first video stream
through `filter_graph`, nothing else — no sound, subtitles, data or chapters."""
return [
"ffmpeg",
"-y",
"-hide_banner",
"-loglevel",
"error",
"-nostdin",
"-progress",
"pipe:1",
"-nostats",
"-ss",
f"{start_s:.3f}",
*INPUT_GUARD,
"-i",
str(src),
"-t",
f"{dur_s:.3f}",
"-filter_complex",
filter_graph(pre, side),
"-an",
"-sn",
"-dn",
"-map_chapters",
"-1",
str(dest),
]
def size(path: Path) -> tuple[int, int] | None:
"""(width, height) from a GIF's header — ffprobe reports none for an image format, and the share
page's preview card wants them. None for anything that is not a GIF."""
try:
with path.open("rb") as f:
head = f.read(10)
# silent-ok: no size is what an unreadable file has; the card is then built without one.
except OSError:
return None
if len(head) < 10 or head[:6] not in (b"GIF87a", b"GIF89a"):
return None
return int.from_bytes(head[6:8], "little"), int.from_bytes(head[8:10], "little")
def ensure_still(path: Path) -> Path | None:
"""A JPEG still of the GIF's first frame beside it (`<name>.jpg`, the poster sidecar every library
file uses): the link preview for a chat that shows no GIF (Messenger) or a GIF too big to move.
None when it cannot be cut — the card is then built without a picture."""
dest = path.with_suffix(".jpg")
cmd = [
"ffmpeg", "-y", "-hide_banner", "-loglevel", "error", "-nostdin",
*_GIF_GUARD, "-i", str(path), "-frames:v", "1", "-q:v", "3", str(dest),
] # fmt: skip
try:
subprocess.run(cmd, capture_output=True, timeout=_STILL_TIMEOUT_S, check=True)
# silent-ok: the still is best-effort; without it the card has no picture, which is true.
except (subprocess.TimeoutExpired, subprocess.CalledProcessError, OSError):
dest.unlink(missing_ok=True)
return None
return dest if dest.exists() else None
+3 -1
View File
@@ -171,7 +171,7 @@ def public_meta(
Resolves the owner's per-download display overrides (custom title/channel) over the asset's
auto-extracted values, and surfaces the source + any extra reference links as clickable URLs."""
from app.downloads import mediakind, service
from app.downloads import mediakind, preview, service
source_url = service.reference_url(job, asset)
# Audio-only assets (mp3 rips, extracted audio) can't play inside a <video> element on WebKit
@@ -189,6 +189,8 @@ def public_meta(
"height": asset.height,
"audio": asset.media_kind == mediakind.AUDIO,
"media_kind": asset.media_kind,
# A picture is shown as itself (the file route serves it inline); other files download.
"preview": "image" if preview.kind(asset.media_kind, asset.container) == "image" else None,
"size_bytes": asset.size_bytes,
"container": asset.container,
"allow_download": link.allow_download,
+44 -11
View File
@@ -14,8 +14,9 @@ from pathlib import Path
from sqlalchemy.orm import Session
from app.config import settings
from app.downloads import gif as gifmod
from app.downloads import links as linksmod
from app.downloads import storage
from app.downloads import preview, storage
from app.downloads.models import DownloadJob, DownloadLink, FileFolder, MediaAsset
_OG_BLOCK = re.compile(r"<!--OG:START-->.*?<!--OG:END-->", re.DOTALL)
@@ -71,8 +72,33 @@ def _jpeg_size(path: Path) -> tuple[int, int] | None:
return None
def _watch_tags(token: str, db: Session) -> str | None:
"""Build the OG/Twitter meta block for a watch token, or None to keep the generic card."""
# Facebook's crawler (Messenger's previews): it never showed a GIF `og:image` and did show a JPEG one
# (measured 2026-11-11), so it is given the still. Matched on its documented agent names.
_FACEBOOK_AGENTS = ("facebookexternalhit", "facebot")
def _is_facebook(user_agent: str | None) -> bool:
agent = (user_agent or "").lower()
return any(name in agent for name in _FACEBOOK_AGENTS)
def _moving_image(
token: str, asset: MediaAsset, user_agent: str | None
) -> tuple[str, tuple[int, int] | None] | None:
"""The shared GIF itself as the card's picture, and its size from its header — when a chat can
move it: not for Facebook's crawler, and not over `gif.PREVIEW_MAX_BYTES` (Teams moved 15 MB and
showed nothing at 22). None: the card takes the still, as any other file's does."""
if preview.kind(asset.media_kind, asset.container) != "image" or asset.container != "gif":
return None
if _is_facebook(user_agent) or (asset.size_bytes or 0) > gifmod.PREVIEW_MAX_BYTES:
return None
path = storage.safe_abs_path(settings.files_root, asset.rel_path)
return f"{settings.app_base}/api/public/watch/{token}/file", gifmod.size(path) if path else None
def _watch_tags(token: str, db: Session, user_agent: str | None = None) -> str | None:
"""Build the OG/Twitter meta block for a watch token, or None to keep the generic card.
`user_agent` is the crawler's: a GIF's card is the GIF for a chat that moves it (`_moving_image`)."""
link = linksmod.find(db, token)
if link is None or linksmod.is_expired(link):
return None
@@ -112,7 +138,12 @@ def _watch_tags(token: str, db: Session) -> str | None:
# non-password links, so the poster needs no grant.
yt_id_match = re.search(r"i\.ytimg\.com/vi/([\w-]+)/", asset.thumbnail_url or "")
image_dims: tuple[int, int] | None = None
if yt_id_match:
image_type = "image/jpeg"
moving = _moving_image(token, asset, user_agent)
if moving:
image, image_dims = moving
image_type = "image/gif"
elif yt_id_match:
image: str | None = f"https://i.ytimg.com/vi/{yt_id_match.group(1)}/hqdefault.jpg"
image_dims = (480, 360)
elif asset.poster_path:
@@ -133,12 +164,12 @@ def _watch_tags(token: str, db: Session) -> str | None:
if image:
# A real thumbnail → a large image card; without one, a plain (text) card is served.
tags.append(_tag("og:image", image))
# Declare the image is a public HTTPS JPEG with known dimensions, so FB (which fetches
# og:image asynchronously on first scrape) includes it instead of unfurling title-only. For a
# poster we read the real dimensions from its JPEG header; the ytimg hqdefault is a fixed 480×360.
# Declare the image's type and known dimensions, so FB (which fetches og:image
# asynchronously on first scrape) includes it instead of unfurling title-only. For a poster
# we read the real dimensions from its JPEG header; the ytimg hqdefault is a fixed 480×360.
if image.startswith("https://"):
tags.append(_tag("og:image:secure_url", image))
tags.append(_tag("og:image:type", "image/jpeg"))
tags.append(_tag("og:image:type", image_type))
if image_dims is None and image.endswith("poster.jpg") and asset.poster_path:
# safe_abs_path returns None if the poster file is missing (gc'd) or escapes the root —
# guard it, else _jpeg_size(None) would raise an uncaught AttributeError on this public page.
@@ -178,11 +209,13 @@ def _folder_tags(db: Session, link: DownloadLink, url: str) -> str | None:
)
def render_watch_html(token: str, page_html: Path, db: Session) -> str | None:
def render_watch_html(
token: str, page_html: Path, db: Session, user_agent: str | None = None
) -> str | None:
"""Return the watch page (watch.html) with the OG block replaced for this token, or None to
serve the generic page unchanged (invalid/expired/not-ready link, or a template without the
markers)."""
tags = _watch_tags(token, db)
markers). `user_agent` is the fetcher's, which picks a GIF's card picture."""
tags = _watch_tags(token, db, user_agent)
if tags is None:
return None
text = page_html.read_text(encoding="utf-8")
+81 -19
View File
@@ -16,6 +16,7 @@ from app.db import SessionLocal
from app.downloads import clip as clipmod
from app.downloads import edit as editmod
from app.downloads import errors as dlerrors
from app.downloads import gif as gifmod
from app.downloads import adapters, credentials, formats, mediakind, playable, probe, quota
from app.downloads import backfill, egress
from app.downloads import service, storage
@@ -590,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"),
@@ -874,7 +876,8 @@ def _process_edit(job_id: int, asset_id: int) -> None:
src_ext = src_path.suffix.lstrip(".").lower() or "mp4"
crop = editmod.has_crop(edit_spec)
out_ext = "mp4" if editmod.needs_reencode(edit_spec) else src_ext
gif = edit_spec.get("gif")
out_ext = "gif" if gif else "mp4" if editmod.needs_reencode(edit_spec) else src_ext
total_s = editmod.clip_duration(edit_spec, src_dur) or src_dur or 0
staging = _edit_staging_dir(asset_id)
@@ -885,7 +888,25 @@ def _process_edit(job_id: int, asset_id: int) -> None:
dest_staging = staging / f"out.{out_ext}"
_set_job(job_id, phase="editing", progress=0, speed_bps=None, eta_s=None)
if edit_spec.get("segments"):
if gif:
# One range with an end (the service's check), cropped first if a frame was drawn, then
# tonemapped as a clip GIF is: an HDR upload keeps its HLG/PQ tags, and a GIF has none,
# so a palette made from those frames would be washed out.
trim = edit_spec["trim"]
start = trim.get("start_s") or 0.0
data = probe.probe_json(src_path)
cmd = gifmod.build_cmd(
src_path,
dest_staging,
start,
trim["end_s"] - start,
pre=clipmod.gif_pre(
editmod.crop_filter(edit_spec["crop"]) if crop else "",
bool(data) and clipmod.is_hdr(data),
),
side=gif["side"],
)
elif edit_spec.get("segments"):
cmd, prep = editmod.build_concat_plan(
src_path, dest_staging, edit_spec, out_ext, staging
)
@@ -901,9 +922,12 @@ def _process_edit(job_id: int, asset_id: int) -> None:
)
if not dest_staging.exists():
raise StatedFailure("ffmpeg produced no output file")
# A fast trim stream-copies the source's codecs and container, so the clip is only as
# playable as its source: hold it to the same rule as every other ready file.
dest_staging = _ensure_phone_safe(job_id, dest_staging, staging, "editing", total_s)
if gif:
_hold_gif_cap(dest_staging)
else:
# A fast trim stream-copies the source's codecs and container, so the clip is only as
# playable as its source: hold it to the same rule as every other ready file.
dest_staging = _ensure_phone_safe(job_id, dest_staging, staging, "editing", total_s)
out_ext = dest_staging.suffix.lstrip(".").lower() or out_ext
# New storage on the owner's footprint, held to what is left by its real size, as a
# download and a player clip are.
@@ -917,8 +941,12 @@ def _process_edit(job_id: int, asset_id: int) -> None:
storage.place_file(dest_staging, settings.files_root, rel)
final = storage.abs_path(settings.files_root, rel)
size = final.stat().st_size if final.exists() else None
# The clip's real frame: a re-encode evens an odd crop or source (`editmod.EVEN_FRAME`).
# The clip's real frame: a re-encode evens an odd crop or source (`editmod.EVEN_FRAME`). A
# GIF's is in its header (ffprobe reports no stream size for an image format).
_, _, out_w, out_h, _ = probe.probe_media(final)
if gif:
out_w, out_h = gifmod.size(final) or (None, None)
poster = _gif_still(rel) if gif else None
# Unsized, a crop's own size stands in, cut from the frame the filters saw.
cut = (
editmod.crop_stored_size(edit_spec["crop"], *_filter_frame(src_path, src_w, src_h))
@@ -940,6 +968,8 @@ def _process_edit(job_id: int, asset_id: int) -> None:
asset.width, asset.height = cut
else:
asset.width, asset.height = (None, None) if crop else (src_w, src_h)
if poster:
asset.poster_path = poster
asset.error = None
asset.last_access_at = datetime.now(timezone.utc)
job = db.get(DownloadJob, job_id)
@@ -1057,6 +1087,19 @@ def _clip_crop(spec: dict, data: dict) -> str:
return crop
def _hold_gif_cap(out: Path) -> None:
"""A GIF over its own size cap is not kept (`gif.MAX_BYTES`): a stated failure that names the
range and the size, the two things that make it smaller."""
if out.stat().st_size > gifmod.MAX_BYTES:
raise StatedFailure(dlerrors.GIF_TOO_BIG_MESSAGE)
def _gif_still(rel: str) -> str | None:
"""The placed GIF's still (`gif.ensure_still`), as the poster's rel path beside it."""
still = gifmod.ensure_still(storage.abs_path(settings.files_root, rel))
return str(Path(rel).with_suffix(".jpg")) if still else None
def _process_clip(job_id: int, asset_id: int) -> None:
"""Cut an ad-hoc clip (downloads/clip): fetch or open its source, ONE re-encode to the clip
shape, then into the user's library beside their edits."""
@@ -1079,34 +1122,49 @@ def _process_clip(job_id: int, asset_id: int) -> None:
raise StatedFailure("The source file could not be read.")
audio_track = spec.get("audio_track")
tracks = clipmod.audio_track_count(data)
if tracks == 0:
gif = spec.get("gif")
if gif:
# A GIF has no sound: no track is chosen, so none can be missing.
audio_track = None
elif tracks == 0:
# A silent source: the player still names its track 0, and the clip is silent too.
audio_track = None
elif audio_track is not None and audio_track >= tracks:
raise StatedFailure("The chosen audio track is not in the file.")
crop = _clip_crop(spec, data)
total_s = clipmod.duration(spec)
out = staging / "clip.mp4"
hdr = clipmod.is_hdr(data)
out = staging / ("clip.gif" if gif else "clip.mp4")
_set_job(job_id, phase="editing", progress=0, speed_bps=None, eta_s=None)
editmod.run_ffmpeg(
clipmod.build_cmd(
cmd = (
gifmod.build_cmd(
src, out, start, total_s, pre=clipmod.gif_pre(crop, hdr), side=gif["side"]
)
if gif
else clipmod.build_cmd(
src,
out,
start,
total_s,
audio_track=audio_track,
hdr=clipmod.is_hdr(data),
hdr=hdr,
threads=editmod.encode_thread_cap(),
crop=crop,
),
)
)
editmod.run_ffmpeg(
cmd,
total_s,
on_progress=_ffmpeg_progress_writer(job_id, "editing"),
should_cancel=_cancel_poll(job_id),
)
if not out.exists():
raise StatedFailure("ffmpeg produced no output file")
# The phone-safe shape by construction; still held to the rule every ready file meets.
out = _ensure_phone_safe(job_id, out, staging, "editing", round(total_s))
if gif:
_hold_gif_cap(out)
else:
# The phone-safe shape by construction; still held to the rule every ready file meets.
out = _ensure_phone_safe(job_id, out, staging, "editing", round(total_s))
# Held to the cap by its real size: a range fetch passes yt-dlp's `max_filesize` to its
# ffmpeg downloader, which never reads it, and a Plex clip fetches nothing at all. Read
# again, since other jobs of this owner may have landed during the encode.
@@ -1118,6 +1176,8 @@ def _process_clip(job_id: int, asset_id: int) -> None:
storage.place_file(out, settings.files_root, rel)
final = storage.abs_path(settings.files_root, rel)
vcodec, acodec, width, height, _ = probe.probe_media(final)
if gif:
width, height = gifmod.size(final) or (None, None)
with SessionLocal() as db:
asset = db.get(MediaAsset, asset_id)
@@ -1135,8 +1195,10 @@ def _process_clip(job_id: int, asset_id: int) -> None:
asset.duration_s = round(total_s)
asset.media_kind = mediakind.derive(vcodec, acodec)
# The clip's own frame as its poster: the link preview shows the moment, and a Plex
# item has no public thumbnail at all.
asset.poster_path = editmod.ensure_poster(asset, settings.files_root)
# item has no public thumbnail at all. A GIF's is its first frame, cut from the GIF.
asset.poster_path = (
_gif_still(rel) if gif else editmod.ensure_poster(asset, settings.files_root)
)
asset.error = None
asset.last_access_at = datetime.now(timezone.utc)
fields: dict = {"status": JobStatus.DONE, "progress": 100, "phase": None}
+10
View File
@@ -45,6 +45,16 @@ TEXT_EXTS: frozenset[str] = frozenset(
TEXT_TYPE = "text/plain; charset=utf-8"
# Every preview served inline — the library's viewer and a share link's picture — goes with these.
# `sandbox` leaves the answer no script and an opaque origin, whatever the file turns out to hold (a
# "gif" that is really a page); `frame-ancestors 'self'` lets the app's viewer frame a PDF (every
# other answer keeps the app-wide 'none', main.py), with X-Frame-Options saying the same for old
# engines.
SANDBOX_HEADERS: dict[str, str] = {
"Content-Security-Policy": "sandbox; frame-ancestors 'self'",
"X-Frame-Options": "SAMEORIGIN",
}
# A text preview is the file's head: enough to read, never a whole log loaded into a tab.
TEXT_PREVIEW_BYTES = 1 << 20
# A NUL byte in the first this-many bytes means the "text" is not text.
+37 -5
View File
@@ -23,6 +23,7 @@ from app.downloads import adapters
from app.downloads import clip as clipmod
from app.downloads import credentials
from app.downloads import edit as editmod
from app.downloads import gif as gifmod
from app.downloads import folders, formats, mediakind, quota, storage
from app.downloads.status import HOLDING, JobKind, JobStatus
from app.models import DISPLAY_TEXT_MAX, User
@@ -42,14 +43,24 @@ from app.userscope import owned_by, owned_row
# Every reason an edit is refused; the UI names each one (`downloads.errors.edit.<reason>`, held by
# the frontend's backendIds contract, which reads this Literal).
EditReason = Literal["source_not_ready", "not_media", "empty_edit"]
EditReason = Literal[
"source_not_ready",
"not_media",
"empty_edit",
"bad_gif",
"gif_range",
"gif_too_long",
"gif_not_video",
]
class EditError(Exception):
"""Bad edit request (empty spec / source not ready / not media); `reason` names the sentence."""
"""Bad edit request (empty spec / source not ready / not media / a GIF it cannot make); `reason`
names the sentence, `params` the numbers it names (a cap travels with its refusal)."""
def __init__(self, reason: EditReason):
def __init__(self, reason: EditReason, **params: float):
self.reason: EditReason = reason
self.params = params
super().__init__(reason)
@@ -255,6 +266,18 @@ def clip_title(source_title: str | None, display_name: str | None) -> str:
return f"{base} (clip)"[:DISPLAY_TEXT_MAX]
def _check_gif_edit(spec: dict) -> None:
"""A GIF edit is one marked range with an end, at most `gif.MAX_S` long, of a size `gif` offers
(a size it does not offer was dropped by `normalize_edit_spec`). Raises EditError."""
if not spec.get("gif"):
raise EditError("bad_gif")
trim = spec.get("trim") or {}
if spec.get("segments") or trim.get("end_s") is None:
raise EditError("gif_range")
if gifmod.too_long(trim.get("start_s", 0.0), trim["end_s"]):
raise EditError("gif_too_long", limit_s=gifmod.MAX_S)
def enqueue_edit(
db: Session,
user_id: int,
@@ -280,6 +303,11 @@ def enqueue_edit(
spec = editmod.normalize_edit_spec(edit_spec)
if not spec:
raise EditError("empty_edit")
if "gif" in (edit_spec or {}):
# A GIF is made of the first video stream: audio has none, or only its cover picture.
if src_asset.media_kind != mediakind.VIDEO:
raise EditError("gif_not_video")
_check_gif_edit(spec)
quota.check_enqueue(db, user_id)
@@ -294,7 +322,10 @@ def enqueue_edit(
asset.duration_s = editmod.clip_duration(spec, src_asset.duration_s)
# A clip inherits its source's kind (a video edit stays video, an audio trim stays audio);
# the ffmpeg edit worker doesn't re-probe codecs, so set it here where the source is known.
asset.media_kind = src_asset.media_kind or mediakind.VIDEO
# A GIF is a picture whatever it was cut from (downloads/gif).
asset.media_kind = (
mediakind.FILE if spec.get("gif") else src_asset.media_kind or mediakind.VIDEO
)
job = DownloadJob(
user_id=user_id,
@@ -384,7 +415,8 @@ def enqueue_clip(
asset.uploader = facts["uploader"]
asset.thumbnail_url = facts["thumbnail_url"]
asset.duration_s = round(clipmod.duration(spec))
asset.media_kind = mediakind.VIDEO
# A GIF is a picture: no player and no editor, an image preview (downloads/gif).
asset.media_kind = mediakind.FILE if spec.get("gif") else mediakind.VIDEO
job = DownloadJob(
user_id=user_id,
+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
+7 -4
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)
)
@@ -387,18 +389,19 @@ async def index() -> FileResponse:
@app.get("/watch/{token}")
@app.get("/file/{token}")
@app.get("/folder/{token}")
async def share_page(token: str):
async def share_page(token: str, request: Request):
"""Serve the public page of a share link, with per-link Open Graph tags injected so the
link unfurls richly (title/channel/thumbnail) in Messenger/social crawlers. A real browser
ignores the extra tags and boots WatchPage; invalid/expired/password links fall back to the
generic card."""
generic card. A GIF's card picture depends on which crawler asks (`og._moving_image`), so the
answer varies by User-Agent."""
from app.downloads import og
with SessionLocal() as db:
rendered = og.render_watch_html(token, WATCH_HTML, db)
rendered = og.render_watch_html(token, WATCH_HTML, db, request.headers.get("user-agent"))
if rendered is None:
return FileResponse(WATCH_HTML, headers=INDEX_HEADERS)
return HTMLResponse(rendered, headers=INDEX_HEADERS)
return HTMLResponse(rendered, headers={**INDEX_HEADERS, "Vary": "User-Agent"})
@app.get("/{full_path:path}")
+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",
+6 -2
View File
@@ -318,7 +318,9 @@ def enqueue_edit(
except quota.QuotaExceeded as e:
raise HTTPException(status_code=422, detail=quota_detail(e)) from e
except service.EditError as e:
raise HTTPException(status_code=400, detail={"code": "edit", "reason": e.reason}) from e
raise HTTPException(
status_code=400, detail={**e.params, "code": "edit", "reason": e.reason}
) from e
db.commit()
return serialize_job(db, job)
@@ -341,6 +343,8 @@ class EnqueueClipIn(BaseModel):
audio_track: int | None = None
# A crop frame (downloads/clip): fractions of the box it was drawn in, and that box's aspect.
crop: ClipCropIn | None = None
# A GIF of this long side (downloads/gif) instead of a video; None is the video clip.
gif_side: int | None = None
display_name: str | None = None
@@ -353,7 +357,7 @@ def enqueue_clip(
try:
crop = body.crop.model_dump() if body.crop else None
spec = clipmod.normalize(
body.source, body.ref, body.start_s, body.end_s, body.audio_track, crop
body.source, body.ref, body.start_s, body.end_s, body.audio_track, crop, body.gif_side
)
display_name = display_text(body.display_name)
job = service.enqueue_clip(db, user.id, spec, display_name=display_name)
+2 -7
View File
@@ -715,13 +715,8 @@ def get_file(
return response
# A preview's own headers. `sandbox` leaves the answer no script and an opaque origin, whatever the
# file turns out to hold; `frame-ancestors 'self'` lets the app's viewer frame a PDF (every other
# answer keeps the app-wide 'none', main.py), with X-Frame-Options saying the same for old engines.
PREVIEW_HEADERS = {
"Content-Security-Policy": "sandbox; frame-ancestors 'self'",
"X-Frame-Options": "SAMEORIGIN",
}
# A preview's own headers, shared with a share link's picture (`preview.SANDBOX_HEADERS`).
PREVIEW_HEADERS = preview.SANDBOX_HEADERS
# Set on a text preview cut at `preview.TEXT_PREVIEW_BYTES`, so the viewer can say it shows the start.
TRUNCATED_HEADER = "X-Preview-Truncated"
+15 -4
View File
@@ -16,7 +16,7 @@ from sqlalchemy.orm import Session
from app.config import settings
from app.db import get_db
from app.downloads import links as linksmod
from app.downloads import queries, storage
from app.downloads import preview, queries, storage
from app.downloads.status import JobStatus
from app.ratelimit import RateLimiter, client_key
from app.security import verify_password
@@ -226,8 +226,9 @@ def _granted(link: "DownloadLink", token: str, g: str | None) -> None:
def _serve(link: "DownloadLink", asset: "MediaAsset") -> FileResponse:
"""Range-aware stream of a shared file. Inline by default (plays in the page); an
`allow_download` link serves it as an attachment."""
"""Range-aware stream of a shared file. Inline by default (a video plays and a picture shows in
the page, and a chat's link preview can fetch the picture); an `allow_download` link serves it
as an attachment."""
path = storage.safe_abs_path(settings.files_root, asset.rel_path)
if path is None:
raise refused(404, "video_gone")
@@ -241,6 +242,17 @@ def _serve(link: "DownloadLink", asset: "MediaAsset") -> FileResponse:
# served with its real audio/video type, inline unless the link forces a download so it can play
# in the watch page. Both are range-aware (FileResponse → 206) for seeking/resume.
media_type = storage.served_media_type(asset.media_kind, asset.container)
disposition = "attachment" if link.allow_download else "inline"
if media_type is None and preview.kind(asset.media_kind, asset.container) == "image":
# A picture (a GIF made from a clip, an uploaded photo) is shown as itself: its type from the
# preview allow-list, under the preview's sandbox, so a mislabelled one still runs nothing.
return FileResponse(
path,
filename=filename,
media_type=preview.media_type("image", asset.container),
content_disposition_type=disposition,
headers=preview.SANDBOX_HEADERS,
)
if media_type is None:
return FileResponse(
path,
@@ -248,7 +260,6 @@ def _serve(link: "DownloadLink", asset: "MediaAsset") -> FileResponse:
media_type="application/octet-stream",
content_disposition_type="attachment",
)
disposition = "attachment" if link.allow_download else "inline"
return FileResponse(
path, filename=filename, media_type=media_type, content_disposition_type=disposition
)
+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)
+154
View File
@@ -710,6 +710,160 @@ def test_a_clip_larger_than_what_its_owner_has_left_is_refused_and_not_placed(
)
# --- a GIF clip ------------------------------------------------------------------------------------------
def test_a_gif_clip_spec_names_its_size_and_is_another_clip_than_the_video():
mp4 = clipmod.normalize("youtube", "abcdefghijk", 10, 20)
gif = clipmod.normalize("youtube", "abcdefghijk", 10, 20, gif_side=320)
assert gif == {**mp4, "gif": {"side": 320}}
# A video clip's spec (and so its cache key) is the one it always was.
assert "gif" not in mp4
assert clipmod.sig(gif) != clipmod.sig(mp4)
@pytest.mark.parametrize(
("kw", "reason", "params"),
[
({"end_s": 40.001, "gif_side": 480}, "gif_too_long", {"limit_s": 30}),
({"end_s": 20, "gif_side": 500}, "bad_gif", {}),
({"end_s": 20, "gif_side": True}, "bad_gif", {}),
],
)
def test_a_gif_clip_it_cannot_make_is_refused_with_its_reason(kw, reason, params):
with pytest.raises(clipmod.ClipError) as e:
clipmod.normalize("youtube", "abcdefghijk", 10, **kw)
assert (e.value.reason, e.value.params) == (reason, params)
def test_the_thirty_second_gif_cap_itself_is_allowed():
assert clipmod.duration(clipmod.normalize("plex", "42", 0, 30, gif_side=480)) == 30
@pytest.mark.parametrize(
("start", "end", "kw"),
[
# Each a range exactly at a cap whose float difference lands a hair past it.
(30.529, 60.529, {"gif_side": 480}),
(424.005, 1024.005, {}),
(0.001, 1.001, {}),
],
)
def test_a_range_exactly_at_a_cap_is_judged_in_milliseconds(start, end, kw):
assert end - start not in (1, 30, 600) # the float difference is off the cap itself
assert round((end - start) * 1000) in (1000, 30000, 600000)
clipmod.normalize("youtube", "abcdefghijk", start, end, **kw)
def test_a_gif_clip_keeps_no_audio_track_so_the_track_does_not_make_another_gif():
first = clipmod.normalize("plex", "42", 10, 20, audio_track=0, gif_side=480)
other = clipmod.normalize("plex", "42", 10, 20, audio_track=1, gif_side=480)
assert "audio_track" not in first
assert clipmod.sig(first) == clipmod.sig(other)
# A video clip keeps the track it was heard on.
assert clipmod.normalize("plex", "42", 10, 20, audio_track=1)["audio_track"] == 1
def test_a_gif_clip_is_queued_as_a_file_and_too_long_says_its_own_cap(
db, make_user, as_user, yt_video
):
c = as_user(make_user())
r = _clip(c, source="youtube", ref=yt_video, gif_side=480)
assert r.status_code == 200, r.text
job = r.json()
assert job["edit_spec"]["gif"] == {"side": 480}
# A GIF is a picture: no player, no editor; it previews as an image.
assert db.get(MediaAsset, job["asset"]["id"]).media_kind == "file"
r = _clip(c, source="youtube", ref=yt_video, gif_side=480, end_s=45)
assert r.status_code == 422
assert r.json()["detail"] == {"code": "clip", "reason": "gif_too_long", "limit_s": 30}
def _gif_bytes(w: int, h: int, n: int = 0) -> bytes:
return b"GIF89a" + w.to_bytes(2, "little") + h.to_bytes(2, "little") + b"\x00" * (1 + n)
@pytest.fixture
def gif_worker(worker, monkeypatch):
"""The worker stand-in, writing a GIF header where a GIF is asked for; the still is a sidecar."""
seen = worker
seen["gif_extra"] = 0
def ffmpeg(cmd, *_a, **_k):
seen["cmd"] = cmd
out = Path(cmd[-1])
out.write_bytes(_gif_bytes(270, 480, seen["gif_extra"]) if out.suffix == ".gif" else b"x")
def still(path):
dest = path.with_suffix(".jpg")
dest.write_bytes(b"\xff\xd8\xff")
return dest
def phone_safe(*_a, **_k):
raise AssertionError("a GIF is not a video: no phone-safe pass")
monkeypatch.setattr(pipeline.editmod, "run_ffmpeg", ffmpeg)
monkeypatch.setattr(pipeline.gifmod, "ensure_still", still)
monkeypatch.setattr(pipeline, "_ensure_phone_safe", phone_safe)
monkeypatch.setattr(probemod, "probe_media", lambda _p: (None, None, None, None, None))
return seen
def test_a_gif_clip_is_cut_through_the_gif_command_and_stored_as_an_image_file(
db, make_user, as_user, plex_episode, gif_worker
):
gif_worker["probe"] = {
"streams": [{"codec_type": "video", "color_transfer": "smpte2084"}, {"codec_type": "audio"}]
}
job = _clip(as_user(make_user()), source="plex", ref="777", gif_side=480, end_s=25).json()
job, asset = gif_worker["run"](job)
assert (job.status, asset.status, asset.container, asset.media_kind) == (
"done",
"ready",
"gif",
"file",
)
assert asset.rel_path.endswith(".gif")
assert (asset.width, asset.height) == (270, 480)
assert asset.poster_path == str(Path(asset.rel_path).with_suffix(".jpg"))
cmd = gif_worker["cmd"]
graph = cmd[cmd.index("-filter_complex") + 1]
# The HDR source is tonemapped before the palette is made from it; no sound rides along.
assert graph.index("tonemap") < graph.index("palettegen")
assert "-an" in cmd and "-map" not in cmd
assert cmd[cmd.index("-t") + 1] == "15.000"
def test_a_gif_clip_is_never_refused_for_an_audio_track(
db, make_user, as_user, plex_episode, gif_worker
):
# A GIF queued with a track (as one made before GIFs dropped it was): the file has one track,
# the spec names a fourth, and a GIF, which has no sound, is made all the same.
job = _clip(as_user(make_user()), source="plex", ref="777", gif_side=480, end_s=25).json()
row = db.get(DownloadJob, job["id"])
row.edit_spec = {**row.edit_spec, "audio_track": 3}
db.commit()
job, asset = gif_worker["run"](job)
assert (job.status, asset.status) == ("done", "ready")
def test_a_gif_over_its_size_cap_is_not_kept_and_says_why(
db, make_user, as_user, plex_episode, gif_worker, monkeypatch
):
from app.downloads import gif as gifmod
monkeypatch.setattr(gifmod, "MAX_BYTES", 20)
gif_worker["gif_extra"] = 50
job = _clip(as_user(make_user()), source="plex", ref="777", gif_side=640).json()
job, asset = gif_worker["run"](job)
assert (job.status, job.error_code, job.error, asset.rel_path) == (
"error",
"gif_too_big",
dlerrors.GIF_TOO_BIG_MESSAGE,
None,
)
def test_a_youtube_clip_range_yt_dlp_refuses_for_its_cap_says_it_is_too_big(
db, make_user, as_user, yt_video, worker, monkeypatch
):
+8
View File
@@ -75,6 +75,8 @@ def test_our_own_worker_refusals_classify_as_themselves():
assert classify(dlerrors.CLIP_TOO_BIG_MESSAGE) == "quota_exceeded"
assert classify(dlerrors.CONVERTED_TOO_BIG_MESSAGE) == "quota_exceeded"
assert classify(dlerrors.DOWNLOAD_TOO_BIG_MESSAGE) == "quota_exceeded"
# A GIF over its own cap is not the storage: its own category, its own sentence.
assert classify(dlerrors.GIF_TOO_BIG_MESSAGE) == "gif_too_big"
def test_the_redacted_network_text_classifies_back_to_network():
@@ -216,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)
+197
View File
@@ -0,0 +1,197 @@
"""The editor's GIF output: one marked range of a library video made into a GIF (downloads/gif), held
to the GIF's limits at enqueue, and made by the worker with real ffmpeg."""
import shutil
import subprocess
from pathlib import Path
import pytest
from app.config import settings
from app.downloads import clip as clipmod
from app.downloads import edit as editmod
from app.downloads import pipeline, service
from app.downloads import probe as probemod
from app.downloads import gif as gifmod
from app.downloads import upload as uploadmod
from app.downloads.models import DownloadJob, MediaAsset
needs_ffmpeg = pytest.mark.skipif(shutil.which("ffmpeg") is None, reason="ffmpeg not available")
def test_a_gif_edit_spec_keeps_its_size_and_is_another_edit_than_the_video():
video = editmod.normalize_edit_spec({"trim": {"start_s": 1, "end_s": 5}})
gif = editmod.normalize_edit_spec({"trim": {"start_s": 1, "end_s": 5}, "gif": {"side": 320}})
assert gif["gif"] == {"side": 320} and "gif" not in video
assert editmod.edit_sig(gif) != editmod.edit_sig(video)
# A GIF is always made, never stream-copied.
assert editmod.needs_reencode(gif)
def _source(db, user, tmp_path, seconds=40):
src = tmp_path / "in.mp4"
subprocess.run(
[
"ffmpeg", "-y", "-loglevel", "error",
"-f", "lavfi", "-i", f"testsrc2=size=640x360:rate=10:duration={seconds}",
"-c:v", "libx264", "-pix_fmt", "yuv420p", str(src),
],
check=True,
) # fmt: skip
return uploadmod.ingest_upload(
db, user.id, filename="in.mp4", src_path=src, size=src.stat().st_size
)
@needs_ffmpeg
@pytest.mark.parametrize(
("spec", "reason"),
[
# A GIF is one range with an end: no join, no open end.
({"segments": [{"start_s": 0, "end_s": 2}, {"start_s": 4, "end_s": 6}]}, "gif_range"),
({"trim": {"start_s": 3}}, "gif_range"),
({"trim": {"start_s": 0, "end_s": 30.5}}, "gif_too_long"),
({"trim": {"start_s": 0, "end_s": 5}, "gif_side": 999}, "bad_gif"),
],
)
def test_a_gif_edit_it_cannot_make_is_refused_with_its_reason(
db, make_user, tmp_path, monkeypatch, spec, reason
):
monkeypatch.setattr(settings, "files_root", str(tmp_path / "root"))
u = make_user()
source = _source(db, u, tmp_path)
side = spec.pop("gif_side", 480)
with pytest.raises(service.EditError) as e:
service.enqueue_edit(db, u.id, source, {**spec, "gif": {"side": side}})
assert e.value.reason == reason
@needs_ffmpeg
def test_the_edit_route_says_a_gif_too_long_with_its_cap(
db, make_user, as_user, tmp_path, monkeypatch
):
monkeypatch.setattr(settings, "files_root", str(tmp_path / "root"))
u = make_user()
c = as_user(u)
source = _source(db, u, tmp_path)
r = c.post(
"/api/downloads/edit",
json={
"source_job_id": source.id,
"edit_spec": {"trim": {"start_s": 0, "end_s": 30.5}, "gif": {"side": 480}},
},
)
assert r.status_code == 400, r.text
# The cap goes along: the refusal's sentence says it ("at most 30 seconds").
assert r.json()["detail"] == {"code": "edit", "reason": "gif_too_long", "limit_s": 30}
@needs_ffmpeg
def test_a_gif_edit_of_exactly_thirty_seconds_is_allowed(db, make_user, tmp_path, monkeypatch):
monkeypatch.setattr(settings, "files_root", str(tmp_path / "root"))
u = make_user()
source = _source(db, u, tmp_path)
# Ends kept to the millisecond whose float difference lands a hair over 30 s.
assert 32.02 - 2.02 > gifmod.MAX_S
job = service.enqueue_edit(
db, u.id, source, {"trim": {"start_s": 2.02, "end_s": 32.02}, "gif": {"side": 480}}
)
assert job.edit_spec["gif"] == {"side": 480}
@needs_ffmpeg
def test_a_gif_is_made_of_a_video_never_of_audio(db, make_user, tmp_path, monkeypatch):
monkeypatch.setattr(settings, "files_root", str(tmp_path / "root"))
u = make_user()
src = tmp_path / "in.m4a"
subprocess.run(
[
"ffmpeg", "-y", "-loglevel", "error",
"-f", "lavfi", "-i", "sine=frequency=440:duration=10", "-c:a", "aac", str(src),
],
check=True,
) # fmt: skip
source = uploadmod.ingest_upload(
db, u.id, filename="in.m4a", src_path=src, size=src.stat().st_size
)
assert db.get(MediaAsset, source.asset_id).media_kind == "audio"
spec = {"trim": {"start_s": 1, "end_s": 5}}
with pytest.raises(service.EditError) as e:
service.enqueue_edit(db, u.id, source, {**spec, "gif": {"side": 480}})
assert e.value.reason == "gif_not_video"
# The same range as audio is an edit as it always was.
assert service.enqueue_edit(db, u.id, source, spec).edit_spec["trim"]["end_s"] == 5
@needs_ffmpeg
def test_an_hdr_source_is_tonemapped_before_its_gif_palette(db, make_user, tmp_path, monkeypatch):
monkeypatch.setattr(settings, "files_root", str(tmp_path / "root"))
u = make_user()
src = tmp_path / "hlg.mp4"
subprocess.run(
[
"ffmpeg", "-y", "-loglevel", "error",
"-f", "lavfi", "-i", "testsrc2=size=640x360:rate=10:duration=4",
"-vf", "setparams=color_trc=arib-std-b67:color_primaries=bt2020:colorspace=bt2020nc",
"-c:v", "libx264", "-pix_fmt", "yuv420p", str(src),
],
check=True,
) # fmt: skip
source = uploadmod.ingest_upload(
db, u.id, filename="hlg.mp4", src_path=src, size=src.stat().st_size
)
# The upload keeps its HLG tag, as an HDR phone video's does.
placed = Path(settings.files_root) / db.get(MediaAsset, source.asset_id).rel_path
assert clipmod.is_hdr(probemod.probe_json(placed) or {})
job = service.enqueue_edit(
db, u.id, source, {"trim": {"start_s": 1, "end_s": 3}, "gif": {"side": 320}}
)
asset = db.get(MediaAsset, job.asset_id)
job.status, asset.status = "running", "downloading"
db.commit()
seen: dict = {}
build = gifmod.build_cmd
def spy(*a, **k):
seen["pre"] = k["pre"]
return build(*a, **k)
monkeypatch.setattr(pipeline.gifmod, "build_cmd", spy)
pipeline._process_edit(job.id, asset.id)
assert "tonemap" in seen["pre"]
db.expire_all()
assert db.get(DownloadJob, job.id).status == "done"
@needs_ffmpeg
def test_a_gif_edit_is_made_by_the_worker_and_kept_as_an_image_file(
db, make_user, tmp_path, monkeypatch
):
monkeypatch.setattr(settings, "files_root", str(tmp_path / "root"))
u = make_user()
source = _source(db, u, tmp_path, seconds=6)
spec = {"trim": {"start_s": 1, "end_s": 3}, "crop": {"x": 0, "y": 0, "w": 320, "h": 360}}
job = service.enqueue_edit(db, u.id, source, {**spec, "gif": {"side": 320}})
asset = db.get(MediaAsset, job.asset_id)
assert asset.media_kind == "file"
job.status, asset.status = "running", "downloading" # the worker's claim
db.commit()
pipeline._process_edit(job.id, asset.id)
db.expire_all()
job, asset = db.get(DownloadJob, job.id), db.get(MediaAsset, job.asset_id)
assert (job.status, asset.status, asset.container, asset.media_kind) == (
"done",
"ready",
"gif",
"file",
)
placed = tmp_path / "root" / asset.rel_path
assert placed.read_bytes()[:6] == b"GIF89a"
# The crop's 320x360 has its long side (360) fitted into 320.
assert (asset.width, asset.height) == gifmod.size(placed) == (284, 320)
assert asset.poster_path and (tmp_path / "root" / asset.poster_path).read_bytes()[:2] == (
b"\xff\xd8"
)
@@ -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).
+167
View File
@@ -0,0 +1,167 @@
"""The GIF output (downloads/gif): its limits, the one ffmpeg command every GIF goes through, and what
that command really makes — run with real ffmpeg, since the size, the palette and the frame are the
encoder's, not the argv's."""
import shutil
import subprocess
from pathlib import Path
import pytest
from app.downloads import gif as gifmod
from app.downloads import mediakind
from app.downloads import probe as probemod
from app.downloads.edit import INPUT_GUARD
needs_ffmpeg = pytest.mark.skipif(shutil.which("ffmpeg") is None, reason="ffmpeg not available")
def test_a_side_is_one_of_the_offered_sizes_and_anything_else_is_refused():
assert gifmod.SIDES == (320, 480, 640) and gifmod.DEFAULT_SIDE == 480
for side in gifmod.SIDES:
assert gifmod.normalize_side(side) == side
for bad in (0, 100, 481, 1080, -480, True, "480", None, 480.5):
with pytest.raises(ValueError):
gifmod.normalize_side(bad)
def test_the_limits_are_the_measured_ones():
# 30 s at most; a link preview moves in Teams up to 15 MB (measured: 15 moved, 22 did not); a GIF
# over 30 MB is not kept.
assert gifmod.MAX_S == 30
assert gifmod.PREVIEW_MAX_BYTES == 15 * 1024 * 1024
assert gifmod.MAX_BYTES == 30 * 1024 * 1024
assert gifmod.FPS == 12
def _cmd(**kw):
args = {"pre": "", "side": 480, **kw}
return gifmod.build_cmd(Path("/m/in.mkv"), Path("/s/out.gif"), 61.5, 12.0, **args)
def test_the_seek_is_on_the_input_and_the_library_read_is_guarded():
cmd = _cmd()
i = cmd.index("-i")
assert cmd[cmd.index("-ss") + 1] == "61.500" and cmd.index("-ss") < i
assert cmd[i + 1] == "/m/in.mkv"
assert cmd[cmd.index("-t") + 1] == "12.000" and cmd.index("-t") > i
g = cmd.index(INPUT_GUARD[0])
assert cmd[g : g + len(INPUT_GUARD)] == INPUT_GUARD and g < i
assert cmd[-1] == "/s/out.gif"
def test_a_gif_has_no_sound_and_one_palette_of_its_own():
cmd = _cmd()
assert "-an" in cmd and "-sn" in cmd and "-dn" in cmd
graph = cmd[cmd.index("-filter_complex") + 1]
assert f"fps={gifmod.FPS}" in graph
assert "palettegen=stats_mode=diff" in graph
assert "paletteuse=dither=bayer:bayer_scale=5:diff_mode=rectangle" in graph
assert "flags=lanczos" in graph
def test_a_crop_or_a_tonemap_comes_before_the_scale_and_the_palette():
cmd = _cmd(pre="crop=100:100:0:0,format=yuv420p")
graph = cmd[cmd.index("-filter_complex") + 1]
assert graph.index("crop=100:100:0:0") < graph.index("scale=") < graph.index("palettegen")
def test_the_length_cap_counts_whole_milliseconds():
# Two ends kept to the millisecond whose float difference lands a hair over 30 s.
assert 60.529 - 30.529 > gifmod.MAX_S
assert not gifmod.too_long(30.529, 60.529)
assert not gifmod.too_long(0, 30)
assert gifmod.too_long(0, 30.001)
def test_the_pixels_are_squared_after_the_crop_and_before_the_size():
graph = gifmod.filter_graph("crop=100:100:0:0", 480)
assert graph.index("crop=") < graph.index("setsar=1") < graph.index("flags=lanczos")
def _video(path: Path, size: str, seconds: int = 2, sar: str = "1") -> Path:
subprocess.run(
[
"ffmpeg", "-y", "-loglevel", "error",
"-f", "lavfi", "-i", f"testsrc2=size={size}:rate=25:duration={seconds}",
"-vf", f"setsar={sar}",
"-c:v", "libx264", "-pix_fmt", "yuv420p", str(path),
],
check=True,
) # fmt: skip
return path
@needs_ffmpeg
@pytest.mark.parametrize(
("size", "sar", "expected"),
[
# A DVD stored 720x480 and shown at 16:9: a GIF shows its stored pixels as they are, so
# they are squared first, or the picture comes out 480x320 (3:2), squeezed.
("720x480", "32/27", (480, 270)),
# HDV: 1440x1080 shown at 16:9.
("1440x1080", "4/3", (480, 270)),
],
)
def test_a_source_with_wide_pixels_keeps_its_shown_shape(tmp_path, size, sar, expected):
src = _video(tmp_path / "in.mp4", size, sar=sar)
out = tmp_path / "out.gif"
subprocess.run(gifmod.build_cmd(src, out, 0.0, 1.0, pre="", side=480), check=True)
assert gifmod.size(out) == expected
@needs_ffmpeg
@pytest.mark.parametrize(
("size", "side", "expected"),
[
# The long side is capped, so a phone's portrait video is as small as a landscape one.
("1280x720", 480, (480, 270)),
("720x1280", 480, (270, 480)),
("1280x720", 320, (320, 180)),
# A picture already smaller than the size is never blown up.
("160x120", 640, (160, 120)),
],
)
def test_the_long_side_is_capped_and_a_small_picture_is_kept(tmp_path, size, side, expected):
src = _video(tmp_path / "in.mp4", size)
out = tmp_path / "out.gif"
subprocess.run(gifmod.build_cmd(src, out, 0.5, 1.0, pre="", side=side), check=True)
assert out.read_bytes()[:6] == b"GIF89a"
assert gifmod.size(out) == expected
@needs_ffmpeg
def test_a_made_gif_is_a_library_file_that_previews_as_an_image(tmp_path):
src = _video(tmp_path / "in.mp4", "320x240")
out = tmp_path / "out.gif"
subprocess.run(gifmod.build_cmd(src, out, 0.0, 1.0, pre="", side=320), check=True)
vcodec, acodec, *_ = probemod.probe_media(out)
assert mediakind.derive(vcodec, acodec) == mediakind.FILE
@needs_ffmpeg
def test_a_gif_gets_a_jpeg_still_for_link_previews(tmp_path):
src = _video(tmp_path / "in.mp4", "320x240")
out = tmp_path / "out.gif"
subprocess.run(gifmod.build_cmd(src, out, 0.0, 1.0, pre="", side=320), check=True)
still = gifmod.ensure_still(out)
assert still == out.with_suffix(".jpg")
assert still.read_bytes()[:3] == b"\xff\xd8\xff"
@needs_ffmpeg
def test_the_still_is_cut_only_from_a_real_gif(tmp_path):
# The still's read is held to the GIF demuxer: a "gif" that is something else gives no still.
fake = tmp_path / "out.gif"
fake.write_bytes(b"ffconcat version 1.0\nfile '/etc/passwd'\n")
assert gifmod.ensure_still(fake) is None
def test_the_size_is_read_from_the_gif_header_and_nothing_else_has_one(tmp_path):
g = tmp_path / "a.gif"
g.write_bytes(b"GIF89a" + (300).to_bytes(2, "little") + (170).to_bytes(2, "little") + b"\x00")
assert gifmod.size(g) == (300, 170)
other = tmp_path / "b.gif"
other.write_bytes(b"\x89PNG\r\n\x1a\n")
assert gifmod.size(other) is None
assert gifmod.size(tmp_path / "missing.gif") is None
+137
View File
@@ -0,0 +1,137 @@
"""A shared GIF (or any shared picture): served inline as itself on the public page, and its link
preview card. Teams moves a GIF `og:image` up to 15 MB; Messenger never showed one and showed a JPEG
(measured 2026-11-09/11), so Facebook's crawler and a GIF too big to move get the still. DB-lane."""
import pytest
from app import main
from app.config import settings
from app.downloads import og
from app.downloads import probe as probemod
from app.downloads.models import DownloadJob, MediaAsset
_PAGE = """<!doctype html><html><head>
<!--OG:START--><meta property="og:title" content="Siftlode" /><!--OG:END-->
</head><body><script src="/assets/watch.js"></script></body></html>"""
_GIF_PROBE = {
"streams": [{"codec_type": "video", "codec_name": "gif", "width": 300, "height": 170}],
"format": {"format_name": "gif"},
}
_GIF = b"GIF89a" + (300).to_bytes(2, "little") + (170).to_bytes(2, "little") + b"\x00" * 16
_TEAMS = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) SkypeUriPreview Preview/0.5 skype-url-preview@microsoft.com"
_FACEBOOK = "facebookexternalhit/1.1 (+http://www.facebook.com/externalhit_uatext.php)"
@pytest.fixture
def pages(tmp_path, monkeypatch):
watch = tmp_path / "watch.html"
watch.write_text(_PAGE, encoding="utf-8")
monkeypatch.setattr(main, "WATCH_HTML", watch)
@pytest.fixture
def shared(as_user, make_user, tmp_path, monkeypatch, upload_headers):
"""Uploads `content` as `name` (ffprobe reading it as `data`) and shares it."""
monkeypatch.setattr(settings, "files_root", str(tmp_path / "media"))
owner = as_user(make_user())
def share(name: str, content: bytes, data: dict | None, **link: object) -> dict:
monkeypatch.setattr(probemod, "probe_json", lambda _p: data)
job = owner.post(
"/api/files/upload", content=content, headers=upload_headers(name, display_name=name)
)
assert job.status_code == 200, job.text
made = owner.post(f"/api/downloads/{job.json()['id']}/links", json=link)
assert made.status_code == 200, made.text
return {**made.json(), "job_id": job.json()["id"]}
return share
def _asset(db, made) -> MediaAsset:
return db.get(MediaAsset, db.get(DownloadJob, made["job_id"]).asset_id)
def test_a_shared_gif_is_served_inline_as_a_gif_and_can_run_nothing(shared, client):
made = shared("wave.gif", _GIF, _GIF_PROBE, allow_download=False)
r = client.get(f"/api/public/watch/{made['token']}/file")
assert r.status_code == 200
assert r.headers["content-type"] == "image/gif"
assert r.headers["content-disposition"].startswith("inline")
# A "gif" whose bytes are a page must not run on this login-free origin.
assert r.headers["x-content-type-options"] == "nosniff"
assert "sandbox" in r.headers["content-security-policy"]
assert r.content == _GIF
def test_a_gif_link_that_allows_download_hands_it_over_as_a_file(shared, client):
made = shared("wave.gif", _GIF, _GIF_PROBE, allow_download=True)
r = client.get(f"/api/public/watch/{made['token']}/file")
assert r.headers["content-type"] == "image/gif"
assert r.headers["content-disposition"].startswith("attachment")
def test_any_other_file_is_still_only_a_download(shared, client):
made = shared("notes.html", b"<script>alert(1)</script>", None)
r = client.get(f"/api/public/watch/{made['token']}/file")
assert r.headers["content-type"] == "application/octet-stream"
assert r.headers["content-disposition"].startswith("attachment")
def test_the_page_is_told_a_picture_is_a_picture(shared, client):
made = shared("wave.gif", _GIF, _GIF_PROBE)
meta = client.get(f"/api/public/watch/{made['token']}").json()
assert meta["preview"] == "image"
other = shared("backup.tar.gz", b"x", None)
assert client.get(f"/api/public/watch/{other['token']}").json()["preview"] is None
def _og(text: str, prop: str) -> str | None:
marker = f'property="{prop}" content="'
if marker not in text:
return None
return text.split(marker, 1)[1].split('"', 1)[0]
def test_a_gif_link_previews_as_the_moving_gif_itself(pages, shared, client):
made = shared("wave.gif", _GIF, _GIF_PROBE)
page = client.get(f"/file/{made['token']}", headers={"User-Agent": _TEAMS}).text
assert _og(page, "og:image") == (f"{settings.app_base}/api/public/watch/{made['token']}/file")
assert _og(page, "og:image:type") == "image/gif"
# Its size from its own header: a card wants it up front.
assert (_og(page, "og:image:width"), _og(page, "og:image:height")) == ("300", "170")
def test_facebooks_crawler_gets_the_still_of_a_gif(pages, shared, client, db):
made = shared("wave.gif", _GIF, _GIF_PROBE)
asset = _asset(db, made)
asset.poster_path = "wave.jpg"
db.commit()
page = client.get(f"/file/{made['token']}", headers={"User-Agent": _FACEBOOK}).text
assert _og(page, "og:image") == (
f"{settings.app_base}/api/public/watch/{made['token']}/poster.jpg"
)
assert _og(page, "og:image:type") == "image/jpeg"
def test_a_gif_too_big_to_move_previews_as_its_still(pages, shared, client, db):
made = shared("wave.gif", _GIF, _GIF_PROBE)
asset = _asset(db, made)
asset.poster_path = "wave.jpg"
asset.size_bytes = 15 * 1024 * 1024 + 1
db.commit()
page = client.get(f"/file/{made['token']}", headers={"User-Agent": _TEAMS}).text
assert _og(page, "og:image").endswith("/poster.jpg")
def test_a_password_gif_link_shows_nothing_of_it(pages, shared, client):
made = shared("wave.gif", _GIF, _GIF_PROBE, password="pw")
page = client.get(f"/file/{made['token']}", headers={"User-Agent": _TEAMS}).text
assert _og(page, "og:image") is None
def test_the_card_builder_takes_no_agent_as_a_browser(shared, db):
made = shared("wave.gif", _GIF, _GIF_PROBE)
assert 'content="image/gif"' in og._watch_tags(made["token"], db)
+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")
+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",
+20
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
@@ -646,6 +648,19 @@ of a full-width panel and nothing on the right.
press outside the frame is still the video's. A cut is a toast with a Downloads link and the usual
"Ready to watch" notification. Limits are the server's: 1 s to 10 min (`lib/clipRange`, held to
`downloads/clip.py` by `clipLimits.contract.test.ts`; the "too long" message names the server's cap).
**GIF (owner's picks D1 A, D2 A, D3):** a GIF is made only here and in the Downloads editor, never from the
download dialog. The panel's Format · Video / GIF toggle sits above the name row; with GIF it shows the size
(the long side: 320 / 480 / 640 px), the expected size ("about 4–10 MB"; one number when both ends read the
same), a note over 15 MB (a chat's link preview will not move) or 30 MB (it may be refused), and over 30 s
the reason, with Make GIF unavailable. A refused length is shown rounded up, never as one the cap allows. The
cap is judged in whole milliseconds between the ends as sent, the server's own rule (the limits in `lib/gif`
are held to `downloads/gif.py` by `gif.contract.test.ts`; the rule itself is written on each side and
tested there against the same boundary cases). The editor has its own Format row (a video only): with GIF
each kept segment is a GIF of its own, Join is unavailable with its reason in a tooltip, the precise / fast
choice gives way to one line (a GIF is always re-encoded), and the notes name each segment over a limit,
in one live region. A shared GIF's page shows the picture itself (no player), its name, kind and size, its
Download, and a tip: the downloaded file moves in any chat. A picture that fails says so only once its
retry failed too.
## Dialogs — _Decided_ (C4, built in S3f)
@@ -878,3 +893,8 @@ of a full-width panel and nothing on the right.
| 2026-11-07 | The panel over the picture is darker, its error the media red (owner a) | `OVER_VIDEO` is black 85 % under `media-scope`: an error on it is the media red in both themes | Only the red changed (3.75:1 on a white frame); black 90 % | A light theme's red was 1.6:1 on the 75 % panel over a white frame; now 5.47:1 at worst, white text too |
| 2026-11-07 | A total's left-out videos are "without length" (owner b) | A summed length names what it left out as "+N without length" / "+N hossz nélkül" | "+N live" as before; "+N stream" | The count holds archives and upcoming streams too, which a card calls a stream or upcoming: "live" there named only one of them (One name per feature) |
| 2026-11-08 | The Channels table's Length is the sum alone (owner) | The table's Length cell shows the summed hours only; the "+N without length" stays on a playlist's total | The line wrapped in the column; the column widened to 6.5 rem | The 4.5 rem column is sized for the sum: the longer line spilled into Tags (88 px in 56), and the owner wants no such line there |
| 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
+8 -2
View File
@@ -12,7 +12,8 @@ import { useEffect, useState, type ImgHTMLAttributes, type ReactNode } from "rea
// `r` parameter (measured 2026-10-14). A 429 from a burst of cards heals this way.
// - While there is no picture — none given, a retry still waiting, every retry spent — the caller's
// `fallback` stands in (nothing by default: the card's own empty frame, which is what a card with no
// thumbnail has always shown). Never the broken-image mark.
// thumbnail has always shown), or its `pending` while a retry waits when the fallback says the
// picture failed. Never the broken-image mark.
// - The failure is remembered BY URL, in render: these sit in virtualized grids whose slots are recycled,
// and a failure stored as a flag an effect resets painted one item's fallback over the next one's
// picture for a frame (ui/Avatar had that defect).
@@ -47,6 +48,7 @@ export default function RemoteImage({
src,
srcSet,
fallback = null,
pending = fallback,
retries = IMAGE_RETRIES,
...img
}: Omit<ImgHTMLAttributes<HTMLImageElement>, "src" | "srcSet" | "onError" | "referrerPolicy"> & {
@@ -54,6 +56,9 @@ export default function RemoteImage({
srcSet?: string;
/** What stands in while there is no picture. */
fallback?: ReactNode;
/** What stands in while a retry waits, when that is not the fallback: a fallback that says the
* picture failed would say it of one that may still come. */
pending?: ReactNode;
/** Fewer retries for a picture whose failure is an answer, not a hiccup — a file that is not there
* yet is better shown as its fallback now than after seven seconds of retries. */
retries?: number;
@@ -75,7 +80,8 @@ export default function RemoteImage({
}, [waiting, attempt, src]);
const spent = current !== null && !current.waiting && current.attempt > retries;
if (!src || waiting || spent) return <>{fallback}</>;
if (waiting && src) return <>{pending}</>;
if (!src || spent) return <>{fallback}</>;
return (
// eslint-disable-next-line no-restricted-syntax -- the one <img> the rest of the app goes through
<img
@@ -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,47 @@
[
{
"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",
"new": [
"Make a GIF of up to 30 seconds: in a player's Clip panel, or in the Downloads editor, where each kept segment becomes a GIF of its own. Both say the expected size before it is made.",
"A shared GIF's link opens the moving picture itself, with Download beside it. In Teams its preview moves when the GIF is 15 MB or less."
],
"fixed": []
},
{
"version": "0.118.0",
"date": "2026-11-08",
@@ -1,4 +1,47 @@
[
{
"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",
"new": [
"Legfeljebb 30 másodperces GIF készíthető: a lejátszó Klip paneljén, vagy a Letöltések szerkesztőjében, ahol minden megtartott szegmensből külön GIF lesz. Mindkettő előre megmondja a várható méretet.",
"Egy megosztott GIF linkje magát a mozgó képet nyitja meg, mellette a Letöltéssel. Teamsben az előnézete mozog, ha a GIF legfeljebb 15 MB."
],
"fixed": []
},
{
"version": "0.118.0",
"date": "2026-11-08",
@@ -12,7 +12,16 @@ import type { ClipRange } from "../../lib/clipRange";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return { ...actual, useTranslation: () => ({ t: (k: string) => k }) };
return {
...actual,
// A size option reads as its own words with its unit ("480 px"), the length with the length it
// gives; every other text is its key.
useTranslation: () => ({
t: (k: string, o?: { n?: string; length?: string }) =>
k === "clip.gif.side" ? `${o?.n} px` : k === "clip.length" ? `${k} ${o?.length}` : k,
i18n: { language: "en" },
}),
};
});
const enqueueClip = vi.fn<(body: ClipRequest) => Promise<unknown>>();
@@ -271,3 +280,106 @@ describe("ClipPanel", () => {
expect(screen.getByTestId("crop").textContent).toBe("none");
});
});
describe("ClipPanel GIF", () => {
it("makes a GIF at the size picked, and says a GIF is being made", async () => {
const onClose = vi.fn();
render(<Harness onClose={onClose} start={{ start: 100, end: 108 }} />);
expect(screen.queryByRole("group", { name: "clip.gif.size" })).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "clip.formatGif" }));
fireEvent.click(screen.getByRole("button", { name: "320 px" }));
expect(enqueueClip).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: "clip.gif.make" }));
await waitFor(() => expect(onClose).toHaveBeenCalled());
expect(enqueueClip.mock.calls[0]![0]).toMatchObject({
start_s: 100,
end_s: 108,
gif_side: 320,
});
expect(notify).toHaveBeenCalledWith(
expect.objectContaining({ level: "success", message: msg("clip.gif.queued") }),
);
});
it("starts at the default size, and a video again sends no GIF", async () => {
render(<Harness start={{ start: 100, end: 108 }} />);
fireEvent.click(screen.getByRole("button", { name: "clip.formatGif" }));
expect(screen.getByRole("button", { name: "480 px" }).getAttribute("aria-pressed")).toBe(
"true",
);
fireEvent.click(screen.getByRole("button", { name: "clip.formatVideo" }));
fireEvent.click(screen.getByRole("button", { name: "clip.cut" }));
await waitFor(() => expect(enqueueClip).toHaveBeenCalled());
expect(enqueueClip.mock.calls[0]![0]).not.toHaveProperty("gif_side");
});
it("a range longer than a GIF may be leaves Make unavailable, with its reason", () => {
render(<Harness start={{ start: 100, end: 131 }} />);
fireEvent.click(screen.getByRole("button", { name: "clip.formatGif" }));
const make = screen.getByRole("button", { name: "clip.gif.make" });
expect(make.getAttribute("aria-disabled")).toBe("true");
const note = document.getElementById(make.getAttribute("aria-describedby")!)!;
expect(note.textContent).toBe("clip.gif.tooLong");
fireEvent.click(make);
expect(enqueueClip).not.toHaveBeenCalled();
});
it("says a refused GIF length so it never reads as one the cap allows", () => {
render(<Harness start={{ start: 100, end: 130.03 }} />);
// A video of it is 0:30.0 long, as it always read…
expect(screen.getByText(/^clip\.length /).textContent).toBe("clip.length 0:30.0");
fireEvent.click(screen.getByRole("button", { name: "clip.formatGif" }));
// …and a GIF of it, refused, reads 0:30.1.
expect(screen.getByText(/^clip\.length /).textContent).toBe("clip.length 0:30.1");
const make = screen.getByRole("button", { name: "clip.gif.make" });
expect(make.getAttribute("aria-disabled")).toBe("true");
});
it("says a note already true when GIF is picked inside a live region that was there before", () => {
render(<Harness start={{ start: 100, end: 131 }} />);
const before = new Map(
[...document.querySelectorAll("[aria-live='polite']")].map((r) => [r, r.textContent]),
);
fireEvent.click(screen.getByRole("button", { name: "clip.formatGif" }));
// The reason and the estimate change inside regions that were there, empty, before: what
// changes inside a region is announced, a region that arrives with its text is not.
const note = screen.getByText("clip.gif.tooLong").closest("[aria-live='polite']")!;
const estimate = screen.getByText("clip.gif.estimate").closest("[aria-live='polite']")!;
for (const region of [note, estimate]) expect(before.get(region)).toBe("");
});
it("says what the expected size means: fine, no moving preview, or past the cap", () => {
render(<Harness start={{ start: 100, end: 110 }} />);
fireEvent.click(screen.getByRole("button", { name: "clip.formatGif" }));
const note = () =>
document.getElementById(
screen.getByRole("button", { name: "clip.gif.make" }).getAttribute("aria-describedby")!,
)!.textContent;
// 10 s at 480: about 4–10 MB.
expect(note()).toBe("clip.gif.hint");
// 20 s at 480: up to about 20 MB, past a link preview's 15.
fireEvent.change(screen.getByRole("textbox", { name: "clip.out" }), {
target: { value: "2:00" },
});
fireEvent.keyDown(screen.getByRole("textbox", { name: "clip.out" }), { key: "Enter" });
expect(range()).toBe("100-120");
expect(note()).toBe("clip.gif.noPreview");
// 20 s at 640: up to about 32 MB, past the server's 30.
fireEvent.click(screen.getByRole("button", { name: "640 px" }));
expect(note()).toBe("clip.gif.mayFail");
});
it("says one size, not a range of one, when both ends read the same", () => {
// 1 s at 480: 0.4–1.0 MB, both shown as 1.
render(<Harness start={{ start: 100, end: 101 }} />);
fireEvent.click(screen.getByRole("button", { name: "clip.formatGif" }));
expect(screen.getByText("clip.gif.estimateOne")).toBeTruthy();
expect(screen.queryByText("clip.gif.estimate")).toBeNull();
// 10 s: a range.
fireEvent.change(screen.getByRole("textbox", { name: "clip.out" }), {
target: { value: "1:50" },
});
fireEvent.keyDown(screen.getByRole("textbox", { name: "clip.out" }), { key: "Enter" });
expect(screen.getByText("clip.gif.estimate")).toBeTruthy();
});
});
+117 -10
View File
@@ -10,6 +10,18 @@ import { notifyActionError } from "../../lib/actionErrors";
import { showInTray } from "../../lib/downloadQueue";
import { log } from "../../lib/log";
import { formatTimecode } from "../../lib/timecode";
import { formatNumber } from "../../lib/format";
import {
atMs,
estimateGif,
gifEstimateText,
gifShownLength,
gifVerdict,
GIF_DEFAULT_SIDE,
GIF_MAX_S,
GIF_SIDES,
type GifSide,
} from "../../lib/gif";
import { handleBounds, markIn, markOut, zoomWindow, type ClipRange } from "../../lib/clipRange";
import { fitRatio, fracRatio, WHOLE, type Frac } from "../../lib/cropGeometry";
import { RangeTrack, RulerTicks, type RangeWindow } from "../../components/ui/RangeTrack";
@@ -38,6 +50,11 @@ import { useTimecodeField, type TimecodeDraft } from "../../components/ui/useTim
// (`ClipCropFrame`, which the player places over its picture) marks the part to keep, free or held
// to an output shape. The crop is the caller's state too, so the player's Escape can turn it off
// before it closes the panel.
//
// The clip is an mp4 unless the user picks GIF: then the size (the long side) and the size the GIF
// is expected to come out at show before it is made, with what that size means — over 15 MB a Teams
// link preview shows it still, over 30 MB the server refuses it. A range longer than a GIF may be
// leaves Cut unavailable, with the reason beside it.
/** The output shapes a crop frame can be held to (width / height of the clip), or a free frame. */
type ClipShape = "free" | "16:9" | "9:16" | "1:1";
@@ -110,10 +127,13 @@ export default function ClipPanel({
onPlayFrom,
onClose,
}: Props) {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const qc = useQueryClient();
const rootRef = useRef<HTMLFormElement>(null);
const [name, setName] = useState("");
const [format, setFormat] = useState<"video" | "gif">("video");
const [side, setSide] = useState<GifSide>(GIF_DEFAULT_SIDE);
const gifNoteId = useId();
const [busy, setBusy] = useState(false);
const [looping, setLooping] = useState(false);
// The strip's window while a handle drags (null at rest: it follows the selection).
@@ -123,6 +143,14 @@ export default function ClipPanel({
const view = frozen ?? zoomWindow(clip, duration);
const bounds = handleBounds(clip, duration);
const length = clip.end - clip.start;
const gif = format === "gif";
// The GIF's shape is the crop's when there is one: the long side is capped, so the shape decides
// how many pixels it holds.
const shape = crop ? (crop.rect.w * picture) / crop.rect.h : picture;
const estimate = estimateGif(length, side, shape);
const verdict = gif ? gifVerdict(clip.start, clip.end, estimate) : "ok";
const estimateText = gifEstimateText(estimate, i18n.language);
const blocked = verdict === "too_long";
// Opened from a button or a key, the panel takes the focus to its In handle: the button that
// opened it is gone (its row is this panel now), and the arrows then move the mark at once. Without
@@ -171,8 +199,8 @@ export default function ClipPanel({
const body: ClipRequest = {
source: src.source,
ref: src.ref,
start_s: Math.round(clip.start * 1000) / 1000,
end_s: Math.round(clip.end * 1000) / 1000,
start_s: atMs(clip.start),
end_s: atMs(clip.end),
...(src.audioTrack != null ? { audio_track: src.audioTrack } : {}),
...(crop
? {
@@ -185,26 +213,34 @@ export default function ClipPanel({
},
}
: {}),
...(gif ? { gif_side: side } : {}),
...(name.trim() ? { display_name: name.trim() } : {}),
};
const submit = async (e: SubmitEvent<HTMLFormElement>) => {
e.preventDefault();
if (busy) return;
if (busy || blocked) return;
setBusy(true);
// Before the try: a `?:` inside one makes the React Compiler skip the whole panel.
const queued = msg(gif ? "clip.gif.queued" : "clip.queued");
const failed = msg(gif ? "clip.gif.failed" : "clip.failed");
try {
const job = await api.enqueueClip(body);
invalidateDownloads(qc);
log.info("clip.queued", "clip queued", { source: src.source, seconds: Math.round(length) });
log.info("clip.queued", "clip queued", {
source: src.source,
seconds: Math.round(length),
format,
});
notify({
level: "success",
message: msg("clip.queued"),
message: queued,
action: { label: msg("clip.view"), onClick: () => showInTray(job.id) },
});
onClose();
} catch (err) {
// A refusal the server explains (too long, quota) is already in the error dialog; a lost
// connection or a 404 is not, and must not leave the panel looking as if nothing happened.
notifyActionError(err, msg("clip.failed"));
notifyActionError(err, failed);
setBusy(false);
}
};
@@ -313,7 +349,9 @@ export default function ClipPanel({
onHere={() => moveOut(current, true)}
/>
<span className="tabular-nums text-muted" aria-live="polite">
{t("clip.length", { length: formatTimecode(length) })}
{t("clip.length", {
length: formatTimecode(gif ? gifShownLength(clip.start, clip.end) : length),
})}
</span>
<Button
variant="overlay"
@@ -370,6 +408,66 @@ export default function ClipPanel({
)}
</div>
<div className="flex flex-wrap items-center gap-2">
<SegmentedToggle
ariaLabel={t("clip.format")}
value={format}
onChange={setFormat}
options={[
{ value: "video", label: t("clip.formatVideo") },
{ value: "gif", label: t("clip.formatGif") },
]}
/>
{gif && (
<>
<span className="ml-2 text-muted" aria-hidden="true">
{t("clip.gif.sizeLabel")}
</span>
<SegmentedToggle
ariaLabel={t("clip.gif.size")}
value={String(side)}
onChange={(v) => setSide(Number(v) as GifSide)}
options={GIF_SIDES.map((s) => ({
value: String(s),
label: t("clip.gif.side", { n: formatNumber(s, i18n.language) }),
}))}
/>
</>
)}
{/* The live regions stay mounted whatever the format (laid out as their content alone): a
live region announces what changes inside it, not what arrives together with it, so
a note already true when GIF is picked is said too. */}
<span className="contents" aria-live="polite">
{gif && (
<span className="tabular-nums text-muted">
{t(estimateText.single ? "clip.gif.estimateOne" : "clip.gif.estimate", estimateText)}
</span>
)}
</span>
</div>
<div className="contents" aria-live="polite">
{gif && (
<p
id={gifNoteId}
className={`text-xs ${
verdict === "too_long"
? "text-danger"
: verdict === "ok"
? "text-muted"
: "text-media-mark"
}`}
>
{verdict === "too_long"
? t("clip.gif.tooLong", { seconds: GIF_MAX_S })
: verdict === "may_fail"
? t("clip.gif.mayFail")
: verdict === "no_preview"
? t("clip.gif.noPreview")
: t("clip.gif.hint")}
</p>
)}
</div>
<div className="flex flex-wrap items-center gap-2">
<Input
size="sm"
@@ -383,8 +481,17 @@ export default function ClipPanel({
<Button variant="overlay" size="sm" onClick={onClose}>
{t("common.cancel")}
</Button>
<Button type="submit" variant="primary" size="sm" loading={busy} icon={<Scissors />}>
{t("clip.cut")}
<Button
type="submit"
variant="primary"
size="sm"
loading={busy}
icon={<Scissors />}
// Unavailable with its reason (the note above), and still focusable: not native disabled.
aria-disabled={blocked || undefined}
aria-describedby={gif ? gifNoteId : undefined}
>
{gif ? t("clip.gif.make") : t("clip.cut")}
</Button>
</div>
</form>
+7
View File
@@ -39,6 +39,13 @@ describe("the copy rule", () => {
expect(offenders((v) => v.includes("✓"))).toEqual([]);
});
it("keeps the stops of a message of two sentences, the last one too", () => {
// A sentence break: a stop, a space, then a capital ("kb. {{n}}" is an abbreviation, not one). A
// value ending in a space is the head of a sentence a link or a number completes on screen.
const twoSentences = (v: string) => /[.!?] \p{Lu}/u.test(v) && !v.endsWith(" ");
expect(offenders((v) => twoSentences(v) && !/[.!?…]["”)]?$/u.test(v))).toEqual([]);
});
it("spells no unit as a bare letter after a number ('90m', '24h', '{{count}}d', '10 s')", () => {
expect(offenders((v) => /(\d|\}\})\s?(m|h|d|s)(?![\p{L}\d/])/u.test(v))).toEqual([]);
});
+20 -1
View File
@@ -22,6 +22,23 @@
"shape": "Frame shape",
"shapeFree": "Free",
"whole": "Whole picture",
"format": "Format",
"formatVideo": "Video",
"formatGif": "GIF",
"gif": {
"size": "GIF size, the longer side in pixels",
"sizeLabel": "Size",
"side": "{{n}} px",
"estimate": "about {{lo}}–{{hi}} MB",
"estimateOne": "about {{lo}} MB",
"hint": "No sound, 12 frames a second",
"noPreview": "It may pass 15 MB, and then a Teams link preview shows only a still picture. The downloaded file plays everywhere.",
"mayFail": "It may pass 30 MB, and then it isn’t saved. A shorter range or a smaller size helps.",
"tooLong": "A GIF is at most {{seconds}} seconds long. Shorten the range.",
"make": "Make GIF",
"queued": "The GIF is being made — it will be in your downloads",
"failed": "Couldn’t make the GIF"
},
"errors": {
"bad_source": "This video can’t be clipped.",
"bad_range": "The clip’s start or end isn’t valid.",
@@ -31,6 +48,8 @@
"bad_crop": "The crop frame isn’t valid. Make it bigger, or go back to the whole picture.",
"not_found": "This item is no longer in the library.",
"media_missing": "The item’s file can’t be reached from here.",
"past_end": "The clip runs past the end of the video."
"past_end": "The clip runs past the end of the video.",
"bad_gif": "That GIF size isn’t available.",
"gif_too_long": "A GIF is at most {{seconds}} seconds long."
}
}
@@ -224,6 +224,10 @@
"empty_edit": "Choose a trim range or crop area first",
"source_not_ready": "The source download isn't ready to edit",
"not_media": "Only video and audio can be edited",
"bad_gif": "That GIF size isn’t available",
"gif_range": "A GIF is made from one range with a start and an end",
"gif_too_long": "A GIF is at most {{seconds}} seconds long",
"gif_not_video": "A GIF is made from a video",
"generic": "Couldn't create that edit"
},
"upload": {
@@ -312,6 +316,10 @@
"short": "Couldn't download",
"body": "Something went wrong with this download. The technical details below may help."
},
"gif_too_big": {
"short": "The GIF is too large",
"body": "This GIF came out larger than a GIF may be, so it wasn’t kept. Mark a shorter range or choose a smaller size, then make it again."
},
"quota_exceeded": {
"short": "Not enough storage",
"body": "This download doesn't fit in the storage you have left. Delete something from your library, then try again."
+20 -1
View File
@@ -24,12 +24,31 @@
"created_other": "{{count}} clips added to your downloads",
"failed": "Couldn’t create the clip",
"partlyCreated": "Only {{done}} of {{total}} clips were added — the rest are still selected, so creating again adds just those.",
"failedGif": "Couldn’t create the GIF",
"partlyCreatedGif": "Only {{done}} of {{total}} GIFs were added — the rest are still selected, so creating again adds just those.",
"splitHere": "Split here",
"output": {
"label": "Output",
"separate": "Separate files",
"join": "Join into one"
"join": "Join into one",
"joinNeedsTwo": "Keep at least two segments to join them",
"joinGif": "A GIF can’t be joined: each kept segment becomes a GIF of its own"
},
"format": {
"label": "Format",
"video": "Video",
"gif": "GIF",
"estimate": "about {{lo}}–{{hi}} MB in all",
"estimateOne": "about {{lo}} MB in all",
"reencode": "A GIF is always re-encoded: no sound, 12 frames a second",
"segTooLong": "Segment {{n}} is {{length}} long: a GIF is at most {{seconds}} seconds",
"segMayFail": "Segment {{n}} may pass 30 MB as a GIF, and then it isn’t saved",
"segNoPreview": "Segment {{n}} may pass 15 MB as a GIF, and then a Teams link preview shows only a still picture"
},
"createGif_one": "Create {{count}} GIF",
"createGif_other": "Create {{count}} GIFs",
"createdGif_one": "{{count}} GIF added to your downloads",
"createdGif_other": "{{count}} GIFs added to your downloads",
"segment": "Segment {{n}}",
"keep": "Keep",
"drop": "Drop",
+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",
+20 -1
View File
@@ -22,6 +22,23 @@
"shape": "Keret aránya",
"shapeFree": "Szabad",
"whole": "Teljes kép",
"format": "Formátum",
"formatVideo": "Videó",
"formatGif": "GIF",
"gif": {
"size": "A GIF mérete, a hosszabbik oldal pixelben",
"sizeLabel": "Méret",
"side": "{{n}} px",
"estimate": "kb. {{lo}}–{{hi}} MB",
"estimateOne": "kb. {{lo}} MB",
"hint": "Hang nélkül, 12 kép másodpercenként",
"noPreview": "15 MB fölé mehet, és akkor a Teams link-előnézetben állókép lesz. A letöltött fájl mindenhol mozog.",
"mayFail": "30 MB fölé mehet, és akkor nem készül el. Rövidebb rész vagy kisebb méret segít.",
"tooLong": "A GIF legfeljebb {{seconds}} másodperc lehet. Rövidítsd a kijelölést.",
"make": "GIF készítése",
"queued": "Készül a GIF — a letöltéseid közé kerül",
"failed": "Nem sikerült elkészíteni a GIF-et"
},
"errors": {
"bad_source": "Ebből a videóból nem lehet klipet vágni.",
"bad_range": "A klip eleje vagy vége érvénytelen.",
@@ -31,6 +48,8 @@
"bad_crop": "A kivágókeret érvénytelen. Nagyítsd meg, vagy térj vissza a teljes képre.",
"not_found": "Ez az elem már nincs a könyvtárban.",
"media_missing": "Az elem fájlja innen nem érhető el.",
"past_end": "A klip túlnyúlik a videó végén."
"past_end": "A klip túlnyúlik a videó végén.",
"bad_gif": "Ez a GIF-méret nem választható.",
"gif_too_long": "A GIF legfeljebb {{seconds}} másodperc lehet."
}
}
@@ -224,6 +224,10 @@
"empty_edit": "Előbb válassz vágási tartományt vagy körbevágási területet",
"source_not_ready": "A forrásletöltés még nem szerkeszthető",
"not_media": "Csak videó és hang szerkeszthető",
"bad_gif": "Ez a GIF-méret nem választható",
"gif_range": "GIF egyetlen szakaszból készül, aminek eleje és vége is van",
"gif_too_long": "A GIF legfeljebb {{seconds}} másodperc lehet",
"gif_not_video": "GIF csak videóból készülhet",
"generic": "Nem sikerült létrehozni a vágást"
},
"upload": {
@@ -312,6 +316,10 @@
"short": "Nem sikerült letölteni",
"body": "Valami hiba történt ennél a letöltésnél. Az alábbi technikai részletek segíthetnek."
},
"gif_too_big": {
"short": "Túl nagy a GIF",
"body": "Ez a GIF nagyobb lett, mint amekkora egy GIF lehet, ezért nem maradt meg. Jelölj ki rövidebb szakaszt, vagy válassz kisebb méretet, és készítsd el újra."
},
"quota_exceeded": {
"short": "Nincs elég tárhely",
"body": "Ez a letöltés nem fér bele a megmaradt tárhelyedbe. Törölj valamit a könyvtáradból, aztán próbáld újra."
+20 -1
View File
@@ -24,12 +24,31 @@
"created_other": "{{count}} klip a letöltésekhez adva",
"failed": "Nem sikerült létrehozni a klipet",
"partlyCreated": "{{total}} klipből csak {{done}} került a letöltésekhez — a többi kijelölve maradt, így az újbóli létrehozás csak azokat adja hozzá.",
"failedGif": "Nem sikerült létrehozni a GIF-et",
"partlyCreatedGif": "{{total}} GIF-ből csak {{done}} került a letöltésekhez — a többi kijelölve maradt, így az újbóli létrehozás csak azokat adja hozzá.",
"splitHere": "Vágás itt",
"output": {
"label": "Kimenet",
"separate": "Külön fájlok",
"join": "Egy fájlba fűzve"
"join": "Egy fájlba fűzve",
"joinNeedsTwo": "Az összefűzéshez legalább két szegmenst tarts meg",
"joinGif": "GIF-et nem lehet összefűzni: minden megtartott szegmensből külön GIF lesz"
},
"format": {
"label": "Formátum",
"video": "Videó",
"gif": "GIF",
"estimate": "összesen kb. {{lo}}–{{hi}} MB",
"estimateOne": "összesen kb. {{lo}} MB",
"reencode": "A GIF mindig újrakódol: hang nélkül, 12 kép másodpercenként",
"segTooLong": "{{n}}. szegmens: {{length}} hosszú, de a GIF legfeljebb {{seconds}} másodperc lehet",
"segMayFail": "{{n}}. szegmens: GIF-ként 30 MB fölé mehet, és akkor nem készül el",
"segNoPreview": "{{n}}. szegmens: GIF-ként 15 MB fölé mehet, és akkor a Teams link-előnézetben állókép lesz"
},
"createGif_one": "{{count}} GIF létrehozása",
"createGif_other": "{{count}} GIF létrehozása",
"createdGif_one": "{{count}} GIF a letöltésekhez adva",
"createdGif_other": "{{count}} GIF a letöltésekhez adva",
"segment": "{{n}}. szegmens",
"keep": "Megtart",
"drop": "Eldob",
+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,
+5
View File
@@ -62,6 +62,8 @@ export interface ClipRequest {
audio_track?: number;
/** A crop frame: fractions of the box it was drawn in, and that box's shape (downloads/clip). */
crop?: { x: number; y: number; w: number; h: number; aspect: number };
/** Make it a GIF, this long side in pixels (`lib/gif` GIF_SIDES); left out, an mp4. */
gif_side?: number;
display_name?: string;
}
@@ -74,6 +76,9 @@ export interface EditSpec {
// scales them to the file's own pixels (a video's stored and shown sizes can differ).
crop?: { x: number; y: number; w: number; h: number; frame_w?: number; frame_h?: number };
accurate?: boolean;
/** Make it a GIF (a single trim with an end, `lib/gif` GIF_MAX_S long at most): a GIF is always
* re-encoded, so `accurate` does not apply. */
gif?: { side: number };
}
export interface StoryboardMeta {
+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;
@@ -55,6 +55,15 @@ describe.each(["en", "hu"])("localizeDetail (%s)", (lng) => {
expect(upload).not.toBe(i18n.t("downloads.errors.upload.tooLarge", { size: "" }));
});
it("says a clip's cap in minutes and a GIF's in seconds, from a clip or an edit", async () => {
expect(await say({ code: "clip", reason: "too_long", limit_s: 600 })).toContain("10");
for (const code of ["clip", "edit"]) {
const said = await say({ code, reason: "gif_too_long", limit_s: 30 });
expect(said).toContain("30");
expect(said).not.toContain("{{");
}
});
it("says a shared-sentence code's own fallback for a reason it does not know", async () => {
expect(await say({ code: "quota", reason: "nope" })).toBe(
i18n.t("downloads.errors.quota.generic"),
+9 -4
View File
@@ -30,13 +30,18 @@ const byReason = new Map(
const fixed = new Map<string, string>(Object.entries(REFUSAL_FIXED));
// The numbers a refusal carries, in the reader's number format: a download cap in GB, a clip cap in
// minutes, an upload cap in bytes, a password's minimum length; a generic `refused` interpolates
// whatever values its sentence names (`{{min}}`, `{{name}}`, …).
// minutes (a GIF's in seconds), an upload cap in bytes, a password's minimum length; a generic
// `refused` interpolates whatever values its sentence names (`{{min}}`, `{{name}}`, …).
function valuesOf(o: NonNullable<Detail>): Record<string, unknown> {
const lang = i18n.language;
if (o.code === "refused") return { ...o };
if (o.code === "clip")
return { minutes: o.limit_s == null ? undefined : formatDecimal(o.limit_s / 60, lang, 1, 0) };
if (o.code === "clip" || o.code === "edit")
return o.limit_s == null
? {}
: {
minutes: formatDecimal(o.limit_s / 60, lang, 1, 0),
seconds: formatDecimal(o.limit_s, lang, 0, 0),
};
if (o.code === "quota")
return o.reason === "max_bytes"
? { size: formatDecimal((o.limit || 0) / 1_073_741_824, lang, 1, 0) }
+52
View File
@@ -0,0 +1,52 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
// The GIF's sizes and limits, held in two languages: the panels offer the sizes the server takes and
// stop a range it would refuse. Neither side can see the other's copy, so the gate reads both (as
// `clipLimits.contract.test.ts` does for a clip's). Read as TEXT, so a renamed constant fails here
// instead of comparing two undefineds.
const src = (u: string) => readFileSync(new URL(u, import.meta.url), "utf-8");
const backend = src("../../../backend/app/downloads/gif.py");
const frontend = src("./gif.ts");
// A product of integers ("15 * 1024 * 1024"), as both files write a byte cap.
const product = (expr: string) =>
expr
.split("*")
.map((n) => Number(n.trim()))
.reduce((a, b) => a * b, 1);
function pyConst(name: string): string {
const m = backend.match(new RegExp(`^${name}\\s*=\\s*(.+)$`, "m"));
expect(m, `backend/app/downloads/gif.py no longer defines ${name}`).toBeTruthy();
return m![1]!.trim();
}
function tsConst(name: string): string {
const m = frontend.match(
new RegExp(`^export const ${name}(?:: \\w+)? = (.+?)(?: as const)?;$`, "m"),
);
expect(m, `lib/gif.ts no longer defines ${name}`).toBeTruthy();
return m![1]!.trim();
}
const list = (s: string) =>
s
.replace(/[[\]()]/g, "")
.split(",")
.map((n) => Number(n.trim()));
describe("GIF limits", () => {
it("offers the sizes the server takes", () => {
expect(list(tsConst("GIF_SIDES"))).toEqual(list(pyConst("SIDES")));
expect(Number(tsConst("GIF_DEFAULT_SIDE"))).toBe(Number(pyConst("DEFAULT_SIDE")));
});
it.each([
["GIF_MAX_S", "MAX_S"],
["GIF_PREVIEW_MAX_BYTES", "PREVIEW_MAX_BYTES"],
["GIF_MAX_BYTES", "MAX_BYTES"],
])("%s is the server's %s", (ts, py) => {
expect(product(tsConst(ts))).toBe(product(pyConst(py)));
});
});
+93
View File
@@ -0,0 +1,93 @@
import { describe, expect, it } from "vitest";
import {
estimateGif,
gifEstimateText,
GIF_MAX_BYTES,
GIF_PREVIEW_MAX_BYTES,
gifShownLength,
gifVerdict,
} from "./gif";
import { formatTimecode } from "./timecode";
const MB = 1024 * 1024;
describe("estimateGif", () => {
it("gives the measured range for a 16:9 picture", () => {
const e = estimateGif(30, 480, 16 / 9);
expect(e.lo / MB).toBeCloseTo(12, 5);
expect(e.hi / MB).toBeCloseTo(30, 5);
});
it("grows with the length and the size", () => {
expect(estimateGif(20, 480, 16 / 9).hi).toBeGreaterThan(estimateGif(10, 480, 16 / 9).hi);
expect(estimateGif(10, 640, 16 / 9).hi).toBeGreaterThan(estimateGif(10, 480, 16 / 9).hi);
});
it("counts the pixels of the shape: a square holds more than 16:9, a tall one as many", () => {
const wide = estimateGif(10, 480, 16 / 9).hi;
expect(estimateGif(10, 480, 1).hi).toBeCloseTo(wide * (16 / 9), 3);
expect(estimateGif(10, 480, 9 / 16).hi).toBeCloseTo(wide, 3);
});
it("falls back to 16:9 for a shape it cannot read", () => {
expect(estimateGif(10, 480, 0).hi).toBe(estimateGif(10, 480, 16 / 9).hi);
expect(estimateGif(10, 480, Number.NaN).hi).toBe(estimateGif(10, 480, 16 / 9).hi);
});
});
describe("gifEstimateText", () => {
it("says whether both ends read the same, so a panel says one size", () => {
expect(gifEstimateText(estimateGif(1, 480, 16 / 9), "en")).toEqual({
lo: "1",
hi: "1",
single: true,
});
expect(gifEstimateText(estimateGif(10, 480, 16 / 9), "en")).toEqual({
lo: "4",
hi: "10",
single: false,
});
});
});
describe("gifVerdict", () => {
it("refuses past the length cap, whatever the size", () => {
expect(gifVerdict(0, 30.2, { hi: 1 })).toBe("too_long");
expect(gifVerdict(0, 30, { hi: 1 })).toBe("ok");
});
it("judges the length the server judges: whole milliseconds between the ends as sent", () => {
// A float difference a hair over 30 s is exactly 30 s once the ends are sent.
expect(60.529 - 30.529).toBeGreaterThan(30);
expect(gifVerdict(30.529, 60.529, { hi: 1 })).toBe("ok");
expect(gifVerdict(0, 30.001, { hi: 1 })).toBe("too_long");
// Each end is sent rounded on its own: 10.0004–40.0008 goes as 10–40.001, 30001 ms, refused…
expect(gifVerdict(10.0004, 40.0008, { hi: 1 })).toBe("too_long");
// …and 10.0006–40.0012 as 10.001–40.001, exactly 30 s, made.
expect(gifVerdict(10.0006, 40.0012, { hi: 1 })).toBe("ok");
});
it("warns past the server's cap, then past the link preview's", () => {
expect(gifVerdict(0, 10, { hi: GIF_MAX_BYTES + 0.6 * MB })).toBe("may_fail");
expect(gifVerdict(0, 10, { hi: GIF_MAX_BYTES })).toBe("no_preview");
expect(gifVerdict(0, 10, { hi: GIF_PREVIEW_MAX_BYTES + 0.6 * MB })).toBe("no_preview");
expect(gifVerdict(0, 10, { hi: GIF_PREVIEW_MAX_BYTES })).toBe("ok");
});
it("judges the size the reader is shown: a high end shown as 30 MB is not past 30 MB", () => {
// 30 s at 480 lands a hair over 30 MiB, and reads "about 12–30 MB".
const hi = GIF_MAX_BYTES + 0.4 * MB;
expect(gifEstimateText({ lo: 12 * MB, hi }, "en").hi).toBe("30");
expect(gifVerdict(0, 30, { hi })).toBe("no_preview");
expect(gifVerdict(0, 10, { hi: GIF_PREVIEW_MAX_BYTES + 0.4 * MB })).toBe("ok");
});
});
describe("gifShownLength", () => {
it("never shows a refused range as one the cap allows", () => {
expect(formatTimecode(gifShownLength(0, 30.03))).toBe("0:30.1");
expect(formatTimecode(gifShownLength(0, 30.001))).toBe("0:30.1");
expect(gifShownLength(30.529, 60.529)).toBe(30);
expect(gifShownLength(0, 12.34)).toBe(12.34);
});
});
+79
View File
@@ -0,0 +1,79 @@
// A GIF cut from a clip or an edit: the sizes it may be made at and the limits it is held to, the
// server's (backend downloads/gif.py; `gif.contract.test.ts` holds the two copies together), and the
// size it is expected to come out at, which the panels show before anything is made.
import { formatNumber } from "./format";
/** The longest side of the GIF, in pixels: the choices the server takes. */
export const GIF_SIDES = [320, 480, 640] as const;
export type GifSide = (typeof GIF_SIDES)[number];
export const GIF_DEFAULT_SIDE: GifSide = 480;
/** The longest GIF the server makes, in seconds. */
export const GIF_MAX_S = 30;
/** Over this a chat's link preview (Teams) shows no moving picture: the share page sends a still. */
export const GIF_PREVIEW_MAX_BYTES = 15 * 1024 * 1024;
/** Over this the server refuses the finished GIF. */
export const GIF_MAX_BYTES = 30 * 1024 * 1024;
// Bytes a second, measured on real films at 12 frames a second, at each size of a 16:9 picture: the
// low and the high end of what four sources gave. The same settings spread 2.5× by what is in the
// picture, so only a range can be said before the GIF is made.
const MB = 1024 * 1024;
const RATE: Record<GifSide, readonly [number, number]> = {
320: [0.23 * MB, 0.47 * MB],
480: [0.4 * MB, 1.0 * MB],
640: [0.7 * MB, 1.6 * MB],
};
/** The size a GIF is expected to come out at, low and high, in bytes. `aspect` is the picture's
* width / height after any crop: the long side is capped, so a square or tall picture holds more
* or fewer pixels than the 16:9 one the rates were measured on. */
export function estimateGif(
seconds: number,
side: GifSide,
aspect: number,
): { lo: number; hi: number } {
const shape = aspect > 0 && Number.isFinite(aspect) ? aspect : 16 / 9;
const area = shape >= 1 ? 1 / shape : shape;
const scale = area / (9 / 16);
const [lo, hi] = RATE[side];
const s = Math.max(0, seconds);
return { lo: lo * s * scale, hi: hi * s * scale };
}
/** An estimate's ends as whole megabytes in the reader's number format (at least 1, as a GIF of a
* second or two still takes one in the reader's eyes). `single` when both ends read the same: the
* panels then say one number ("about 1 MB"), not a range of one ("about 1–1 MB"). */
export function gifEstimateText(est: { lo: number; hi: number }, lang: string) {
const lo = Math.max(1, Math.round(est.lo / MB));
const hi = Math.max(lo, Math.round(est.hi / MB));
return { lo: formatNumber(lo, lang), hi: formatNumber(hi, lang), single: lo === hi };
}
/** What the panels say about a GIF before it is made: too long (refused, it does not start), it may
* pass the cap (refused when it does), it may be too big to move in a link preview, or fine. */
export type GifVerdict = "too_long" | "may_fail" | "no_preview" | "ok";
/** A time as the request sends it: to the millisecond. */
export const atMs = (s: number) => Math.round(s * 1000) / 1000;
/** A range's length in whole milliseconds, between its ends as the request sends them: the length
* the server judges (gif.too_long), so a range the panel allows is never refused for its length,
* and none it refuses would have been made. */
const spanMs = (start: number, end: number) => Math.round(end * 1000) - Math.round(start * 1000);
/** A range's length to show beside a verdict: over the cap it is rounded up to the tenth, so a
* refused range never reads as one the cap allows ("0:30.0" for 30.03 s). */
export function gifShownLength(start: number, end: number): number {
const ms = spanMs(start, end);
return ms > GIF_MAX_S * 1000 ? Math.ceil(ms / 100) / 10 : ms / 1000;
}
export function gifVerdict(start: number, end: number, est: { hi: number }): GifVerdict {
if (spanMs(start, end) > GIF_MAX_S * 1000) return "too_long";
// Judged on the high end as shown (whole megabytes): "about 12–30 MB" beside "it may pass 30 MB"
// would contradict itself, and the estimate is no finer than that anyway.
const hi = Math.round(est.hi / MB) * MB;
if (hi > GIF_MAX_BYTES) return "may_fail";
if (hi > GIF_PREVIEW_MAX_BYTES) return "no_preview";
return "ok";
}
@@ -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}`,
+5 -3
View File
@@ -423,12 +423,14 @@ describe("module URL keys", () => {
});
const named = new Set(["MODE_URL", "PLEX_SUB_URL"]);
const unnamed = (text: string) => handed(text).filter((arg) => !named.has(arg));
// Each source read once: read three times, the scan ran past the test timeout under a loaded gate.
const files = sources(SRC).filter(
(f) => !relative(SRC, f).split("\\").join("/").startsWith("lib/history"),
);
expect(files.flatMap((f) => unnamed(readFileSync(f, "utf8")))).toEqual([]);
const texts = files.map((f) => readFileSync(f, "utf8"));
expect(texts.flatMap(unnamed)).toEqual([]);
// The call sites are seen (a scan of none would pass)…
expect(new Set(files.flatMap((f) => handed(readFileSync(f, "utf8"))))).toEqual(named);
expect(new Set(texts.flatMap(handed))).toEqual(named);
// …and the check tells an inline definition from a named one.
expect(unnamed('useHistorySubview(sub, { key: "zz", encode, decode })')).toEqual([
'{ key: "zz", encode, decode }',
@@ -442,7 +444,7 @@ describe("module URL keys", () => {
]);
// Under another name it is a call no scan sees.
const aliased = (text: string) => /\buseHistorySubview\s+as\b/.test(text);
expect(files.filter((f) => aliased(readFileSync(f, "utf8")))).toEqual([]);
expect(files.filter((_f, i) => aliased(texts[i]!))).toEqual([]);
expect(aliased('import { useHistorySubview as useSub } from "./history";')).toBe(true);
});
@@ -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"
/>
),
},
@@ -0,0 +1,249 @@
// @vitest-environment jsdom
//
// The editor's GIF: each kept segment becomes a GIF of its own at the size picked (never a joined
// cut-list, and never with the precise/fast choice, which a GIF does not have), a whole short video
// is a GIF too, and a segment longer than a GIF may be leaves Create unavailable with its reason.
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor, cleanup, within } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
vi.mock("react-i18next", async (importOriginal) => ({
...(await importOriginal<typeof import("react-i18next")>()),
// A size option reads as its own words with its unit ("480 px"), a segment's note with the
// segment it names (and a too-long one with the length it gives), the kept summary with its
// length; every other text is its key.
useTranslation: () => ({
t: (key: string, o?: { n?: string; length?: string; dur?: string }) =>
key === "clip.gif.side"
? `${o?.n} px`
: key === "editor.format.segTooLong"
? `${key} #${o?.n} ${o?.length}`
: key === "editor.format.segMayFail" || key === "editor.format.segNoPreview"
? `${key} #${o?.n}`
: key === "editor.keptSummary"
? `${key} ${o?.dur}`
: key,
i18n: { language: "en" },
}),
}));
const { notify } = vi.hoisted(() => ({ notify: vi.fn() }));
vi.mock("../../lib/notifications", async (importOriginal) => ({
...(await importOriginal<typeof import("../../lib/notifications")>()),
notify,
}));
import VideoEditor from "./VideoEditor";
import { api, HttpError, type DownloadJob } from "../../lib/api";
import { markReported } from "../../lib/api/core";
import { msg } from "../../lib/notifications";
// A refusal as `req` delivers it: already shown in its error dialog, and marked so.
const markedReported = (e: HttpError) => {
markReported(e);
return e;
};
beforeAll(() => {
class Stub {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
notify.mockReset();
});
const jobOf = (duration: number, kind = "video") =>
({
id: 7,
display_name: "Talk",
asset: { duration_s: duration, width: 1920, height: 1080, title: "Talk", media_kind: kind },
}) as unknown as DownloadJob;
function open(duration: number, kind = "video") {
vi.spyOn(api, "downloadStoryboard").mockResolvedValue({ available: false } as never);
const enqueue = vi.spyOn(api, "enqueueEdit").mockResolvedValue({} as never);
const onClose = vi.fn();
render(
<QueryClientProvider client={new QueryClient()}>
<VideoEditor job={jobOf(duration, kind)} onClose={onClose} />
</QueryClientProvider>,
);
const video = document.querySelector("video")!;
const playhead = (s: number) => {
Object.defineProperty(video, "currentTime", { value: s, configurable: true, writable: true });
fireEvent.timeUpdate(video);
};
return { enqueue, onClose, playhead };
}
describe("VideoEditor — GIF", () => {
it("makes a whole short video a GIF at the size picked, with no precise/fast choice", async () => {
const { enqueue, onClose } = open(20);
fireEvent.click(screen.getByRole("button", { name: "editor.format.gif" }));
fireEvent.click(screen.getByRole("button", { name: "640 px" }));
expect(screen.queryByText("editor.accurate.label")).toBeNull();
expect(screen.getByText("editor.format.reencode")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "editor.createGif" }));
await waitFor(() => expect(onClose).toHaveBeenCalled());
expect(enqueue).toHaveBeenCalledTimes(1);
expect(enqueue.mock.calls[0]![0].edit_spec).toEqual({
trim: { start_s: 0, end_s: 20 },
gif: { side: 640 },
});
expect(notify).toHaveBeenCalledWith(
expect.objectContaining({ message: msg("editor.createdGif", { count: 1 }) }),
);
});
it("cuts each kept segment into a GIF of its own, even with Join picked before", async () => {
const { enqueue, playhead } = open(20);
playhead(8);
fireEvent.click(screen.getByText("editor.splitHere"));
fireEvent.click(screen.getByRole("button", { name: "editor.output.join" }));
fireEvent.click(screen.getByRole("button", { name: "editor.format.gif" }));
const join = screen.getByRole("button", { name: "editor.output.join" });
expect(join.getAttribute("aria-disabled")).toBe("true");
expect(join.getAttribute("aria-description")).toBe("editor.output.joinGif");
expect(join.getAttribute("aria-pressed")).toBe("false");
fireEvent.click(screen.getByRole("button", { name: "editor.createGif" }));
await waitFor(() => expect(enqueue).toHaveBeenCalledTimes(2));
expect(enqueue.mock.calls.map((c) => c[0].edit_spec)).toEqual([
{ trim: { start_s: 0, end_s: 8 }, gif: { side: 480 } },
{ trim: { start_s: 8, end_s: 20 }, gif: { side: 480 } },
]);
});
it("a segment longer than a GIF may be leaves Create unavailable, with its reason", () => {
const { enqueue } = open(100);
fireEvent.click(screen.getByRole("button", { name: "editor.format.gif" }));
const create = screen.getByRole("button", { name: "editor.createGif" });
expect(create.getAttribute("aria-disabled")).toBe("true");
const notes = document.getElementById(create.getAttribute("aria-describedby")!)!;
expect(notes.textContent).toBe("editor.format.segTooLong #1 1:40.0");
fireEvent.click(create);
expect(enqueue).not.toHaveBeenCalled();
// A video of the same range has no such limit.
fireEvent.click(screen.getByRole("button", { name: "editor.format.video" }));
expect(
screen.getByRole("button", { name: "editor.create" }).getAttribute("aria-disabled"),
).toBe(null);
});
it("says a refused length so it never reads as one the cap allows", () => {
// A phone video of 30.03 s: refused, and said as 0:30.1, not 0:30.0.
open(30.03);
fireEvent.click(screen.getByRole("button", { name: "editor.format.gif" }));
const create = screen.getByRole("button", { name: "editor.createGif" });
expect(create.getAttribute("aria-disabled")).toBe("true");
const notes = document.getElementById(create.getAttribute("aria-describedby")!)!;
expect(notes.textContent).toBe("editor.format.segTooLong #1 0:30.1");
// The segment's own length and the kept summary say it the same way, never 0:30.0.
expect(screen.getByText("0:30.1")).toBeTruthy();
expect(screen.getByText("editor.keptSummary 0:30.1")).toBeTruthy();
expect(screen.queryByText("0:30.0")).toBeNull();
});
it("names each segment by its place, the worst note first, a dropped one left out", () => {
const { playhead } = open(80);
playhead(10);
fireEvent.click(screen.getByText("editor.splitHere"));
playhead(30);
fireEvent.click(screen.getByText("editor.splitHere"));
// Segment 1 (0–10) dropped; 2 (10–30) and 3 (30–80) kept.
const track = screen.getByRole("group", { name: "editor.timeline" });
fireEvent.pointerDown(within(track).getAllByRole("button", { pressed: true })[0]!);
fireEvent.click(screen.getByRole("button", { name: "editor.format.gif" }));
fireEvent.click(screen.getByRole("button", { name: "640 px" }));
const notes = [...document.querySelectorAll("li")].map((l) => l.textContent);
// 50 s is too long, 20 s at 640 may pass 30 MB; the dropped segment says nothing.
expect(notes).toEqual(["editor.format.segTooLong #3 0:50.0", "editor.format.segMayFail #2"]);
});
it("says a note already true when GIF is picked inside a live region that was there before", () => {
open(100);
const region = document.querySelector("div[aria-live='polite']")!;
expect(region.textContent).toBe("");
fireEvent.click(screen.getByRole("button", { name: "editor.format.gif" }));
expect(document.querySelector("div[aria-live='polite']")).toBe(region);
expect(region.textContent).toBe("editor.format.segTooLong #1 1:40.0");
});
it("says a GIF failure as one of GIFs, not of clips", async () => {
const { enqueue, playhead } = open(20);
enqueue
.mockReset()
.mockResolvedValueOnce({} as never)
.mockRejectedValueOnce(
markedReported(new HttpError(422, "errors.generic", { code: "quota" })),
)
.mockRejectedValueOnce(new Error("offline"));
playhead(8);
fireEvent.click(screen.getByText("editor.splitHere"));
fireEvent.click(screen.getByRole("button", { name: "editor.format.gif" }));
fireEvent.click(screen.getByRole("button", { name: "editor.createGif" }));
await waitFor(() =>
expect(notify).toHaveBeenCalledWith(
expect.objectContaining({ title: msg("editor.partlyCreatedGif", { done: 1, total: 2 }) }),
),
);
// The one left fails on its own: said as a GIF too.
fireEvent.click(screen.getByRole("button", { name: "editor.createGif" }));
await waitFor(() =>
expect(notify).toHaveBeenCalledWith(
expect.objectContaining({ title: msg("editor.failedGif") }),
),
);
});
it("offers no GIF for an audio file, which has no picture to make one of", () => {
open(20, "audio");
expect(screen.queryByRole("button", { name: "editor.format.gif" })).toBeNull();
expect(screen.getByRole("button", { name: "editor.create" })).toBeTruthy();
});
it("keeps its notes' live region in place, so the first note is read out", () => {
open(20);
fireEvent.click(screen.getByRole("button", { name: "editor.format.gif" }));
// 20 s at 320: about 5–9 MB, nothing to say — but the region is already there.
fireEvent.click(screen.getByRole("button", { name: "320 px" }));
const region = document.querySelector("div[aria-live='polite']")!;
expect(region).toBeTruthy();
expect(region.textContent).toBe("");
// 20 s at 640: up to about 32 MB, said inside the same region.
fireEvent.click(screen.getByRole("button", { name: "640 px" }));
expect(document.querySelector("div[aria-live='polite']")).toBe(region);
expect(region.textContent).toBe("editor.format.segMayFail #1");
});
it("says one size for a short GIF, and none when nothing is kept", () => {
open(2);
fireEvent.click(screen.getByRole("button", { name: "editor.format.gif" }));
fireEvent.click(screen.getByRole("button", { name: "320 px" }));
// 2 s at 320: 0.46–0.94 MB, both shown as 1.
expect(screen.getByText("editor.format.estimateOne")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "editor.kept" }));
expect(screen.queryByText("editor.format.estimateOne")).toBeNull();
expect(screen.queryByText("editor.format.estimate")).toBeNull();
});
it("Join needs two kept segments, and says so instead of going dead", async () => {
open(20);
const join = screen.getByRole("button", { name: "editor.output.join" });
expect(join.hasAttribute("disabled")).toBe(false);
expect(join.getAttribute("aria-disabled")).toBe("true");
expect(join.getAttribute("aria-description")).toBe("editor.output.joinNeedsTwo");
// The reason is seen on a key's focus too, not only under a mouse (a native title is not).
expect(join.hasAttribute("title")).toBe(false);
fireEvent.focus(join);
expect(await screen.findByText("editor.output.joinNeedsTwo")).toBeTruthy();
});
});
+178 -30
View File
@@ -10,7 +10,10 @@ import { msg, notify } from "../../lib/notifications";
import { api, type DownloadJob, type EditSpec } from "../../lib/api";
import { cropToSourcePx, type Frac } from "../../lib/cropGeometry";
import { CropFrame } from "../../components/ui/CropFrame";
import SegmentedToggle from "../../components/ui/SegmentedToggle";
import { Button } from "../../components/ui/Button";
import Tooltip from "../../components/ui/Tooltip";
import { unavailable } from "../../components/ui/unavailable";
import { failureReason, notifyActionError } from "../../lib/actionErrors";
import { markReported } from "../../lib/api/core";
import { IconButton } from "../../components/ui/IconButton";
@@ -22,6 +25,19 @@ import { SURFACE } from "../../components/ui/surfaces";
import { RangeTrack } from "../../components/ui/RangeTrack";
import { trackLeft } from "../../components/ui/trackPosition";
import { formatTimecode } from "../../lib/timecode";
import { formatNumber } from "../../lib/format";
import {
atMs,
estimateGif,
gifEstimateText,
gifShownLength,
gifVerdict,
GIF_DEFAULT_SIDE,
GIF_MAX_S,
GIF_SIDES,
type GifSide,
type GifVerdict,
} from "../../lib/gif";
type Seg = { id: number; start: number; end: number; keep: boolean };
@@ -40,8 +56,18 @@ async function enqueueClips(
}
}
// A GIF is cut per kept segment (the server makes one GIF of one range, never a joined cut-list), so
// what the editor says before Create is said per segment: the worst first — one too long stops
// Create — then one that may pass the server's cap, then one too big to move in a link preview.
const GIF_NOTE_ORDER: GifVerdict[] = ["too_long", "may_fail", "no_preview"];
const GIF_NOTE_KEY = {
too_long: "editor.format.segTooLong",
may_fail: "editor.format.segMayFail",
no_preview: "editor.format.segNoPreview",
} as const;
export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClose: () => void }) {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const qc = useQueryClient();
const videoRef = useRef<HTMLVideoElement>(null);
const trackRef = useRef<HTMLDivElement>(null);
@@ -72,6 +98,13 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
const [cropOn, setCropOn] = useState(false);
const [crop, setCrop] = useState<Frac>({ x: 0.1, y: 0.1, w: 0.8, h: 0.8 });
const [accurate, setAccurate] = useState(true);
const [format, setFormat] = useState<"video" | "gif">("video");
const [side, setSide] = useState<GifSide>(GIF_DEFAULT_SIDE);
// A GIF is made of the picture: an audio file has none (or only its cover), so it is offered for
// a video alone, as the server holds it.
const canGif = job.asset?.media_kind === "video";
const gif = canGif && format === "gif";
const gifNoteId = useId();
const [name, setName] = useState("");
const fileUrl = useMemo(() => api.fileUrl(job.id), [job.id]);
@@ -222,9 +255,35 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
kept.length === 1 &&
kept[0]!.start <= 0.01 &&
kept[0]!.end >= duration - 0.01;
const valid = kept.length >= 1 && keptDur >= MIN_SEG && (cropOn || !isFullSingle);
const willJoin = output === "join" && kept.length > 1;
const reencode = cropOn || accurate;
// A GIF of the whole video is no no-op: the format changes.
const valid = kept.length >= 1 && keptDur >= MIN_SEG && (cropOn || gif || !isFullSingle);
const willJoin = !gif && output === "join" && kept.length > 1;
const reencode = cropOn || accurate || gif;
// The GIF's shape is the crop's when there is one (the long side is capped, so the shape decides
// how many pixels it holds).
const gifShape = cropOn ? (crop.w * va) / crop.h : va;
const gifSegs = kept.map((s) => {
const est = estimateGif(s.end - s.start, side, gifShape);
return {
n: segments.indexOf(s) + 1,
length: gifShownLength(s.start, s.end),
est,
verdict: gifVerdict(s.start, s.end, est),
};
});
const gifTotal = gifSegs.reduce((a, g) => ({ lo: a.lo + g.est.lo, hi: a.hi + g.est.hi }), {
lo: 0,
hi: 0,
});
const gifTotalText = gifEstimateText(gifTotal, i18n.language);
const gifNotes = GIF_NOTE_ORDER.flatMap((v) => gifSegs.filter((g) => g.verdict === v));
const gifBlocked = gif && gifSegs.some((g) => g.verdict === "too_long");
const joinReason = gif
? t("editor.output.joinGif")
: kept.length < 2
? t("editor.output.joinNeedsTwo")
: undefined;
const cropPx = (): EditSpec["crop"] => {
const v = videoRef.current;
@@ -245,7 +304,7 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
const base = name.trim() || defaultName;
if (willJoin) {
const spec: EditSpec = {
segments: kept.map((s) => ({ start_s: +s.start.toFixed(3), end_s: +s.end.toFixed(3) })),
segments: kept.map((s) => ({ start_s: atMs(s.start), end_s: atMs(s.end) })),
accurate,
};
if (cp) spec.crop = cp;
@@ -267,10 +326,11 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
// cannot lower a conditional or a loop inside one yet, and skipped the whole editor for it.
const clips = kept.map((s) => {
const spec: EditSpec = {
trim: { start_s: +s.start.toFixed(3), end_s: +s.end.toFixed(3) },
trim: { start_s: atMs(s.start), end_s: atMs(s.end) },
};
if (cp) spec.crop = cp;
else spec.accurate = accurate;
if (gif) spec.gif = { side };
else if (!cp) spec.accurate = accurate;
const dn = suffix.get(s.id) ? `${base}${suffix.get(s.id)}` : name.trim() || undefined;
return { id: s.id, spec, dn };
});
@@ -290,7 +350,10 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
// what the dialog does not tell.
notify({
level: "error",
title: msg("editor.partlyCreated", { done: done.length, total: N }),
title: msg(gif ? "editor.partlyCreatedGif" : "editor.partlyCreated", {
done: done.length,
total: N,
}),
message: failureReason(e),
});
markReported(e);
@@ -302,10 +365,13 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
onSuccess: ({ files }) => {
// The whole library moves with it: an edit the user already made is done at once, and lists.
invalidateDownloads(qc);
notify({ level: "success", message: msg("editor.created", { count: files }) });
notify({
level: "success",
message: msg(gif ? "editor.createdGif" : "editor.created", { count: files }),
});
onClose();
},
onError: (e) => notifyActionError(e, msg("editor.failed")),
onError: (e) => notifyActionError(e, msg(gif ? "editor.failedGif" : "editor.failed")),
onSettled: () => {
creating.current = false;
},
@@ -533,7 +599,7 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
/>
</label>
<span className="text-xs text-muted tabular-nums w-16 text-right">
{tc(sel.end - sel.start)}
{tc(gif ? gifShownLength(sel.start, sel.end) : sel.end - sel.start)}
</span>
{(inField.invalid || outField.invalid) && (
<div className="basis-full">
@@ -546,24 +612,97 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
{/* output mode + summary */}
<div className="flex flex-wrap items-center gap-2 text-sm">
<span className="text-muted">{t("editor.output.label")}:</span>
{(["separate", "join"] as const).map((m) => (
<Button
key={m}
variant="outline"
size="sm"
pressed={output === m}
onClick={() => setOutput(m)}
disabled={m === "join" && kept.length < 2}
>
{t(`editor.output.${m}`)}
</Button>
))}
{(["separate", "join"] as const).map((m) => {
// Join is unavailable with its reason in its tooltip (a GIF is never joined; a join
// needs two kept segments), shown on focus too, and stays focusable so the reason
// reaches the keyboard.
const reason = m === "join" ? joinReason : undefined;
return (
<Tooltip key={m} hint={reason ?? ""} always={!!reason} stable>
<Button
variant="outline"
size="sm"
pressed={(willJoin ? "join" : "separate") === m}
onClick={() => setOutput(m)}
{...unavailable(!!reason, reason)}
>
{t(`editor.output.${m}`)}
</Button>
</Tooltip>
);
})}
<div className="flex-1" />
<span className="text-xs text-muted">
{t("editor.keptSummary", { n: kept.length, dur: tc(keptDur) })}
{t("editor.keptSummary", {
n: kept.length,
// One GIF: its length as the notes say it (a refused one never reads as allowed).
dur: tc(
gif && kept.length === 1 ? gifShownLength(kept[0]!.start, kept[0]!.end) : keptDur,
),
})}
</span>
</div>
{/* format: a video, or each kept segment a GIF */}
{canGif && (
<div className="flex flex-wrap items-center gap-2 text-sm">
<span className="text-muted">{t("editor.format.label")}:</span>
<SegmentedToggle
ariaLabel={t("editor.format.label")}
value={format}
onChange={setFormat}
options={[
{ value: "video", label: t("editor.format.video") },
{ value: "gif", label: t("editor.format.gif") },
]}
/>
{gif && (
<>
<span className="ml-2 text-muted" aria-hidden="true">
{t("clip.gif.sizeLabel")}
</span>
<SegmentedToggle
ariaLabel={t("clip.gif.size")}
value={String(side)}
onChange={(v) => setSide(Number(v) as GifSide)}
options={GIF_SIDES.map((s) => ({
value: String(s),
label: t("clip.gif.side", { n: formatNumber(s, i18n.language) }),
}))}
/>
</>
)}
{/* The live regions stay mounted whatever the format (laid out as their content
alone): a live region announces what changes inside it, not what arrives together
with it, so a note already true when GIF is picked is said too. */}
<span className="contents" aria-live="polite">
{gif && kept.length > 0 && (
<span className="text-xs text-muted tabular-nums">
{t(
gifTotalText.single ? "editor.format.estimateOne" : "editor.format.estimate",
gifTotalText,
)}
</span>
)}
</span>
</div>
)}
<div id={gifNoteId} className="contents" aria-live="polite">
{gif && gifNotes.length > 0 && (
<ul className="space-y-0.5 text-xs">
{gifNotes.map((g) => (
<li key={g.n} className={g.verdict === "too_long" ? "text-danger" : "text-warn"}>
{t(GIF_NOTE_KEY[g.verdict as keyof typeof GIF_NOTE_KEY], {
n: g.n,
length: tc(g.length),
seconds: GIF_MAX_S,
})}
</li>
))}
</ul>
)}
</div>
{/* crop + precision */}
<div className="grid sm:grid-cols-2 gap-3">
<Button
@@ -575,7 +714,11 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
>
{cropOn ? t("editor.cropOn") : t("editor.cropOff")}
</Button>
{!cropOn ? (
{gif ? (
<div className="flex items-center px-3 text-xs text-muted">
{t("editor.format.reencode")}
</div>
) : !cropOn ? (
<div className="flex gap-2">
{(["accurate", "fast"] as const).map((m) => {
const on = (m === "accurate") === accurate;
@@ -623,14 +766,19 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
size="sm"
onClick={() => create.mutate()}
disabled={!valid}
// A GIF segment too long: unavailable with its reason (the notes above), focusable.
aria-disabled={gifBlocked || undefined}
aria-describedby={gifBlocked ? gifNoteId : undefined}
loading={create.isPending}
icon={<Scissors className="w-4 h-4" />}
>
{willJoin
? t("editor.createJoin")
: kept.length > 1
? t("editor.createN", { count: kept.length })
: t("editor.create")}
{gif
? t("editor.createGif", { count: kept.length })
: willJoin
? t("editor.createJoin")
: kept.length > 1
? t("editor.createN", { count: kept.length })
: t("editor.create")}
</Button>
</div>
</ModalFooter>
+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" },
}),
};
});
+86
View File
@@ -161,6 +161,68 @@ describe("WatchPage media element", () => {
expect(dl!.className).toContain("state-filled");
});
it("shows a shared picture as itself, no player, with its kind, size and the chat tip", async () => {
window.history.replaceState({}, "", "/watch/tok-gif");
mockMeta({
needs_password: false,
media_kind: "file",
preview: "image",
title: "Evening clip",
container: "gif",
width: 480,
height: 270,
size_bytes: 6_500_000,
allow_download: true,
file_url: "/f.gif",
});
const page = render(<WatchPage />);
const img = await page.findByRole("img", { name: "Evening clip" });
expect(img.getAttribute("src")).toBe("/f.gif");
expect(page.container.querySelector("video")).toBeNull();
expect(page.getByText(/GIF · 480×270 · 6\.2 MB/)).toBeTruthy();
expect(page.getByRole("link", { name: "Download" }).getAttribute("href")).toBe("/f.gif");
expect(page.getByText(/drag the downloaded file in/)).toBeTruthy();
});
it("a picture that does not load says so, rather than looking like a file without one", async () => {
window.history.replaceState({}, "", "/watch/tok-gif-gone");
mockMeta({
needs_password: false,
media_kind: "file",
preview: "image",
title: "Gone",
container: "gif",
allow_download: true,
file_url: "/gone.gif",
});
const page = render(<WatchPage />);
fireEvent.error(await page.findByRole("img", { name: "Gone" }));
// The first miss may be a hiccup: nothing is said while the retry waits…
expect(page.queryByRole("status")).toBeNull();
// …and when the retry fails too, the page says so.
const retry = await page.findByRole("img", { name: "Gone" }, { timeout: 3000 });
expect(retry.getAttribute("src")).toBe("/gone.gif?r=1");
fireEvent.error(retry);
expect((await page.findByRole("status")).textContent).toBe("Couldn't load the picture.");
});
it("a view-only picture link shows the picture, without Download or the chat tip", async () => {
window.history.replaceState({}, "", "/watch/tok-gif-view");
mockMeta({
needs_password: false,
media_kind: "file",
preview: "image",
title: "Still",
container: "gif",
allow_download: false,
file_url: "/f.gif",
});
const page = render(<WatchPage />);
await page.findByRole("img", { name: "Still" });
expect(page.queryByRole("link", { name: "Download" })).toBeNull();
expect(page.queryByText(/drag the downloaded file in/)).toBeNull();
});
it("omits the Download for a view-only file link", async () => {
window.history.replaceState({}, "", "/watch/tok-file-noview");
mockMeta({
@@ -418,6 +480,30 @@ describe("WatchPage folder", () => {
);
});
it("opens a picture above the list, shown rather than played", async () => {
window.history.replaceState({}, "", "/watch/tok-folder");
mockMeta(
folder({
files: [
file(1),
file(4, {
title: "Wave.gif",
media_kind: "file",
preview: "image",
container: "gif",
duration_s: null,
}),
],
}),
);
const page = render(<WatchPage />);
fireEvent.click(await page.findByRole("button", { name: "Open: Wave.gif" }));
const img = await page.findByRole("img", { name: "Wave.gif" });
expect(img.getAttribute("src")).toBe("/api/public/watch/tok-folder/files/4/file");
expect(page.container.querySelector("video")).toBeNull();
expect(page.getByRole("heading", { level: 2 }).textContent).toBe("Wave.gif");
});
it("offers a document for download only, and Download only where the link allows it", async () => {
window.history.replaceState({}, "", "/watch/tok-folder");
mockMeta(folder());
+67 -3
View File
@@ -44,6 +44,10 @@ type FileMeta = {
duration_s?: number | null;
audio?: boolean;
media_kind?: "video" | "audio" | "file" | null;
/** "image": a picture (a GIF cut from a clip, an uploaded photo), which the server serves as itself. */
preview?: "image" | null;
width?: number | null;
height?: number | null;
size_bytes?: number | null;
container?: string | null;
allow_download?: boolean;
@@ -335,6 +339,8 @@ function FileView({
writeJSON(LS.watchRepeat, next);
};
if (meta.media_kind === "file" && meta.preview === "image")
return <SharedPicture meta={meta} nested={nested} />;
if (meta.media_kind === "file")
/* A generic uploaded file has no player — just an info card (name/type/size) and, if the
link allows it, a Download. The backend serves the file force-download regardless, so
@@ -465,6 +471,61 @@ function FileView({
);
}
/** A shared picture: the picture itself, no player (a GIF plays and repeats on its own, and nobody
* pauses one), its name and kind under it, and the Download the link allows. A GIF says how to get
* it moving in a chat: a link's preview is the chat's to draw, the dragged-in file moves everywhere. */
function SharedPicture({ meta, nested }: { meta: FileMeta; nested: boolean }) {
// In a folder the page's own heading is the folder's.
const Heading = nested ? "h2" : "h1";
const gif = meta.container?.toLowerCase() === "gif";
const dims = meta.width && meta.height ? `${meta.width}×${meta.height}` : null;
return (
<div>
<div className="rounded-xl overflow-hidden bg-black border border-border w-fit max-w-full mx-auto">
<RemoteImage
src={meta.file_url}
alt={meta.title || T.file}
width={meta.width ?? undefined}
height={meta.height ?? undefined}
retries={1}
// A picture link always has a picture: a missing one failed to load, and says so —
// the bare file glyph would read as a file with no preview. Only once the retry failed
// too: while it waits, the frame stays empty.
pending={<div aria-hidden className="w-48 h-32" />}
fallback={
<div role="status" className="flex flex-col items-center gap-2 px-12 py-10 text-muted">
<FileIcon className="w-12 h-12" aria-hidden />
<p className="text-sm">{T.pictureFailed}</p>
</div>
}
className={clsx(
"max-w-full h-auto w-auto mx-auto block",
nested ? "max-h-[60dvh]" : "max-h-[80dvh]",
)}
/>
</div>
<div className={`${SURFACE.panel} mt-3 flex flex-wrap items-start gap-x-3 gap-y-2 p-3`}>
<div className="min-w-0 flex-1 basis-60">
<Heading className="text-lg font-semibold leading-snug break-words">
{meta.title || T.file}
</Heading>
<div className="text-sm text-muted">
{[meta.container?.toUpperCase(), dims, formatBytes(meta.size_bytes, lang)]
.filter(Boolean)
.join(" · ")}
</div>
</div>
{meta.allow_download && (
<LinkButton href={meta.file_url} icon={<Download />} size="sm" variant="glass">
{T.download}
</LinkButton>
)}
</div>
{gif && meta.allow_download && <p className="mt-3 text-sm text-muted">{T.gifTip}</p>}
</div>
);
}
/** A file's picture in the folder list: its poster frame, else a glyph of its kind; a video or a
* song carries its length. */
function RowThumb({ file }: { file: FileMeta }) {
@@ -536,6 +597,9 @@ function FolderView({ folder, token }: { folder: FolderMeta; token: string }) {
</span>
</>
);
// A picture opens above the list as a video does (it is shown, not played); any other
// file only downloads.
const picture = f.media_kind === "file" && f.preview === "image";
return (
<li
key={f.id}
@@ -544,19 +608,19 @@ function FolderView({ folder, token }: { folder: FolderMeta; token: string }) {
f.id === playing && "bg-accent/10",
)}
>
{f.media_kind === "file" ? (
{f.media_kind === "file" && !picture ? (
<div className="flex min-w-0 flex-1 items-center gap-3 px-1.5 py-1.5">{body}</div>
) : (
<Pressable
layer
selectable
aria-current={f.id === playing ? "true" : undefined}
aria-label={`${T.play}: ${title}`}
aria-label={`${picture ? T.open : T.play}: ${title}`}
onClick={() => setPlaying(f.id)}
className="flex min-w-0 flex-1 items-center gap-3 rounded-lg px-1.5 py-1.5"
>
{body}
<Play aria-hidden className="size-4 shrink-0 text-muted" />
{!picture && <Play aria-hidden className="size-4 shrink-0 text-muted" />}
</Pressable>
)}
{folder.allow_download && (
+7
View File
@@ -31,6 +31,8 @@ const STR = {
open: "Open",
folderEmpty: "This folder is empty.",
play: "Play",
gifTip: "To share it in a chat, drag the downloaded file in: it plays everywhere.",
pictureFailed: "Couldn't load the picture.",
files: (n: number) => (n === 1 ? "1 file" : `${n} files`),
},
hu: {
@@ -61,6 +63,8 @@ const STR = {
open: "Megnyitás",
folderEmpty: "Ez a mappa üres.",
play: "Lejátszás",
gifTip: "Csetbe a letöltött fájlt húzd be: az mindenhol mozog.",
pictureFailed: "Nem sikerült betölteni a képet.",
files: (n: number) => `${n} fájl`,
},
de: {
@@ -91,6 +95,9 @@ const STR = {
open: "Öffnen",
folderEmpty: "Dieser Ordner ist leer.",
play: "Abspielen",
gifTip:
"Zum Teilen in einem Chat die heruntergeladene Datei hineinziehen: Sie läuft überall als Animation.",
pictureFailed: "Das Bild konnte nicht geladen werden.",
files: (n: number) => (n === 1 ? "1 Datei" : `${n} Dateien`),
},
} as const;