Consolidates source-specific policy (youtube / facebook / reddit / generic) that was scattered across worker.py, backfill.py, service.py, formats.py and storage.py with no shared source identity. app/downloads/adapters.py holds the base (= generic yt-dlp behaviour) + per-source overrides; resolve_adapter(source_kind, source_ref, info) picks one by source_kind, the yt-dlp extractor_key, or the URL host, falling back to generic. yt-dlp stays the engine — the adapter is policy only. Moved into adapters (behaviour-equivalent): - uploader_url derivation (YouTube channel_url / Facebook uploader_id / reddit None) — removes the worker/backfill duplication (build_uploader_url + _should_refetch gone). - the player-client fallback sets + retriable markers + YouTube extractor_args/POT (build_ydl_opts now takes source-agnostic opts plus an `extra` dict from the adapter). - source_url reconstruction (service.source_url delegates to the adapter). Two intentional (approved) behaviour changes: - the player-client fallback is now YouTube-only; non-YouTube sources make a single extraction attempt instead of a pointless double attempt with youtube-only client args. - the .nfo <uniqueid type=...> follows the source (adapter.nfo_uniqueid_type) instead of the hardcoded "youtube" that mislabelled every url-source download. Deliberately left generic (documented non-moves): title cleaning (normalize_title is harmless across sources), the classify marker table, thumbnail_to_jpg, the format selectors, and og.py's YouTube-hqdefault og:image preference (a self-contained thumbnail heuristic on the share path). Verified end-to-end on localdev across all four adapters: downloads succeed and uploader_url + the .nfo uniqueid type are correct per source.
89 lines
4.1 KiB
Python
89 lines
4.1 KiB
Python
"""build_ydl_opts: the mp4 compat profile must SELECT H.264 (not merely prefer it), so shared
|
|
downloads play on iOS/Safari, while custom vcodec / non-mp4 profiles opt out. See the iPad AV1
|
|
broken-player fix."""
|
|
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_opts_out_of_avc1_selector():
|
|
opts = _opts({"mode": "av", "container": "mp4", "vcodec": "vp9"})
|
|
assert "vcodec^=avc1" not in opts["format"]
|
|
assert opts["format"] == "bestvideo+bestaudio/best"
|
|
assert "vcodec:vp9" in opts["format_sort"]
|
|
|
|
|
|
def test_non_mp4_container_opts_out():
|
|
fmt = _opts({"mode": "av", "container": "webm"})["format"]
|
|
assert "vcodec^=avc1" not in fmt
|
|
|
|
|
|
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)
|