feat(downloads): every ready file is phone-safe — uploads and clips too
A share link is opened on whatever the friend holds. Measured on an iPhone and an iPad: AV1, Matroska, HEVC tagged hev1 and MP3 inside an .mp4 fail there; Chrome and Firefox add HEVC and 10-bit / 4:4:4 H.264. The old check judged the codec NAME only and ran on downloads only, so a 10-bit H.264 passed, an upload went out exactly as it came (an iPhone's HEVC .mov, a webm, an mkv) and a fast trim kept its source's codecs. One rule now, `downloads/playable.py`: video is H.264 8-bit 4:2:0 + AAC in .mp4, audio-only is .mp3 or AAC .m4a; `plan` says what must change, `build_cmd` re-encodes only the failing stream (or remuxes). The worker applies it to a download, an editor clip and — new — an upload: media that fails the rule is queued (asset pending, job queued) and the worker converts it, keeping only the converted file (owner's choice). Until then the original sits at rel_path, so Retry starts from it and a delete removes it; an upload that lost its last holder mid-conversion is reaped by the GC. Every libx264 run (edit, concat, conversion) now pins `-pix_fmt yuv420p` — without it a 10-bit source came out High 10. The download cache signature moves to v5 so a cached 10-bit asset re-derives. The upload toast says when the file is still converting (HU/EN). Tests: the plan for every measured class; real ffmpeg turning a 10-bit Matroska + Opus and an mp3-in-mp4 into files the rule accepts (red without the pixel-format pin); the queued upload's delete, cancel-then-GC, failed conversion and real worker conversion (the cleanup tests red without their rules); the toast (red without the branch). Live on localdev: 9 fixture uploads converted, every served file probed H.264 High yuv420p + AAC, or mp3 / m4a.
This commit is contained in:
@@ -8,6 +8,7 @@ import pytest
|
||||
|
||||
from app.config import settings
|
||||
from app.downloads import edit as editmod
|
||||
from app.downloads import playable
|
||||
from app.downloads import runner
|
||||
|
||||
|
||||
@@ -32,11 +33,12 @@ class TestEncodeThreadCap:
|
||||
assert editmod.encode_thread_cap() == 1 # max(1, 2 - 1)
|
||||
|
||||
|
||||
class TestBrowserSafeThreads:
|
||||
_REENCODE_VIDEO = playable.Plan("mp4", True, True, False, True)
|
||||
|
||||
|
||||
class TestPhoneSafeThreads:
|
||||
def test_threads_flag_added_when_capped(self):
|
||||
cmd = editmod.build_browser_safe_cmd(
|
||||
"/in.mkv", "/out.mp4", reencode_video=True, reencode_audio=False, threads=2
|
||||
)
|
||||
cmd = playable.build_cmd("/in.mkv", "/out.mp4", _REENCODE_VIDEO, threads=2)
|
||||
assert "-threads" in cmd
|
||||
assert cmd[cmd.index("-threads") + 1] == "2"
|
||||
# The cap precedes the output file, so it binds the encoder (not input decoding).
|
||||
@@ -44,9 +46,7 @@ class TestBrowserSafeThreads:
|
||||
|
||||
@pytest.mark.parametrize("threads", [0, None])
|
||||
def test_no_threads_flag_when_uncapped(self, threads):
|
||||
cmd = editmod.build_browser_safe_cmd(
|
||||
"/in.mkv", "/out.mp4", reencode_video=True, reencode_audio=False, threads=threads
|
||||
)
|
||||
cmd = playable.build_cmd("/in.mkv", "/out.mp4", _REENCODE_VIDEO, threads=threads)
|
||||
assert "-threads" not in cmd
|
||||
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ with "PosixPath('.') has an empty name" instead of downloading."""
|
||||
|
||||
import pytest
|
||||
|
||||
from app.downloads.edit import _codecs_from_streams, _concat_quote, run_ffprobe_json
|
||||
from app.downloads.edit import _concat_quote, run_ffprobe_json
|
||||
from app.downloads.pipeline import _media_info, _produced_file
|
||||
from pathlib import Path, PurePosixPath
|
||||
|
||||
@@ -105,46 +105,9 @@ class TestMediaInfo:
|
||||
assert _media_info(info)["id"] == "vid"
|
||||
|
||||
|
||||
class TestCodecsFromStreams:
|
||||
# ffprobe emits codec_name BEFORE codec_type, so the old line parser shifted every name onto
|
||||
# the previous stream and an H.264+AAC mp4 came back as (aac, None) → a needless re-encode.
|
||||
# _codecs_from_streams reads each stream dict by its own codec_type, immune to that ordering.
|
||||
def test_reads_each_stream_by_its_own_type(self):
|
||||
streams = [
|
||||
{"codec_name": "h264", "codec_type": "video"},
|
||||
{"codec_name": "aac", "codec_type": "audio"},
|
||||
]
|
||||
assert _codecs_from_streams(streams) == ("h264", "aac")
|
||||
|
||||
def test_audio_first_ordering_is_handled(self):
|
||||
streams = [
|
||||
{"codec_name": "AAC", "codec_type": "audio"},
|
||||
{"codec_name": "H264", "codec_type": "video"},
|
||||
]
|
||||
assert _codecs_from_streams(streams) == ("h264", "aac")
|
||||
|
||||
def test_extra_streams_do_not_win_over_the_first_of_their_kind(self):
|
||||
streams = [
|
||||
{"codec_name": "h264", "codec_type": "video"},
|
||||
{"codec_name": "vp9", "codec_type": "video"},
|
||||
{"codec_name": "mov_text", "codec_type": "subtitle"},
|
||||
{"codec_name": "opus", "codec_type": "audio"},
|
||||
]
|
||||
assert _codecs_from_streams(streams) == ("h264", "opus")
|
||||
|
||||
def test_audio_only_and_video_only(self):
|
||||
assert _codecs_from_streams([{"codec_name": "opus", "codec_type": "audio"}]) == (
|
||||
None,
|
||||
"opus",
|
||||
)
|
||||
assert _codecs_from_streams([{"codec_name": "av1", "codec_type": "video"}]) == ("av1", None)
|
||||
|
||||
def test_empty_stream_list(self):
|
||||
assert _codecs_from_streams([]) == (None, None)
|
||||
|
||||
|
||||
class TestRunFfprobeJson:
|
||||
"""The single ffprobe-JSON invocation point (shared by probe_codecs + probe.probe_media). A failed
|
||||
"""The single ffprobe-JSON invocation point (`probe.probe_json`, which the stored facts and the
|
||||
phone-safe rule both read). A failed
|
||||
probe or unparseable output must degrade to None, not raise — the malformed-input safety the old
|
||||
parse_probe_streams string parser used to own now lives here."""
|
||||
|
||||
@@ -174,7 +137,7 @@ class TestRunFfprobeJson:
|
||||
|
||||
monkeypatch.setattr(subprocess, "run", lambda *a, **k: _R())
|
||||
data = run_ffprobe_json(Path("x"), entries="stream=codec_type,codec_name")
|
||||
assert _codecs_from_streams(data["streams"]) == ("h264", None)
|
||||
assert data["streams"] == [{"codec_name": "h264", "codec_type": "video"}]
|
||||
|
||||
|
||||
class TestConcatQuote:
|
||||
|
||||
@@ -94,23 +94,6 @@ def test_audio_only_unaffected():
|
||||
assert _opts({"mode": "a", "audio_format": "m4a"})["format"] == "bestaudio/best"
|
||||
|
||||
|
||||
def test_browser_transcode_flags_universal_playability():
|
||||
F = editmod.browser_transcode_flags
|
||||
# Already universal: H.264 + AAC in mp4 → no change.
|
||||
assert F("h264", "aac", True) == (False, False, False)
|
||||
assert F("h264", "mp3", True) == (False, False, False)
|
||||
# Non-H.264 video → full video re-encode (AV1/VP9 fail iOS; HEVC fails Chrome/FF).
|
||||
assert F("av1", "aac", True) == (True, True, False)
|
||||
assert F("vp9", "opus", True) == (True, True, True)
|
||||
assert F("hevc", "aac", True) == (True, True, False)
|
||||
# H.264 but bad audio → audio-only transcode (video copied).
|
||||
assert F("h264", "opus", True) == (True, False, True)
|
||||
# Codecs fine but wrong container → remux only (no re-encode).
|
||||
assert F("h264", "aac", False) == (True, False, False)
|
||||
# A failed probe / missing stream is never re-encoded.
|
||||
assert F(None, None, True) == (False, False, False)
|
||||
|
||||
|
||||
# --- SEC-13 · one enqueue is one bounded download -------------------------------------------
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
"""The phone-safe rule (`downloads.playable`): what a library file must be so a share link plays
|
||||
on any phone or browser, and the one ffmpeg command that makes it so.
|
||||
|
||||
The plan table is the measured device verdict, class by class: what failed on a real iPhone + iPad
|
||||
(AV1, Matroska, HEVC, MP3 inside an .mp4) must change, and so must what browsers other than Safari
|
||||
cannot be trusted with (10-bit / 4:4:4 H.264, VP9, Opus). The ffmpeg cases run the real encoder and
|
||||
probe its output, because the rule is about the bytes, not the flags.
|
||||
"""
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.downloads import playable, probe
|
||||
|
||||
# conftest turns a missing ffmpeg into a hard error in the gate (SIFTLODE_TEST_REQUIRE_DB), so this
|
||||
# only ever skips on an explicit pure-logic run.
|
||||
needs_ffmpeg = pytest.mark.skipif(shutil.which("ffmpeg") is None, reason="ffmpeg not available")
|
||||
|
||||
|
||||
def _data(video=None, audio=None, fmt="mov,mp4,m4a,3gp,3g2,mj2", cover=False):
|
||||
streams = []
|
||||
if cover:
|
||||
streams.append(
|
||||
{"codec_type": "video", "codec_name": "mjpeg", "disposition": {"attached_pic": 1}}
|
||||
)
|
||||
if video:
|
||||
codec, pix = video
|
||||
streams.append({"codec_type": "video", "codec_name": codec, "pix_fmt": pix})
|
||||
if audio:
|
||||
streams.append({"codec_type": "audio", "codec_name": audio})
|
||||
return {"streams": streams, "format": {"format_name": fmt}}
|
||||
|
||||
|
||||
# (probe, extension) -> (ext, reencode_video, reencode_audio, needs_change)
|
||||
CASES = {
|
||||
"h264 8-bit + aac in mp4": (_data(("h264", "yuv420p"), "aac"), "mp4", ("mp4", 0, 0, 0)),
|
||||
"full-range h264 is still 8-bit 4:2:0": (
|
||||
_data(("h264", "yuvj420p"), "aac"),
|
||||
"mp4",
|
||||
("mp4", 0, 0, 0),
|
||||
),
|
||||
"10-bit h264": (_data(("h264", "yuv420p10le"), "aac"), "mp4", ("mp4", 1, 0, 1)),
|
||||
"4:4:4 h264": (_data(("h264", "yuv444p"), "aac"), "mp4", ("mp4", 1, 0, 1)),
|
||||
"hevc (an iPhone's .mov)": (_data(("hevc", "yuv420p10le"), "aac"), "mov", ("mp4", 1, 0, 1)),
|
||||
"vp9 + opus webm": (_data(("vp9", "yuv420p"), "opus"), "webm", ("mp4", 1, 1, 1)),
|
||||
"av1": (_data(("av1", "yuv420p"), "aac"), "mp4", ("mp4", 1, 0, 1)),
|
||||
"h264 + aac in mkv: remux only": (_data(("h264", "yuv420p"), "aac"), "mkv", ("mp4", 0, 0, 1)),
|
||||
"h264 + opus in mp4": (_data(("h264", "yuv420p"), "opus"), "mp4", ("mp4", 0, 1, 1)),
|
||||
"mp3 beside video in mp4": (_data(("h264", "yuv420p"), "mp3"), "mp4", ("mp4", 0, 1, 1)),
|
||||
"a silent h264 video": (_data(("h264", "yuv420p")), "mp4", ("mp4", 0, 0, 0)),
|
||||
"mp3": (_data(audio="mp3", fmt="mp3"), "mp3", ("mp3", 0, 0, 0)),
|
||||
"mp3 with cover art": (_data(audio="mp3", fmt="mp3", cover=True), "mp3", ("mp3", 0, 0, 0)),
|
||||
"mp3 inside an mp4: remux to .mp3": (_data(audio="mp3"), "mp4", ("mp3", 0, 0, 1)),
|
||||
"aac in m4a": (_data(audio="aac"), "m4a", ("m4a", 0, 0, 0)),
|
||||
"aac audio-only in mp4: remux to .m4a": (_data(audio="aac"), "mp4", ("m4a", 0, 0, 1)),
|
||||
"opus in ogg": (_data(audio="opus", fmt="ogg"), "ogg", ("m4a", 0, 1, 1)),
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", CASES)
|
||||
def test_the_plan_for_each_class(name):
|
||||
data, ext, (want_ext, rv, ra, needs) = CASES[name]
|
||||
p = playable.plan(data, ext)
|
||||
assert p is not None
|
||||
assert (p.ext, p.reencode_video, p.reencode_audio, p.needs_change) == (
|
||||
want_ext,
|
||||
bool(rv),
|
||||
bool(ra),
|
||||
bool(needs),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"data",
|
||||
[
|
||||
None, # ffprobe could not read it
|
||||
{"streams": [], "format": {"format_name": "pdf"}},
|
||||
_data(("png", "rgb24"), fmt="png_pipe"), # a still image is a download-only file
|
||||
],
|
||||
)
|
||||
def test_a_file_that_is_not_media_has_no_plan(data):
|
||||
assert playable.plan(data, "bin") is None
|
||||
|
||||
|
||||
def test_the_extension_is_read_with_or_without_its_dot():
|
||||
data = _data(("h264", "yuv420p"), "aac")
|
||||
assert playable.plan(data, ".MP4") == playable.plan(data, "mp4")
|
||||
assert not playable.plan(data, ".MP4").needs_change
|
||||
|
||||
|
||||
def test_a_video_re_encode_pins_8_bit_420_and_maps_only_the_judged_streams():
|
||||
p = playable.Plan("mp4", True, True, False, True)
|
||||
cmd = playable.build_cmd(Path("/in.mov"), Path("/out.mp4"), p)
|
||||
assert cmd[cmd.index("-pix_fmt") + 1] == "yuv420p"
|
||||
assert cmd[cmd.index("-c:v") + 1] == "libx264"
|
||||
assert cmd[cmd.index("-c:a") + 1] == "copy"
|
||||
maps = [cmd[i + 1] for i, a in enumerate(cmd) if a == "-map"]
|
||||
assert maps == ["0:V:0", "0:a:0?"] # never cover art, never a subtitle the muxer refuses
|
||||
assert cmd[-3:] == ["-movflags", "+faststart", "/out.mp4"]
|
||||
|
||||
|
||||
def test_an_audio_remux_copies_the_audio_alone():
|
||||
p = playable.Plan("mp3", False, False, False, True)
|
||||
cmd = playable.build_cmd(Path("/in.mp4"), Path("/out.mp3"), p)
|
||||
assert [cmd[i + 1] for i, a in enumerate(cmd) if a == "-map"] == ["0:a:0"]
|
||||
assert "-c:v" not in cmd and "-pix_fmt" not in cmd
|
||||
assert cmd[cmd.index("-c:a") + 1] == "copy"
|
||||
assert "-movflags" not in cmd # an .mp3 has no moov atom to move
|
||||
|
||||
|
||||
# --- the real encoder ------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _ffmpeg(*args: str) -> None:
|
||||
subprocess.run(["ffmpeg", "-y", "-loglevel", "error", *args], check=True)
|
||||
|
||||
|
||||
def _convert(src: Path, tmp_path: Path) -> Path:
|
||||
p = playable.plan(probe.probe_json(src), src.suffix)
|
||||
assert p is not None and p.needs_change
|
||||
dest = tmp_path / f"out.{p.ext}"
|
||||
subprocess.run(playable.build_cmd(src, dest, p), check=True, capture_output=True)
|
||||
return dest
|
||||
|
||||
|
||||
@needs_ffmpeg
|
||||
def test_a_10_bit_matroska_with_opus_comes_out_phone_safe(tmp_path):
|
||||
src = tmp_path / "in.mkv"
|
||||
_ffmpeg(
|
||||
"-f", "lavfi", "-i", "testsrc2=size=160x120:rate=10:duration=1",
|
||||
"-f", "lavfi", "-i", "sine=duration=1",
|
||||
"-c:v", "libx264", "-pix_fmt", "yuv420p10le", "-c:a", "libopus",
|
||||
str(src),
|
||||
) # fmt: skip
|
||||
out = _convert(src, tmp_path)
|
||||
data = probe.probe_json(out)
|
||||
video, audio = probe.primary_streams(data)
|
||||
assert (video["codec_name"], video["pix_fmt"], video["profile"]) == ("h264", "yuv420p", "High")
|
||||
assert audio["codec_name"] == "aac"
|
||||
assert out.suffix == ".mp4"
|
||||
assert not playable.plan(data, out.suffix).needs_change # the rule accepts its own output
|
||||
|
||||
|
||||
@needs_ffmpeg
|
||||
def test_mp3_inside_an_mp4_comes_out_as_an_mp3(tmp_path):
|
||||
# The one unsafe file prod held: an audio download remuxed into .mp4, which iPhone and iPad
|
||||
# both refused to play.
|
||||
src = tmp_path / "in.mp4"
|
||||
_ffmpeg("-f", "lavfi", "-i", "sine=duration=1", "-c:a", "libmp3lame", "-f", "mp4", str(src))
|
||||
out = _convert(src, tmp_path)
|
||||
data = probe.probe_json(out)
|
||||
assert out.suffix == ".mp3"
|
||||
assert probe.primary_streams(data)[1]["codec_name"] == "mp3"
|
||||
assert not playable.plan(data, out.suffix).needs_change
|
||||
@@ -20,7 +20,10 @@ _PW = "open sesame"
|
||||
@pytest.fixture
|
||||
def unlock_url(as_user, make_user, tmp_path, monkeypatch, upload_headers):
|
||||
monkeypatch.setattr(settings, "download_root", str(tmp_path))
|
||||
monkeypatch.setattr(probemod, "probe_media", lambda _p: ("h264", "aac", 320, 240, 5))
|
||||
video = {"codec_type": "video", "codec_name": "h264", "pix_fmt": "yuv420p"}
|
||||
audio = {"codec_type": "audio", "codec_name": "aac"}
|
||||
data = {"streams": [video, audio], "format": {"format_name": "mov,mp4", "duration": "5"}}
|
||||
monkeypatch.setattr(probemod, "probe_json", lambda _p: data)
|
||||
owner = as_user(make_user())
|
||||
job = owner.post(
|
||||
"/api/downloads/upload", content=b"fake-video", headers=upload_headers("clip.mp4")
|
||||
|
||||
@@ -7,15 +7,18 @@ guards that keep an upload out of paths that assume a re-fetchable source (edit
|
||||
|
||||
DB-backed (real Postgres) — skipped when no database is reachable. `settings.download_root` is
|
||||
pointed at a per-test tmp dir so ingest's on-disk moves are isolated. The ingest/route tests
|
||||
monkeypatch the shared `probe.probe_media` so they don't shell out to ffprobe; the classifier's OWN
|
||||
monkeypatch the shared `probe.probe_json` so they don't shell out to ffprobe; the classifier's OWN
|
||||
parsing (image → file, cover-art skip, dims, duration) is covered directly by `TestProbeClassifier`
|
||||
below, which stubs one level down (`run_ffprobe_json`) and exercises the real `probe_media`.
|
||||
below, which stubs one level down (`run_ffprobe_json`) and exercises the real `probe_media`. The
|
||||
phone-safe conversion of an upload runs the real ffmpeg (`TestPhoneSafeUpload`).
|
||||
"""
|
||||
|
||||
import ast
|
||||
import asyncio
|
||||
import io
|
||||
import pathlib
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
@@ -25,9 +28,11 @@ from app.config import settings
|
||||
from app.routes._common import capped_stream_to
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.downloads import mediakind, quota, storage
|
||||
from app.downloads import gc as gcmod
|
||||
from app.downloads import mediakind, pipeline, playable, quota, service, storage
|
||||
from app.downloads import probe as probemod
|
||||
from app.downloads import upload as uploadmod
|
||||
from app.downloads.stated import StatedFailure
|
||||
from app.models import DISPLAY_TEXT_MAX, DownloadJob, DownloadQuota, MediaAsset
|
||||
|
||||
|
||||
@@ -51,9 +56,20 @@ def _root(tmp_path, monkeypatch):
|
||||
|
||||
|
||||
def _probe(monkeypatch, result):
|
||||
# upload ingest now calls the shared `probe.probe_media` (same ground-truth ffprobe the download
|
||||
# worker uses); patch it at that shared home.
|
||||
monkeypatch.setattr(probemod, "probe_media", lambda _p: result)
|
||||
"""Stub the shared ffprobe call (`probe.probe_json`, the same ground truth the download worker
|
||||
reads) with the JSON a file of these (vcodec, acodec, width, height, duration) would give — its
|
||||
video 8-bit 4:2:0, so an H.264 + AAC .mp4 is already phone-safe and ingests straight to `done`.
|
||||
All-None is a file ffprobe could not read."""
|
||||
v, a, w, h, d = result
|
||||
streams = []
|
||||
if v:
|
||||
streams.append(
|
||||
{"codec_type": "video", "codec_name": v, "pix_fmt": "yuv420p", "width": w, "height": h}
|
||||
)
|
||||
if a:
|
||||
streams.append({"codec_type": "audio", "codec_name": a})
|
||||
data = {"streams": streams, "format": {"format_name": "mov,mp4", "duration": d}}
|
||||
monkeypatch.setattr(probemod, "probe_json", lambda _p: data if streams else None)
|
||||
|
||||
|
||||
def test_ingest_generic_file_is_download_only(db, make_user, _root, monkeypatch):
|
||||
@@ -281,7 +297,7 @@ def test_upload_route_generic_forces_download(
|
||||
|
||||
|
||||
def test_upload_route_media_html_extension_not_served_as_html(
|
||||
as_user, make_user, _root, monkeypatch, upload_headers
|
||||
db, as_user, make_user, _root, monkeypatch, upload_headers
|
||||
):
|
||||
"""The XSS guard: a MEDIA-classified upload (real codecs) named with an executable extension must
|
||||
NOT be served with a guessed text/html type. The served Content-Type comes from the verified
|
||||
@@ -298,6 +314,14 @@ def test_upload_route_media_html_extension_not_served_as_html(
|
||||
assert r.status_code == 200, r.text
|
||||
job = r.json()
|
||||
assert job["asset"]["media_kind"] == "video" and job["asset"]["container"] == "html"
|
||||
# Media in no phone-safe container is queued for conversion to .mp4, so today it is never served
|
||||
# as .html at all. The serving guard below is the second line: a row from before that rule (or
|
||||
# one whose conversion is skipped some day) must still not run as HTML.
|
||||
assert job["status"] == "queued"
|
||||
row = db.get(DownloadJob, job["id"])
|
||||
db.get(MediaAsset, row.asset_id).status = "ready"
|
||||
row.status = "done"
|
||||
db.commit()
|
||||
|
||||
# Authenticated endpoint: never text/html.
|
||||
fr = c.get(f"/api/downloads/{job['id']}/file")
|
||||
@@ -1014,3 +1038,122 @@ class TestResolveMediaFacts:
|
||||
)
|
||||
assert (v, a) == (None, "aac")
|
||||
assert mediakind.derive(v, a) == mediakind.AUDIO
|
||||
|
||||
|
||||
# --- the phone-safe conversion (an iPhone's HEVC, a webm, an mkv) ------------------------------
|
||||
|
||||
|
||||
def _queued_upload(db, make_user, root, monkeypatch, name="clip.mov"):
|
||||
"""An upload no phone could play as it is: probed as HEVC, so ingest queues it."""
|
||||
_probe(monkeypatch, ("hevc", "aac", 640, 360, 6))
|
||||
src = root / "src.bin"
|
||||
src.write_bytes(b"hevc bytes")
|
||||
job = uploadmod.ingest_upload(db, make_user().id, filename=name, src_path=src, size=10)
|
||||
return job, db.get(MediaAsset, job.asset_id)
|
||||
|
||||
|
||||
class TestPhoneSafeUpload:
|
||||
def test_an_unsafe_video_is_queued_for_the_worker_not_done(
|
||||
self, db, make_user, _root, monkeypatch
|
||||
):
|
||||
job, asset = _queued_upload(db, make_user, _root, monkeypatch)
|
||||
assert (job.status, job.progress, asset.status) == ("queued", 0, "pending")
|
||||
assert asset.rel_path.endswith(".mov") # the original waits at rel_path for the worker
|
||||
assert storage.abs_path(str(_root), asset.rel_path).exists()
|
||||
|
||||
def test_a_safe_video_still_lands_done(self, db, make_user, _root, monkeypatch):
|
||||
_probe(monkeypatch, ("h264", "aac", 640, 360, 6))
|
||||
src = _root / "src.bin"
|
||||
src.write_bytes(b"h264 bytes")
|
||||
job = uploadmod.ingest_upload(
|
||||
db, make_user().id, filename="clip.mp4", src_path=src, size=10
|
||||
)
|
||||
assert job.status == "done" and db.get(MediaAsset, job.asset_id).status == "ready"
|
||||
|
||||
def test_deleting_a_queued_upload_removes_its_original(self, db, make_user, _root, monkeypatch):
|
||||
job, asset = _queued_upload(db, make_user, _root, monkeypatch)
|
||||
path, asset_id = storage.abs_path(str(_root), asset.rel_path), asset.id
|
||||
service.delete(db, job)
|
||||
assert not path.exists()
|
||||
assert db.get(MediaAsset, asset_id) is None
|
||||
|
||||
def test_one_cancelled_mid_conversion_is_reaped_once_the_worker_let_go(
|
||||
self, db, make_user, _root, monkeypatch
|
||||
):
|
||||
job, asset = _queued_upload(db, make_user, _root, monkeypatch)
|
||||
path, asset_id = storage.abs_path(str(_root), asset.rel_path), asset.id
|
||||
asset.status = "downloading" # the worker child is reading the original
|
||||
job.status = "running"
|
||||
db.commit()
|
||||
service.cancel(db, job)
|
||||
assert path.exists() # never pulled out from under a running ffmpeg
|
||||
db.get(MediaAsset, asset_id).status = "pending" # the worker stopped the child and let go
|
||||
db.commit()
|
||||
gcmod.run_download_gc(db)
|
||||
db.expire_all()
|
||||
assert not path.exists()
|
||||
assert db.get(MediaAsset, asset_id) is None
|
||||
|
||||
def test_a_failed_conversion_keeps_the_original_for_retry(
|
||||
self, db, make_user, _root, monkeypatch
|
||||
):
|
||||
job, asset = _queued_upload(db, make_user, _root, monkeypatch)
|
||||
path = storage.abs_path(str(_root), asset.rel_path)
|
||||
job.status, asset.status = "running", "downloading"
|
||||
db.commit()
|
||||
|
||||
def _ffmpeg_fails(*_a, **_k):
|
||||
raise StatedFailure("ffmpeg failed (1): unsupported")
|
||||
|
||||
monkeypatch.setattr(pipeline.editmod, "run_ffmpeg", _ffmpeg_fails)
|
||||
pipeline._convert_upload(job.id, asset.id)
|
||||
db.expire_all()
|
||||
assert (db.get(DownloadJob, job.id).status, db.get(MediaAsset, asset.id).status) == (
|
||||
"error",
|
||||
"error",
|
||||
)
|
||||
assert path.exists()
|
||||
service.resume(db, db.get(DownloadJob, job.id))
|
||||
assert db.get(MediaAsset, asset.id).status == "pending" # Retry converts from it again
|
||||
|
||||
@pytest.mark.skipif(shutil.which("ffmpeg") is None, reason="ffmpeg not available")
|
||||
def test_the_worker_converts_it_and_keeps_only_the_safe_file(
|
||||
self, db, make_user, _root, monkeypatch
|
||||
):
|
||||
# Real bytes, real probe, real ffmpeg: a 10-bit H.264 + Opus Matroska, the kind of file
|
||||
# that reached the share page as-is before this rule.
|
||||
src = _root / "src.mkv"
|
||||
subprocess.run(
|
||||
[
|
||||
"ffmpeg", "-y", "-loglevel", "error",
|
||||
"-f", "lavfi", "-i", "testsrc2=size=160x120:rate=10:duration=1",
|
||||
"-f", "lavfi", "-i", "sine=duration=1",
|
||||
"-c:v", "libx264", "-pix_fmt", "yuv420p10le", "-c:a", "libopus", str(src),
|
||||
],
|
||||
check=True,
|
||||
) # fmt: skip
|
||||
job = uploadmod.ingest_upload(
|
||||
db, make_user().id, filename="holiday.mkv", src_path=src, size=src.stat().st_size
|
||||
)
|
||||
asset = db.get(MediaAsset, job.asset_id)
|
||||
original = storage.abs_path(str(_root), asset.rel_path)
|
||||
assert job.status == "queued"
|
||||
job.status, asset.status = "running", "downloading" # what the worker's claim does
|
||||
db.commit()
|
||||
|
||||
pipeline._convert_upload(job.id, asset.id)
|
||||
|
||||
db.expire_all()
|
||||
asset = db.get(MediaAsset, job.asset_id)
|
||||
assert db.get(DownloadJob, job.id).status == "done"
|
||||
assert (asset.status, asset.container, asset.vcodec, asset.acodec) == (
|
||||
"ready",
|
||||
"mp4",
|
||||
"h264",
|
||||
"aac",
|
||||
)
|
||||
final = storage.abs_path(str(_root), asset.rel_path)
|
||||
assert final.suffix == ".mp4" and final.exists()
|
||||
assert asset.size_bytes == final.stat().st_size
|
||||
assert not original.exists() # one file per upload
|
||||
assert not playable.plan(probemod.probe_json(final), final.suffix).needs_change
|
||||
|
||||
Reference in New Issue
Block a user