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:
@@ -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`)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user