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
|
- Channels are polled every 10 minutes by default
|
||||||
- On `follow`, existing videos are recorded without announcing (prevents flood)
|
- 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`
|
- New videos are announced as `[name] Video Title | 18:25 1.5Mv 45klk 2026-01-15 -- URL`
|
||||||
- Metadata suffix includes views, likes, and published date when available
|
- 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`
|
- Maximum 5 videos announced per poll; excess shown as `... and N more`
|
||||||
- Titles are truncated to 80 characters
|
- Titles are truncated to 80 characters
|
||||||
- Supports HTTP conditional requests (`ETag`, `If-Modified-Since`)
|
- Supports HTTP conditional requests (`ETag`, `If-Modified-Since`)
|
||||||
|
|||||||
@@ -137,6 +137,47 @@ def _resolve_via_innertube(video_id: str) -> str | None:
|
|||||||
return 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:
|
def _resolve_channel(url: str) -> str | None:
|
||||||
"""Fetch YouTube page HTML and extract channel ID. Blocking.
|
"""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"]
|
channel = data["channel"]
|
||||||
name = data["name"]
|
name = data["name"]
|
||||||
shown = new_items[:_MAX_ANNOUNCE]
|
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:
|
for item in shown:
|
||||||
title = _truncate(item["title"]) if item["title"] else "(no title)"
|
title = _truncate(item["title"]) if item["title"] else "(no title)"
|
||||||
link = item["link"]
|
link = item["link"]
|
||||||
# Build metadata suffix
|
# Build metadata suffix
|
||||||
parts = []
|
parts = []
|
||||||
|
dur = durations.get(item["id"], 0)
|
||||||
|
dur_str = _format_duration(dur)
|
||||||
|
if dur_str:
|
||||||
|
parts.append(dur_str)
|
||||||
views = item.get("views", 0)
|
views = item.get("views", 0)
|
||||||
likes = item.get("likes", 0)
|
likes = item.get("likes", 0)
|
||||||
if views:
|
if views:
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ from plugins.youtube import ( # noqa: E402
|
|||||||
_derive_name,
|
_derive_name,
|
||||||
_errors,
|
_errors,
|
||||||
_extract_channel_id,
|
_extract_channel_id,
|
||||||
|
_format_duration,
|
||||||
_is_youtube_url,
|
_is_youtube_url,
|
||||||
_load,
|
_load,
|
||||||
_parse_feed,
|
_parse_feed,
|
||||||
@@ -827,18 +828,26 @@ class TestCmdYtCheck:
|
|||||||
}
|
}
|
||||||
_save(bot, "#test:news", data)
|
_save(bot, "#test:news", data)
|
||||||
|
|
||||||
|
def fake_duration(video_id):
|
||||||
|
return {"def456": 1105, "ghi789": 62}.get(video_id, 0)
|
||||||
|
|
||||||
async def inner():
|
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"))
|
await cmd_yt(bot, _msg("!yt check news"))
|
||||||
announcements = [s for t, s in bot.sent if t == "#test"]
|
announcements = [s for t, s in bot.sent if t == "#test"]
|
||||||
assert len(announcements) == 2
|
assert len(announcements) == 2
|
||||||
assert "[news]" in announcements[0]
|
assert "[news]" in announcements[0]
|
||||||
assert "Calculus" in announcements[0]
|
assert "Calculus" in announcements[0]
|
||||||
# Verify metadata suffix (views, likes, date)
|
# Verify metadata suffix (duration, views, likes, date)
|
||||||
assert "| " in announcements[0]
|
assert "18:25" in announcements[0]
|
||||||
assert "820kv" in announcements[0]
|
assert "820kv" in announcements[0]
|
||||||
assert "32klk" in announcements[0]
|
assert "32klk" in announcements[0]
|
||||||
assert "2026-02-01" 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())
|
asyncio.run(inner())
|
||||||
|
|
||||||
@@ -930,7 +939,10 @@ class TestPollOnce:
|
|||||||
_channels[key] = data
|
_channels[key] = data
|
||||||
|
|
||||||
async def inner():
|
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)
|
await _poll_once(bot, key, announce=True)
|
||||||
messages = [s for t, s in bot.sent if t == "#test"]
|
messages = [s for t, s in bot.sent if t == "#test"]
|
||||||
# 5 individual + 1 "... and N more"
|
# 5 individual + 1 "... and N more"
|
||||||
@@ -1177,3 +1189,33 @@ class TestCompactNum:
|
|||||||
|
|
||||||
def test_fractional_m(self):
|
def test_fractional_m(self):
|
||||||
assert _compact_num(2_500_000) == "2.5M"
|
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