diff --git a/docs/USAGE.md b/docs/USAGE.md index a936b08..2656029 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -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`) diff --git a/plugins/youtube.py b/plugins/youtube.py index 05c627e..a60a914 100644 --- a/plugins/youtube.py +++ b/plugins/youtube.py @@ -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: diff --git a/tests/test_youtube.py b/tests/test_youtube.py index 42193a0..fd3c0c5 100644 --- a/tests/test_youtube.py +++ b/tests/test_youtube.py @@ -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"