Files
siftlode/backend/tests/test_formats.py
T
peter 0cc450d1c9 fix(downloads,sync): bound what one user can make the server fetch, download and spend
SEC-12: the download box accepts an arbitrary http(s) URL and yt-dlp fetches
it from the WORKER, so any approved user could aim the server at the compose
network, the LAN or a cloud metadata endpoint — and read the result off
job.error, where "connection refused" vs "HTTP Error 401" vs "timed out" is a
port scanner. downloads/urlguard.py refuses a URL whose host resolves to a
loopback/private/link-local/reserved address, at enqueue AND again in the
worker (DNS can move in between, and the worker is where the socket opens).
A network-class failure on a `url` job now reports one fixed sentence instead
of the raw transport text; the error CODE is still classified from the
original, so the localized explanation is unchanged. DNS rebinding is
explicitly NOT closed — that needs the connection gated inside yt-dlp — and
the module says so rather than implying more than it does.

SEC-13: `noplaylist` only demotes a video-inside-a-playlist URL to that video,
so a URL that is purely a playlist or channel still expanded into every
entry — one enqueue, unbounded downloads. `playlist_items` pins it to one.
Size was unbounded too: `check_enqueue` compares the CURRENT footprint against
the cap, and a download's size is unknown until it lands, so one job could
overshoot by any amount; the owner's remaining budget is now handed to yt-dlp
as `max_filesize`, applied AFTER the adapter merge so no source adapter can
raise a limit the quota layer set. A join's cut-list is capped at 100: an
accurate join emits two filters per segment into one ffmpeg filter_complex.

SEC-14: the YouTube budget is instance-wide, so one user holding down a
manual sync spent the day for everyone. Per-user (not per-IP — the cost
belongs to the account) 3-per-10-minutes limiters on the two bulk pulls,
subscriptions and playlists, with separate budgets so one does not close the
other. The review also listed /rss, /backfill and /enrich; E3 deleted them,
and search stays unlimited on purpose — it is interactive, not a bulk pull.

Gate: pytest 896 -> 924 (none skipped), vitest 671.
2026-09-05 01:03:24 +02:00

161 lines
7.5 KiB
Python

