Created
March 29, 2026 03:37
-
-
Save ruseel/127992e51eb95b12c31a87630f7fd68b to your computer and use it in GitHub Desktop.
lge_standbyme_youtube_launch.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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