diff --git a/plugins/youtube.py b/plugins/youtube.py index a60a914..3ec8ca7 100644 --- a/plugins/youtube.py +++ b/plugins/youtube.py @@ -24,7 +24,9 @@ _VIDEO_ID_RE = re.compile(r"(?:v=|youtu\.be/|/embed/|/shorts/)([A-Za-z0-9_-]{11} _YT_DOMAINS = {"youtube.com", "www.youtube.com", "m.youtube.com", "youtu.be"} _YT_FEED_URL = "https://www.youtube.com/feeds/videos.xml?channel_id={}" _YT_PLAYER_URL = "https://www.youtube.com/youtubei/v1/player" -_YT_CLIENT_VERSION = "2.20250101.00.00" +_ANDROID_VERSION = "19.29.37" +_ANDROID_SDK = 33 +_ANDROID_UA = f"com.google.android.youtube/{_ANDROID_VERSION} (Linux; U; Android 13)" _ATOM_NS = "{http://www.w3.org/2005/Atom}" _YT_NS = "{http://www.youtube.com/xml/schemas/2015}" _MEDIA_NS = "{http://search.yahoo.com/mrss/}" @@ -107,33 +109,41 @@ def _extract_video_id(url: str) -> str | None: # -- Blocking helpers (for executor) ----------------------------------------- -def _resolve_via_innertube(video_id: str) -> str | None: - """Resolve video ID to channel ID via InnerTube player API. Blocking. +def _innertube_player(video_id: str) -> dict: + """Fetch videoDetails via InnerTube ANDROID client. Blocking. - Small JSON request/response -- much more resilient to transient proxy - issues than fetching the full 1MB watch page. + Uses ANDROID client -- WEB client returns LOGIN_REQUIRED since ~2026-02. + Returns videoDetails dict, or {} on failure. """ payload = json.dumps({ "context": { "client": { - "clientName": "WEB", - "clientVersion": _YT_CLIENT_VERSION, + "clientName": "ANDROID", + "clientVersion": _ANDROID_VERSION, + "androidSdkVersion": _ANDROID_SDK, }, }, "videoId": video_id, }).encode() req = urllib.request.Request(_YT_PLAYER_URL, data=payload, method="POST") req.add_header("Content-Type", "application/json") + req.add_header("User-Agent", _ANDROID_UA) try: resp = _urlopen(req, timeout=_FETCH_TIMEOUT) raw = resp.read() resp.close() data = json.loads(raw) - channel_id = (data.get("videoDetails") or {}).get("channelId", "") - if channel_id and _CHANNEL_ID_RE.fullmatch(channel_id): - return channel_id + return data.get("videoDetails") or {} except Exception: - pass + return {} + + +def _resolve_via_innertube(video_id: str) -> str | None: + """Resolve video ID to channel ID via InnerTube player API. Blocking.""" + details = _innertube_player(video_id) + channel_id = details.get("channelId", "") + if channel_id and _CHANNEL_ID_RE.fullmatch(channel_id): + return channel_id return None @@ -142,28 +152,14 @@ def _fetch_duration(video_id: str) -> int: Returns 0 on failure or for live content. """ - payload = json.dumps({ - "context": { - "client": { - "clientName": "WEB", - "clientVersion": _YT_CLIENT_VERSION, - }, - }, - "videoId": video_id, - }).encode() - req = urllib.request.Request(_YT_PLAYER_URL, data=payload, method="POST") - req.add_header("Content-Type", "application/json") + details = _innertube_player(video_id) + if not details: + return 0 + if details.get("isLiveContent") and details.get("isLive"): + return 0 try: - resp = _urlopen(req, timeout=_FETCH_TIMEOUT) - raw = resp.read() - resp.close() - data = json.loads(raw) - details = data.get("videoDetails") or {} - if details.get("isLiveContent") and details.get("isLive"): - return 0 - secs = int(details.get("lengthSeconds", 0)) - return secs - except Exception: + return int(details.get("lengthSeconds", 0)) + except (ValueError, TypeError): return 0