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:
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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}")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
):
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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."
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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) }
|
||||
|
||||
Reference in New Issue
Block a user