Skip to content

Instantly share code, notes, and snippets.

@ruseel
Created March 29, 2026 03:37
Show Gist options
  • Select an option

  • Save ruseel/127992e51eb95b12c31a87630f7fd68b to your computer and use it in GitHub Desktop.

Select an option

Save ruseel/127992e51eb95b12c31a87630f7fd68b to your computer and use it in GitHub Desktop.
lge_standbyme_youtube_launch.py
#!/usr/bin/env python3
"""
run1.py — DIAL screenId → casttube Lounge API 로 LGE webOS YouTube 앱 제어
동작 과정:
1. DIAL SSDP 디스커버리 → LG TV 의 Application-URL 획득
2. DIAL POST /apps/YouTube (Chrome Origin) → webOS YouTube 앱 실행
3. DIAL GET /apps/YouTube → additionalData 에서 screenId 획득
4. casttube YouTubeSession(screenId) → Lounge API 세션 수립
(get_lounge_token_batch → bind → SID/gsessionid)
5. session.play_video(video_id) → setPlaylist via Lounge API
→ TV 의 YouTube 앱 안에서 동영상 재생
핵심 발견:
- pychromecast 의 Cast LAUNCH(8009) 로 띄우는 것은 built-in Cast receiver 이다.
LGE webOS 의 실제 YouTube 앱이 아니다.
- Chrome 이 Cast 버튼을 누를 때는 DIAL 로 YouTube 앱을 먼저 띄운다.
- DIAL 로 띄운 YouTube 앱의 상태(GET /apps/YouTube)에 screenId 가 들어있다.
- 이 screenId 로 casttube(YouTube Lounge API)를 호출하면
실제 webOS YouTube 앱 안에서 동영상이 재생된다.
- DIAL YouTube 요청에는 Origin: package:Google-Chrome.145.Mac-OS-X 헤더가 필요하다.
없으면 403.
필요 패키지:
pip install casttube requests
사용법:
python run1.py # 기본 video (dQw4w9WgXcQ)
python run1.py --video 9bZkp7q19f0 # 다른 video
python run1.py --screen-id XXXXX # screenId 직접 지정 (DIAL 건너뜀)
"""
import argparse
import json
import socket
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
import uuid
import xml.etree.ElementTree as ET
from casttube import YouTubeSession # type: ignore[import-untyped]
# ── 상수 ─────────────────────────────────────────────────────────────
SSDP_GROUP = ("239.255.255.250", 1900)
DIAL_SEARCH_TARGET = "urn:dial-multiscreen-org:service:dial:1"
CHROME_ORIGIN = "package:Google-Chrome.145.Mac-OS-X"
# ── DIAL 헬퍼 ────────────────────────────────────────────────────────
def dial_discover(timeout: float = 4.0) -> list[dict[str, str]]:
"""SSDP M-SEARCH 로 DIAL 디바이스를 찾는다."""
request = "\r\n".join([
"M-SEARCH * HTTP/1.1",
f"HOST: {SSDP_GROUP[0]}:{SSDP_GROUP[1]}",
'MAN: "ssdp:discover"',
"MX: 2",
f"ST: {DIAL_SEARCH_TARGET}",
"", "",
]).encode()
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
sock.settimeout(0.5)
sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 2)
sock.sendto(request, SSDP_GROUP)
deadline = time.time() + timeout
seen = set()
results: list[dict[str, str]] = []
while time.time() < deadline:
try:
payload, _ = sock.recvfrom(65535)
except socket.timeout:
continue
text = payload.decode("utf-8", "replace")
headers: dict[str, str] = {}
for line in text.splitlines()[1:]:
if ":" in line:
k, v = line.split(":", 1)
headers[k.strip().lower()] = v.strip()
loc = headers.get("location")
if loc and loc not in seen:
seen.add(loc)
results.append(headers)
sock.close()
return results
def dial_get_application_url(location: str, timeout: float = 5.0) -> str:
"""DIAL device description 에서 Application-URL 을 가져온다."""
req = urllib.request.Request(location)
with urllib.request.urlopen(req, timeout=timeout) as resp:
app_url = resp.headers.get("Application-URL") or resp.headers.get("application-url")
if not app_url:
raise RuntimeError(f"No Application-URL in {location}")
return app_url.rstrip("/")
def dial_find_lg_tv(timeout: float = 4.0) -> str:
"""네트워크에서 LG TV 를 찾아 Application-URL 을 반환한다."""
devices = dial_discover(timeout)
for dev in devices:
loc = dev.get("location", "")
try:
app_url = dial_get_application_url(loc)
return app_url
except Exception:
continue
raise RuntimeError("DIAL 디바이스를 찾지 못했습니다")
def dial_youtube_state(app_url: str, timeout: float = 5.0) -> str | None:
"""DIAL GET /apps/YouTube → state (running/stopped) 또는 None(403 등)."""
url = f"{app_url}/YouTube"
req = urllib.request.Request(url)
req.add_header("Origin", CHROME_ORIGIN)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
body = resp.read()
root = ET.fromstring(body)
for elem in root.iter():
if elem.tag.endswith("state") and elem.text:
return elem.text.strip()
except Exception:
pass
return None
def dial_launch_youtube(app_url: str, timeout: float = 10.0) -> str:
"""DIAL POST /apps/YouTube → webOS YouTube 앱 실행. pairing code 를 반환.
이미 running 이면 launch 를 건너뛴다."""
state = dial_youtube_state(app_url, timeout=timeout)
if state == "running":
print(f" YouTube 앱 이미 실행 중 (state={state}), launch 건너뜀")
return ""
url = f"{app_url}/YouTube"
pairing_code = str(uuid.uuid4())
body = urllib.parse.urlencode({
"pairingCode": pairing_code,
"theme": "cl",
}).encode()
req = urllib.request.Request(url, data=body, method="POST")
req.add_header("Origin", CHROME_ORIGIN)
req.add_header("Content-Type", "text/plain")
with urllib.request.urlopen(req, timeout=timeout) as resp:
status = resp.status
print(f" DIAL POST /apps/YouTube → {status}")
return pairing_code
def dial_get_screen_id(app_url: str, timeout: float = 5.0) -> str:
"""DIAL GET /apps/YouTube → additionalData/screenId 추출."""
url = f"{app_url}/YouTube"
req = urllib.request.Request(url)
req.add_header("Origin", CHROME_ORIGIN)
with urllib.request.urlopen(req, timeout=timeout) as resp:
body = resp.read()
root = ET.fromstring(body)
# additionalData 안의 screenId 찾기
ns = "urn:dial-multiscreen-org:schemas:dial"
ad = root.find(f"{{{ns}}}additionalData")
if ad is None:
# namespace 없이 재시도
for elem in root.iter():
if elem.tag.endswith("screenId") and elem.text:
return elem.text.strip()
raise RuntimeError("screenId 를 찾지 못했습니다")
sid_elem = ad.find(f"{{{ns}}}screenId")
if sid_elem is None:
# namespace 없이 찾기
sid_elem = ad.find("screenId")
if sid_elem is None or not sid_elem.text:
raise RuntimeError(f"screenId 를 찾지 못했습니다. XML:\n{body.decode()}")
return sid_elem.text.strip()
# ── 메인 ─────────────────────────────────────────────────────────────
def main() -> int:
parser = argparse.ArgumentParser(description="DIAL + Lounge API 로 LG TV YouTube 재생")
parser.add_argument("--video", default="dQw4w9WgXcQ",
help="재생할 YouTube video ID (기본: dQw4w9WgXcQ)")
parser.add_argument("--screen-id", default=None,
help="screenId 직접 지정 (DIAL 단계 건너뜀)")
parser.add_argument("--app-url", default=None,
help="DIAL Application-URL 직접 지정")
args = parser.parse_args()
screen_id = args.screen_id
if not screen_id:
# Step 1: DIAL 디스커버리
print("[1] DIAL 디스커버리...")
app_url = args.app_url or dial_find_lg_tv()
print(f" Application-URL: {app_url}")
# Step 2: YouTube 앱 실행
print("[2] DIAL 로 YouTube 앱 실행...")
dial_launch_youtube(app_url)
time.sleep(2)
# Step 3: screenId 획득
print("[3] DIAL GET /apps/YouTube → screenId 획득...")
screen_id = dial_get_screen_id(app_url)
print(f" screenId: {screen_id}")
else:
print(f"[1-3] screenId 직접 지정: {screen_id}")
# Step 4: casttube Lounge API 세션
print("[4] casttube YouTubeSession 생성 (Lounge API 세션)...")
session = YouTubeSession(screen_id)
# play_video 내부에서:
# _get_lounge_id() → POST get_lounge_token_batch → loungeToken
# _bind() → POST bc/bind → SID, gsessionid
# _initialize_queue → POST bc/bind (setPlaylist)
# Step 5: 동영상 재생
print(f"[5] play_video('{args.video}')...")
session.play_video(args.video)
print(f" ✓ YouTube 앱에서 재생 시작됨")
return 0
if __name__ == "__main__":
sys.exit(main())
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment