From 1523bdd48df64c407b121e454cf8f185d5d07549 Mon Sep 17 00:00:00 2001 From: James Price Date: Fri, 14 Aug 2026 14:30:11 -0400 Subject: [PATCH] Fetch YouTube at up to 1080p instead of 360p MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit YouTube's only muxed mp4 is format 18 (360p), so the muxed-first selector was posting 360p even when 1080p was available. Split streams are now preferred for YouTube and merged locally; every other host keeps the muxed-first chain unchanged (verified identical selection for Instagram, X and Reddit). Three things the obvious version got wrong: - A height<=1080 cap excludes portrait video: a 1080p Short or reel is 1080x1920. Resolution is capped with -S res:1080 instead, which keys off the smaller dimension. - h264 is a hard first preference, not an -S sort key. At equal resolution YouTube's VP9/AV1 renditions are smaller and win on size, but neither reliably plays in an mp4 container on older Signal clients. Dropping a resolution rung to stay on h264 is the better trade, and is usually smaller too: a 10-min video takes 480p h264 (37 MB) over 720p AV1 (78 MB). - The audio cap matters. YouTube's 388k track is 29 MB by itself on a 10-min video, enough to push an otherwise-fine merge past the 100 MB Signal limit; abr<=160 takes the 129k track instead. The 88 MB video cap makes yt-dlp step down automatically on long videos, so a 3-min video lands at 1080p (80 MB) and a 10-min one at 480p. Clips get the same treatment — they're YouTube-only, and 60s at 1080p is only ~29 MB. /force is deliberately left muxed-first: it exists for the biggest, slowest videos, where 1080p would risk the rsync timeout. Measured, old -> new: 3-min 360p/11 MB -> 1080p/80 MB; 10-min 360p/27 MB -> 480p/37 MB; portrait Short 360x640/6 MB -> 1080x1920/23 MB. Verified end to end: real downloads decode as h264+aac, clip lands at exactly 60.000s. --- README.md | 5 +++- bot.py | 80 ++++++++++++++++++++++++++++++++++++++++++------------- 2 files changed, 66 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 6172b88..afe7bee 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,10 @@ couple of toys for messing with the last video. - **Auto-download** — watches every group message for a supported link and replies with the video. Powered by [yt-dlp](https://github.com/yt-dlp/yt-dlp). - Videos over 100 MB are auto re-encoded with ffmpeg to fit. + Videos over 100 MB are auto re-encoded with ffmpeg to fit. YouTube is fetched + as h264 up to 1080p, stepping down a resolution rung on longer videos so the + merge lands under the Signal size limit without a re-encode; other hosts take + their muxed rendition as-is. - **`/speed [n]`** — re-posts the last video sped up `n`× (default 2×), pitch-shifted like tape. e.g. `/speed 4`, `/speed 0.5`. - **`/rev`** — re-posts the last video reversed (audio too). diff --git a/bot.py b/bot.py index 6671af9..f764505 100644 --- a/bot.py +++ b/bot.py @@ -68,15 +68,68 @@ JS_RUNTIME = f"node:{_NODE22}" if os.path.exists(_NODE22) else "node" # Instagram/TikTok/X, so we withhold them per-URL rather than dropping the file. _YOUTUBE_HOST_RE = re.compile(r"(?:^|\.)(?:youtube\.com|youtu\.be|youtube-nocookie\.com)$", re.I) +def _is_youtube(url: str) -> bool: + return bool(_YOUTUBE_HOST_RE.search((urlsplit(url).hostname or "").lower())) + + def _cookie_args(url: str) -> list[str]: """yt-dlp --cookies args for this URL, withheld for YouTube (see note above).""" if not os.path.exists(COOKIES): return [] - host = (urlsplit(url).hostname or "").lower() - if _YOUTUBE_HOST_RE.search(host): + if _is_youtube(url): return [] return ["--cookies", COOKIES] + +# --- format selection --- +# Muxed-first. Right for every host that publishes a complete rendition (X, +# Instagram, TikTok): their muxed stream is the best quality on offer, and taking +# it avoids a merge. The bv*+ba tail catches split-only hosts (Reddit), and the +# bare bv* catches silent videos. +FORMAT_MUXED_FIRST = ( + "best[ext=mp4][filesize<95M]/best[ext=mp4][filesize_approx<95M]/best[ext=mp4]/best" + "/bv*[filesize<95M]+ba/bv*[filesize_approx<95M]+ba/bv*+ba/bv*" +) + +# YouTube is the one host whose muxed renditions stop at 360p (format 18 is the +# only mp4 carrying both streams), so the chain above posts 360p even when 1080p +# is sitting right there. Take split streams first and merge them instead. +# +# avc1 (h264) is taken as a hard first preference rather than left to the -S sort +# below: at equal resolution YouTube's VP9/AV1 renditions are smaller and would +# win on size, but they are the two codecs that don't reliably play in an mp4 +# container on older Signal clients. Falling back a resolution rung to stay on +# h264 is the better trade for a chat bot — a 10-min video takes 480p h264 +# (~27 MB) over 720p AV1 (~74 MB), which is both more compatible and smaller. +# +# The 88 MB video cap keeps the merge under MAX_FILE_SIZE without paying for a +# re-encode, and makes yt-dlp step down a rung on long videos automatically: it +# picks the best rendition that fits, so a 3-min video lands at 1080p h264 +# (~81 MB) while a 10-min one (1080p ≈ 245 MB) drops to 480p. Anything past ~1 hr +# never reaches here — _process_one short-circuits it to a /force offer. +# +# abr<=160 rules out YouTube's 388k audio track, which is 29 MB by itself on a +# 10-min video — enough to push an otherwise-fine merge over the limit. The bare +# +ba term after it covers videos where yt-dlp reports no bitrate. +FORMAT_YOUTUBE = ( + "bv*[vcodec^=avc1][filesize<88M]+ba[abr<=160]" + "/bv*[vcodec^=avc1][filesize_approx<88M]+ba[abr<=160]" + "/bv*[filesize<88M]+ba[abr<=160]/bv*[filesize_approx<88M]+ba[abr<=160]" + "/bv*+ba[abr<=160]/bv*+ba/" + FORMAT_MUXED_FIRST +) +# Deliberately a sort, not a `height<=1080` filter: `res` keys off the *smaller* +# dimension, so it caps portrait Shorts (1080x1920) at 1080p instead of excluding +# them outright the way a height filter would. h264/AAC play on every Signal +# client; VP9/AV1 in an mp4 container do not reliably. +FORMAT_SORT_YOUTUBE = "res:1080,vcodec:h264,acodec:aac" + + +def _format_args(url: str) -> list[str]: + """yt-dlp format-selection args for this URL.""" + if _is_youtube(url): + return ["-f", FORMAT_YOUTUBE, "-S", FORMAT_SORT_YOUTUBE] + return ["-f", FORMAT_MUXED_FIRST] + # --- /force: host an oversized video as a temporary download link --- FORCE_DURATION_THRESHOLD = 3600 # offer /force for videos longer than this (1 hr) FORCE_OFFER_TTL = 600 # a /force offer stays valid for 10 min @@ -544,27 +597,18 @@ def _run_ytdlp(url: str, outpath: str, tmpdir: str, # Download only the requested window instead of the whole video — # essential for hour-long uploads shared with a ?t= timestamp. # force-keyframes-at-cuts makes the start boundary accurate. - # The filesize filters below key off the *whole* video's size, which - # is irrelevant to a 60s slice, so drop them and just take best mp4. + # A whole-video filesize cap is meaningless for a 60s slice, so take the + # best rendition outright. Clips only ever come from YouTube (the caller + # gates timestamps on YOUTUBE_URL_PATTERN), where that means split + # streams — 1080p for a clip is only ~20 MB. cmd += [ "--download-sections", f"*{start}-{end}", "--force-keyframes-at-cuts", - "-f", "best[ext=mp4]/best", + "-f", "bv*+ba/b", + "-S", FORMAT_SORT_YOUTUBE, ] else: - # Prefer the largest mp4 that already fits under 95 MB, so we avoid - # re-encoding when a smaller variant exists (e.g. a 4K rendition - # >100 MB alongside a 1080p ~50 MB). - # The bv*+ba tail is required for hosts that never publish a muxed - # rendition — Reddit serves video and audio as separate DASH/HLS - # streams, so every `best...` term above fails and yt-dlp aborts with - # "Requested format is not available" unless we merge them ourselves. - # The bare bv* last resort covers silent videos (no audio to merge). - cmd += [ - "-f", - "best[ext=mp4][filesize<95M]/best[ext=mp4][filesize_approx<95M]/best[ext=mp4]/best" - "/bv*[filesize<95M]+ba/bv*[filesize_approx<95M]+ba/bv*+ba/bv*", - ] + cmd += _format_args(url) cmd += [ "--merge-output-format", "mp4", *_cookie_args(url),