fix: switch youtube innertube to ANDROID client (WEB blocked)
YouTube's InnerTube /player endpoint now returns LOGIN_REQUIRED for the WEB client. Switch to ANDROID client context which still returns full videoDetails. Fixes missing duration in announcements and broken channel resolution from video URLs. Extract shared _innertube_player() helper to deduplicate payload construction between _resolve_via_innertube and _fetch_duration.
This commit is contained in:
+26
-30
@@ -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_DOMAINS = {"youtube.com", "www.youtube.com", "m.youtube.com", "youtu.be"}
|
||||||
_YT_FEED_URL = "https://www.youtube.com/feeds/videos.xml?channel_id={}"
|
_YT_FEED_URL = "https://www.youtube.com/feeds/videos.xml?channel_id={}"
|
||||||
_YT_PLAYER_URL = "https://www.youtube.com/youtubei/v1/player"
|
_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}"
|
_ATOM_NS = "{http://www.w3.org/2005/Atom}"
|
||||||
_YT_NS = "{http://www.youtube.com/xml/schemas/2015}"
|
_YT_NS = "{http://www.youtube.com/xml/schemas/2015}"
|
||||||
_MEDIA_NS = "{http://search.yahoo.com/mrss/}"
|
_MEDIA_NS = "{http://search.yahoo.com/mrss/}"
|
||||||
@@ -107,33 +109,41 @@ def _extract_video_id(url: str) -> str | None:
|
|||||||
|
|
||||||
# -- Blocking helpers (for executor) -----------------------------------------
|
# -- Blocking helpers (for executor) -----------------------------------------
|
||||||
|
|
||||||
def _resolve_via_innertube(video_id: str) -> str | None:
|
def _innertube_player(video_id: str) -> dict:
|
||||||
"""Resolve video ID to channel ID via InnerTube player API. Blocking.
|
"""Fetch videoDetails via InnerTube ANDROID client. Blocking.
|
||||||
|
|
||||||
Small JSON request/response -- much more resilient to transient proxy
|
Uses ANDROID client -- WEB client returns LOGIN_REQUIRED since ~2026-02.
|
||||||
issues than fetching the full 1MB watch page.
|
Returns videoDetails dict, or {} on failure.
|
||||||
"""
|
"""
|
||||||
payload = json.dumps({
|
payload = json.dumps({
|
||||||
"context": {
|
"context": {
|
||||||
"client": {
|
"client": {
|
||||||
"clientName": "WEB",
|
"clientName": "ANDROID",
|
||||||
"clientVersion": _YT_CLIENT_VERSION,
|
"clientVersion": _ANDROID_VERSION,
|
||||||
|
"androidSdkVersion": _ANDROID_SDK,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"videoId": video_id,
|
"videoId": video_id,
|
||||||
}).encode()
|
}).encode()
|
||||||
req = urllib.request.Request(_YT_PLAYER_URL, data=payload, method="POST")
|
req = urllib.request.Request(_YT_PLAYER_URL, data=payload, method="POST")
|
||||||
req.add_header("Content-Type", "application/json")
|
req.add_header("Content-Type", "application/json")
|
||||||
|
req.add_header("User-Agent", _ANDROID_UA)
|
||||||
try:
|
try:
|
||||||
resp = _urlopen(req, timeout=_FETCH_TIMEOUT)
|
resp = _urlopen(req, timeout=_FETCH_TIMEOUT)
|
||||||
raw = resp.read()
|
raw = resp.read()
|
||||||
resp.close()
|
resp.close()
|
||||||
data = json.loads(raw)
|
data = json.loads(raw)
|
||||||
channel_id = (data.get("videoDetails") or {}).get("channelId", "")
|
return data.get("videoDetails") or {}
|
||||||
|
except Exception:
|
||||||
|
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):
|
if channel_id and _CHANNEL_ID_RE.fullmatch(channel_id):
|
||||||
return channel_id
|
return channel_id
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -142,28 +152,14 @@ def _fetch_duration(video_id: str) -> int:
|
|||||||
|
|
||||||
Returns 0 on failure or for live content.
|
Returns 0 on failure or for live content.
|
||||||
"""
|
"""
|
||||||
payload = json.dumps({
|
details = _innertube_player(video_id)
|
||||||
"context": {
|
if not details:
|
||||||
"client": {
|
return 0
|
||||||
"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")
|
|
||||||
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"):
|
if details.get("isLiveContent") and details.get("isLive"):
|
||||||
return 0
|
return 0
|
||||||
secs = int(details.get("lengthSeconds", 0))
|
try:
|
||||||
return secs
|
return int(details.get("lengthSeconds", 0))
|
||||||
except Exception:
|
except (ValueError, TypeError):
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user