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:
user
2026-02-19 18:28:40 +01:00
parent 2d00360bc3
commit 8ce6922cc3
3 changed files with 111 additions and 6 deletions

View File

@@ -609,8 +609,9 @@ Polling and announcements:
- Channels are polled every 10 minutes by default
- On `follow`, existing videos are recorded without announcing (prevents flood)
- New videos are announced as `[name] Video Title | 1.5Mv 45klk 2026-01-15 -- URL`
- Metadata suffix includes views, likes, and published date when available
- New videos are announced as `[name] Video Title | 18:25 1.5Mv 45klk 2026-01-15 -- URL`
- Metadata suffix includes duration, views, likes, and published date when available
- Duration fetched via InnerTube API per new video (only at announcement time)
- Maximum 5 videos announced per poll; excess shown as `... and N more`
- Titles are truncated to 80 characters
- Supports HTTP conditional requests (`ETag`, `If-Modified-Since`)

View File

@@ -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:

View File

@@ -24,6 +24,7 @@ from plugins.youtube import ( # noqa: E402
_derive_name,
_errors,
_extract_channel_id,
_format_duration,
_is_youtube_url,
_load,
_parse_feed,
@@ -827,18 +828,26 @@ class TestCmdYtCheck:
}
_save(bot, "#test:news", data)
def fake_duration(video_id):
return {"def456": 1105, "ghi789": 62}.get(video_id, 0)
async def inner():
with patch.object(_mod, "_fetch_feed", _fake_fetch_ok):
with (
patch.object(_mod, "_fetch_feed", _fake_fetch_ok),
patch.object(_mod, "_fetch_duration", fake_duration),
):
await cmd_yt(bot, _msg("!yt check news"))
announcements = [s for t, s in bot.sent if t == "#test"]
assert len(announcements) == 2
assert "[news]" in announcements[0]
assert "Calculus" in announcements[0]
# Verify metadata suffix (views, likes, date)
assert "| " in announcements[0]
# Verify metadata suffix (duration, views, likes, date)
assert "18:25" in announcements[0]
assert "820kv" in announcements[0]
assert "32klk" in announcements[0]
assert "2026-02-01" in announcements[0]
# Second announcement has 1:02 duration
assert "1:02" in announcements[1]
asyncio.run(inner())
@@ -930,7 +939,10 @@ class TestPollOnce:
_channels[key] = data
async def inner():
with patch.object(_mod, "_fetch_feed", fake_big):
with (
patch.object(_mod, "_fetch_feed", fake_big),
patch.object(_mod, "_fetch_duration", lambda vid: 0),
):
await _poll_once(bot, key, announce=True)
messages = [s for t, s in bot.sent if t == "#test"]
# 5 individual + 1 "... and N more"
@@ -1177,3 +1189,33 @@ class TestCompactNum:
def test_fractional_m(self):
assert _compact_num(2_500_000) == "2.5M"
# ---------------------------------------------------------------------------
# TestFormatDuration
# ---------------------------------------------------------------------------
class TestFormatDuration:
def test_zero(self):
assert _format_duration(0) == ""
def test_negative(self):
assert _format_duration(-1) == ""
def test_seconds_only(self):
assert _format_duration(45) == "0:45"
def test_minutes_and_seconds(self):
assert _format_duration(125) == "2:05"
def test_exact_minutes(self):
assert _format_duration(600) == "10:00"
def test_hours(self):
assert _format_duration(3661) == "1:01:01"
def test_large_hours(self):
assert _format_duration(36000) == "10:00:00"
def test_one_second(self):
assert _format_duration(1) == "0:01"