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.
This commit is contained in:
2026-11-11 01:50:55 +01:00
parent fe38c07e7e
commit c221452a00
24 changed files with 885 additions and 58 deletions
+27 -1
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
@@ -103,9 +112,20 @@ def normalize(
start, end = round(start_s, 3), round(end_s, 3)
if end - start < MIN_CLIP_S:
raise ClipError("too_short")
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 end - start > gifmod.MAX_S:
# 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 end - start > MAX_CLIP_S:
raise ClipError("too_long", limit_s=MAX_CLIP_S)
spec: dict = {"source": source, "ref": ref, "start_s": start, "end_s": end}
if side is not None:
spec["gif"] = {"side": side}
if source == "plex" and audio_track is not None:
if not 0 <= audio_track <= _MAX_AUDIO_TRACK:
raise ClipError("bad_audio")
@@ -257,6 +277,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:
+6
View File
@@ -68,6 +68,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 +81,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,
+124
View File
@@ -0,0 +1,124 @@
"""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 _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 and the size, then a palette made from these frames and applied to them."""
head = ",".join([*([pre] if pre else []), f"fps={FPS}", _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")
+67 -15
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
@@ -874,7 +875,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 +887,19 @@ 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.
trim = edit_spec["trim"]
start = trim.get("start_s") or 0.0
cmd = gifmod.build_cmd(
src_path,
dest_staging,
start,
trim["end_s"] - start,
pre=editmod.crop_filter(edit_spec["crop"]) if crop else "",
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 +915,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 +934,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 +961,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 +1080,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."""
@@ -1086,27 +1122,39 @@ def _process_clip(job_id: int, asset_id: int) -> None:
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"
gif = spec.get("gif")
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 +1166,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 +1185,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.
+28 -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,18 @@ 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"
]
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 +260,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 trim["end_s"] - trim.get("start_s", 0.0) > gifmod.MAX_S:
raise EditError("gif_too_long", limit_s=gifmod.MAX_S)
def enqueue_edit(
db: Session,
user_id: int,
@@ -280,6 +297,8 @@ def enqueue_edit(
spec = editmod.normalize_edit_spec(edit_spec)
if not spec:
raise EditError("empty_edit")
if "gif" in (edit_spec or {}):
_check_gif_edit(spec)
quota.check_enqueue(db, user_id)
@@ -294,7 +313,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 +406,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,
+5 -4
View File
@@ -387,18 +387,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}")
+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
)
+117
View File
@@ -710,6 +710,123 @@ 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
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_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
):
+2
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():
+96
View File
@@ -0,0 +1,96 @@
"""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
import pytest
from app.config import settings
from app.downloads import edit as editmod
from app.downloads import pipeline, service
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_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"
)
+135
View File
@@ -0,0 +1,135 @@
"""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 _video(path: Path, size: str, seconds: int = 2) -> Path:
subprocess.run(
[
"ffmpeg", "-y", "-loglevel", "error",
"-f", "lavfi", "-i", f"testsrc2=size={size}:rate=25:duration={seconds}",
"-c:v", "libx264", "-pix_fmt", "yuv420p", str(path),
],
check=True,
) # fmt: skip
return path
@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)
+3 -1
View File
@@ -31,6 +31,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,9 @@
"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",
"generic": "Couldn't create that edit"
},
"upload": {
@@ -312,6 +315,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."
+3 -1
View File
@@ -31,6 +31,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,9 @@
"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",
"generic": "Nem sikerült létrehozni a vágást"
},
"upload": {
@@ -312,6 +315,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."
@@ -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) }