"""build_ydl_opts: EVERY download is clamped to the mp4/H.264 compat profile (E7 browser-
playability guarantee) and must SELECT H.264 (not merely prefer it), so shared downloads play in
every browser incl. iOS/Safari. Non-mp4 containers and non-H.264 vcodecs are coerced, not honoured.
See the iPad AV1 broken-player fix + E7 format narrowing."""
from app.downloads import edit as editmod
from app.downloads import formats
def _opts(spec):
return formats.build_ydl_opts(spec, "/tmp/%(id)s.%(ext)s", lambda d: None)
def test_adapter_extra_opts_are_merged_in():
# The SourceAdapter contributes source-specific opts (e.g. YouTube's extractor_args) via `extra`.
extra = {"extractor_args": {"youtube": {"player_client": ["web"]}}}
opts = formats.build_ydl_opts({}, "/tmp/%(id)s.%(ext)s", lambda d: None, extra=extra)
assert opts["extractor_args"]["youtube"]["player_client"] == ["web"]
# No extra → no extractor_args (a generic source contributes nothing).
assert "extractor_args" not in formats.build_ydl_opts({}, "/tmp/x", lambda d: None)
def test_mp4_default_selects_avc1_with_progressive_fallback():
fmt = _opts({"mode": "av", "container": "mp4"})["format"]
# avc1 video-only first, then the avc1 progressive, then an unrestricted fallback.
assert fmt.startswith("bestvideo[vcodec^=avc1]+bestaudio[acodec^=mp4a]")
assert "/best[vcodec^=avc1]" in fmt
assert fmt.endswith("bestvideo+bestaudio/best")
def test_format_sig_salt_forks_a_distinct_identity():
spec = {"mode": "av", "container": "mp4", "max_height": 720}
base = formats.format_sig(spec)
# A salt yields a distinct cache identity (force-redownload's private-asset fork); different
# salts differ from each other and from the unsalted sig, same salt is stable.
assert formats.format_sig(spec, salt="7:12") != base
assert formats.format_sig(spec, salt="7:12") != formats.format_sig(spec, salt="7:13")
assert formats.format_sig(spec, salt="7:12") == formats.format_sig(spec, salt="7:12")
def test_mp4_default_keeps_h264_sort_tiebreaker():
sort = _opts({"mode": "av", "container": "mp4"})["format_sort"]
assert "vcodec:h264" in sort and "acodec:aac" in sort
def test_video_only_mp4_selects_avc1():
fmt = _opts({"mode": "v", "container": "mp4"})["format"]
assert fmt.startswith("bestvideo[vcodec^=avc1]")
assert "+bestaudio" not in fmt # video-only never merges audio
# A video-only download stays audio-free: every `bestvideo` (video-only) option is preferred
# over any `best` (progressive, audio-bearing) fallback.
assert fmt.index("bestvideo") < fmt.index("best[vcodec^=avc1]") < fmt.rindex("/best")
def test_max_height_caps_every_branch():
fmt = _opts({"mode": "av", "container": "mp4", "max_height": 720})["format"]
assert fmt.count("[height<=?720]") >= 3 # applied to each avc1/fallback branch
def test_explicit_vcodec_is_clamped_to_the_compat_selector():
# E7 browser-playability guarantee: a non-H.264 vcodec no longer opts out — normalize() coerces
# vcodec→None, so the avc1 selector (and mp4 merge) is used regardless of what was requested.
opts = _opts({"mode": "av", "container": "mp4", "vcodec": "vp9"})
assert opts["format"].startswith("bestvideo[vcodec^=avc1]")
assert opts["merge_output_format"] == "mp4"
assert "vcodec:vp9" not in opts["format_sort"]
def test_non_mp4_container_is_clamped_to_mp4():
# A webm/mkv request is coerced to the mp4/H.264 compat profile (nothing plays mkv in-browser;
# webm/VP9 fails iOS), so the avc1 selector is used and the merge target is mp4.
opts = _opts({"mode": "av", "container": "webm"})
assert opts["format"].startswith("bestvideo[vcodec^=avc1]")
assert opts["merge_output_format"] == "mp4"
def test_normalize_clamps_to_browser_playable():
n = formats.normalize({"container": "mkv", "vcodec": "av1", "audio_format": "opus"})
assert n["container"] == "mp4"
assert n["vcodec"] is None
assert n["audio_format"] == "m4a"
# A playable audio choice is preserved.
assert formats.normalize({"audio_format": "mp3"})["audio_format"] == "mp3"
def test_non_playable_specs_dedup_to_the_compat_identity():
# Two requests that differ only in (now-clamped) non-playable fields share ONE cache asset.
a = formats.format_sig({"mode": "av", "max_height": 720, "container": "webm", "vcodec": "av1"})
b = formats.format_sig({"mode": "av", "max_height": 720, "container": "mp4"})
assert a == b
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 -------------------------------------------
def test_a_playlist_url_can_only_yield_one_item():
# `noplaylist` alone only demotes a video-inside-a-playlist URL to that video; a URL that is
# purely a playlist or channel has no such video, so without this it expands into every entry.
opts = _opts({})
assert opts["noplaylist"] is True
assert opts["playlist_items"] == "1"
def test_the_remaining_quota_becomes_a_hard_size_ceiling():
opts = formats.build_ydl_opts({}, "/tmp/x", lambda d: None, max_bytes=5_000_000)
assert opts["max_filesize"] == 5_000_000
def test_no_budget_means_no_size_filter():
# Unlimited users pass None; a non-positive remainder is the quota layer's refusal to make at
# enqueue, not an impossible yt-dlp filter to express here.
for budget in (None, 0, -1):
assert "max_filesize" not in formats.build_ydl_opts(
{}, "/tmp/x", lambda d: None, max_bytes=budget
)
def test_a_source_adapter_cannot_raise_the_size_ceiling():
# The adapter merge happens after max_filesize is set, so this pins that no adapter option can
# widen a storage cap — the mirror of the cookiefile rule, in the opposite direction.
opts = formats.build_ydl_opts(
{}, "/tmp/x", lambda d: None, extra={"max_filesize": 999_999_999}, max_bytes=5_000_000
)
assert opts["max_filesize"] == 5_000_000
def test_a_join_cut_list_is_capped():
# An accurate join emits two filters per segment into one ffmpeg filter_complex, so an unbounded
# list is an unbounded command line. The surplus is dropped; the legitimate head survives.
segs = [{"start_s": i, "end_s": i + 1} for i in range(500)]
out = editmod.normalize_edit_spec({"segments": segs})
assert len(out["segments"]) == editmod._MAX_SEGMENTS
assert out["segments"][0] == {"start_s": 0.0, "end_s": 1.0}
def test_a_normal_cut_list_is_untouched():
segs = [{"start_s": 0, "end_s": 5}, {"start_s": 10, "end_s": 12}]
out = editmod.normalize_edit_spec({"segments": segs})
assert len(out["segments"]) == 2