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:
@@ -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)
|
||||
Reference in New Issue
Block a user