feat: add video duration to YouTube announcements
Fetches duration via InnerTube player API for new videos at announcement time. Displayed as compact h:mm:ss before views/likes. Gracefully omitted for Shorts and unavailable content. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -137,6 +137,47 @@ def _resolve_via_innertube(video_id: str) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def _fetch_duration(video_id: str) -> int:
|
||||
"""Fetch video duration in seconds via InnerTube player API. Blocking.
|
||||
|
||||
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")
|
||||
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 0
|
||||
|
||||
|
||||
def _format_duration(seconds: int) -> str:
|
||||
"""Format seconds as compact duration: 62 -> '1:02', 3661 -> '1:01:01'."""
|
||||
if seconds <= 0:
|
||||
return ""
|
||||
h, rem = divmod(seconds, 3600)
|
||||
m, s = divmod(rem, 60)
|
||||
if h:
|
||||
return f"{h}:{m:02d}:{s:02d}"
|
||||
return f"{m}:{s:02d}"
|
||||
|
||||
|
||||
def _resolve_channel(url: str) -> str | None:
|
||||
"""Fetch YouTube page HTML and extract channel ID. Blocking.
|
||||
|
||||
@@ -337,11 +378,32 @@ async def _poll_once(bot, key: str, announce: bool = True) -> None:
|
||||
channel = data["channel"]
|
||||
name = data["name"]
|
||||
shown = new_items[:_MAX_ANNOUNCE]
|
||||
# Fetch durations for announced videos concurrently
|
||||
durations: dict[str, int] = {}
|
||||
video_ids = []
|
||||
for item in shown:
|
||||
vid = item["id"].removeprefix("yt:video:")
|
||||
if vid != item["id"]:
|
||||
video_ids.append((item["id"], vid))
|
||||
if video_ids:
|
||||
futs = {
|
||||
item_id: loop.run_in_executor(None, _fetch_duration, vid)
|
||||
for item_id, vid in video_ids
|
||||
}
|
||||
for item_id, fut in futs.items():
|
||||
try:
|
||||
durations[item_id] = await fut
|
||||
except Exception:
|
||||
pass
|
||||
for item in shown:
|
||||
title = _truncate(item["title"]) if item["title"] else "(no title)"
|
||||
link = item["link"]
|
||||
# Build metadata suffix
|
||||
parts = []
|
||||
dur = durations.get(item["id"], 0)
|
||||
dur_str = _format_duration(dur)
|
||||
if dur_str:
|
||||
parts.append(dur_str)
|
||||
views = item.get("views", 0)
|
||||
likes = item.get("likes", 0)
|
||||
if views:
|
||||
|
||||
Reference in New Issue
Block a user