Files
teslayoutube/resolver.py
Erhan Keseli 74f49c7712 Initial commit: ASCILINE YouTube Streamer
ASCII-art YouTube streaming for the Tesla in-car browser.

- FastAPI server on a Mac mini, no Docker.
- yt-dlp resolver: ID/URL/search.
- ffmpeg with -re -fps_mode cfr for source-paced video; trivial drain
  consumer.  Separate ffmpeg for AAC/ADTS audio.
- Vendored ASCILINE renderer (MIT) for the binary wire protocol; pure
  fillText color path, on-demand selection flush.
- HMAC PIN-gated cookie; Secure flag scheme-aware so /audio works on
  plain http during local dev.
- LOW preset (120x50 24fps) verified clean on M4: FPS 24/24, JIT ~42ms.
2026-06-13 18:05:19 +02:00

204 lines
6.2 KiB
Python

"""Map user input (video ID / YouTube URL / search query) to media URLs.
Resolution order:
1. YouTube URL (youtube.com / youtu.be) → use directly.
2. Bare 11-char video ID → https://www.youtube.com/watch?v=<id>. If
yt-dlp reports "video unavailable", fall back to search.
3. Anything else → prefix with `ytsearch1:` and let yt-dlp pick the
first hit.
Returns a ResolvedMedia. Raises ResolverError on failure.
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from typing import Optional
import yt_dlp
_VIDEO_ID_RE = re.compile(r"^[A-Za-z0-9_-]{11}$")
_YOUTUBE_URL_RE = re.compile(
r"^(https?://)?(www\.)?(youtube\.com|youtu\.be)/", re.IGNORECASE
)
class ResolverError(Exception):
pass
@dataclass
class ResolvedMedia:
title: str
is_live: bool
duration: Optional[float] # seconds; None for live
video_url: str # direct stream URL for ffmpeg
audio_url: Optional[str] # separate audio URL, or None if muxed
video_id: str
format_note: str
def _ydl_opts(max_height: int) -> dict:
# For live streams yt-dlp picks the HLS manifest automatically when
# the format filter matches. Prefer split video+audio so the two
# pipelines can be independent; accept muxed as fallback.
fmt = (
f"bestvideo[height<={max_height}][ext=mp4]"
f"+bestaudio[ext=m4a]"
f"/bestvideo[height<={max_height}]+bestaudio"
f"/best[height<={max_height}]"
f"/best"
)
return {
"format": fmt,
"quiet": True,
"no_warnings": True,
"extract_flat": False,
}
def _extract(url: str, max_height: int) -> dict:
opts = _ydl_opts(max_height)
with yt_dlp.YoutubeDL(opts) as ydl:
try:
info = ydl.extract_info(url, download=False)
except yt_dlp.utils.DownloadError as exc:
raise ResolverError(str(exc)) from exc
if info is None:
raise ResolverError(f"yt-dlp returned no info for: {url}")
# ytsearch1: wraps the result in an "entries" list.
if "entries" in info:
entries = list(info["entries"])
if not entries:
raise ResolverError(f"No results for query: {url}")
info = entries[0]
if info.get("url") is None and info.get("webpage_url"):
with yt_dlp.YoutubeDL(opts) as ydl2:
try:
info = ydl2.extract_info(info["webpage_url"], download=False)
except yt_dlp.utils.DownloadError as exc:
raise ResolverError(str(exc)) from exc
return info
def _pick_urls(info: dict) -> tuple[str, Optional[str]]:
"""(video_url, audio_url). audio_url is None when video_url is muxed."""
requested = info.get("requested_formats")
if requested and len(requested) >= 2:
video_fmt = next(
(f for f in requested if f.get("vcodec") not in (None, "none")), None
)
audio_fmt = next(
(f for f in requested
if f.get("acodec") not in (None, "none")
and f.get("vcodec") in (None, "none")),
None,
)
if video_fmt and audio_fmt:
return video_fmt["url"], audio_fmt["url"]
if video_fmt:
return video_fmt["url"], None
url = info.get("url")
if not url:
raise ResolverError("yt-dlp returned no playable URL")
return url, None
def _build_format_note(info: dict) -> str:
requested = info.get("requested_formats")
if requested:
parts = []
for f in requested:
h = f.get("height")
codec = f.get("vcodec") or f.get("acodec") or "?"
note = f.get("format_note") or ""
parts.append(f"{codec} {h}p {note}".strip() if h else f"{codec} {note}".strip())
return " + ".join(parts)
return (
f"{info.get('format_note', '')} "
f"{info.get('height', '')}p "
f"{info.get('ext', '')}".strip()
)
def _is_unavailable(err: ResolverError) -> bool:
msg = str(err).lower()
return any(
kw in msg
for kw in ("video unavailable", "this video is not available", "private video",
"has been removed", "not available in your country")
)
def resolve(query: str, max_height: int = 720) -> ResolvedMedia:
"""Resolve user input to a ResolvedMedia. Raises ResolverError."""
query = query.strip()
if _YOUTUBE_URL_RE.match(query):
yt_url = query
is_id_attempt = False
elif _VIDEO_ID_RE.match(query):
yt_url = f"https://www.youtube.com/watch?v={query}"
is_id_attempt = True
else:
yt_url = f"ytsearch1:{query}"
is_id_attempt = False
try:
info = _extract(yt_url, max_height)
except ResolverError as exc:
# ID-failure → retry as search.
if is_id_attempt and _is_unavailable(exc):
info = _extract(f"ytsearch1:{query}", max_height)
else:
raise
video_url, audio_url = _pick_urls(info)
return ResolvedMedia(
title=info.get("title") or "Unknown",
is_live=bool(info.get("is_live")),
duration=info.get("duration"),
video_url=video_url,
audio_url=audio_url,
video_id=info.get("id") or "",
format_note=_build_format_note(info),
)
# CLI test entry: python -m resolver "<input>"
if __name__ == "__main__":
import sys
import json
if len(sys.argv) < 2:
print("Usage: python -m resolver \"<video ID | URL | search query>\"")
sys.exit(1)
user_input = " ".join(sys.argv[1:])
print(f"Resolving: {user_input!r}")
try:
result = resolve(user_input)
except ResolverError as e:
print(f"ERROR: {e}", file=sys.stderr)
sys.exit(1)
print(json.dumps(
{
"title": result.title,
"video_id": result.video_id,
"is_live": result.is_live,
"duration": result.duration,
"format_note": result.format_note,
"video_url": result.video_url[:80] + "" if len(result.video_url) > 80 else result.video_url,
"audio_url": (
(result.audio_url[:80] + "" if len(result.audio_url) > 80 else result.audio_url)
if result.audio_url else None
),
},
indent=2,
))