Fetch YouTube at up to 1080p instead of 360p
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.
This commit is contained in:
@@ -8,7 +8,10 @@ couple of toys for messing with the last video.
|
|||||||
|
|
||||||
- **Auto-download** — watches every group message for a supported link and
|
- **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).
|
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
|
- **`/speed [n]`** — re-posts the last video sped up `n`× (default 2×), pitch-shifted
|
||||||
like tape. e.g. `/speed 4`, `/speed 0.5`.
|
like tape. e.g. `/speed 4`, `/speed 0.5`.
|
||||||
- **`/rev`** — re-posts the last video reversed (audio too).
|
- **`/rev`** — re-posts the last video reversed (audio too).
|
||||||
|
|||||||
@@ -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.
|
# 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)
|
_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]:
|
def _cookie_args(url: str) -> list[str]:
|
||||||
"""yt-dlp --cookies args for this URL, withheld for YouTube (see note above)."""
|
"""yt-dlp --cookies args for this URL, withheld for YouTube (see note above)."""
|
||||||
if not os.path.exists(COOKIES):
|
if not os.path.exists(COOKIES):
|
||||||
return []
|
return []
|
||||||
host = (urlsplit(url).hostname or "").lower()
|
if _is_youtube(url):
|
||||||
if _YOUTUBE_HOST_RE.search(host):
|
|
||||||
return []
|
return []
|
||||||
return ["--cookies", COOKIES]
|
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: host an oversized video as a temporary download link ---
|
||||||
FORCE_DURATION_THRESHOLD = 3600 # offer /force for videos longer than this (1 hr)
|
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
|
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 —
|
# Download only the requested window instead of the whole video —
|
||||||
# essential for hour-long uploads shared with a ?t= timestamp.
|
# essential for hour-long uploads shared with a ?t= timestamp.
|
||||||
# force-keyframes-at-cuts makes the start boundary accurate.
|
# force-keyframes-at-cuts makes the start boundary accurate.
|
||||||
# The filesize filters below key off the *whole* video's size, which
|
# A whole-video filesize cap is meaningless for a 60s slice, so take the
|
||||||
# is irrelevant to a 60s slice, so drop them and just take best mp4.
|
# 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 += [
|
cmd += [
|
||||||
"--download-sections", f"*{start}-{end}",
|
"--download-sections", f"*{start}-{end}",
|
||||||
"--force-keyframes-at-cuts",
|
"--force-keyframes-at-cuts",
|
||||||
"-f", "best[ext=mp4]/best",
|
"-f", "bv*+ba/b",
|
||||||
|
"-S", FORMAT_SORT_YOUTUBE,
|
||||||
]
|
]
|
||||||
else:
|
else:
|
||||||
# Prefer the largest mp4 that already fits under 95 MB, so we avoid
|
cmd += _format_args(url)
|
||||||
# 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 += [
|
cmd += [
|
||||||
"--merge-output-format", "mp4",
|
"--merge-output-format", "mp4",
|
||||||
*_cookie_args(url),
|
*_cookie_args(url),
|
||||||
|
|||||||
Reference in New Issue
Block a user