Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions bot/cogs/youtube_watcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
import discord
from discord import app_commands
from discord.ext import commands, tasks
from googleapiclient.errors import HttpError

from utils.state import load_state, save_state
from utils.youtube_api import YouTubeClient
Expand Down Expand Up @@ -221,6 +222,13 @@ async def youtubedigest(self, interaction: discord.Interaction) -> None:
f"✅ YouTube digest posted to <#{self.discord_channel_id}> ({len(videos)} video(s)).",
ephemeral=True,
)
except HttpError as exc:
logger.error("YouTube API error during manual digest for %s: %s", interaction.user, exc)
await interaction.followup.send(
f"❌ YouTube API error (status {exc.status_code}): {exc.reason}\n"
"This is usually a quota issue or invalid API key — check the logs and your Google Cloud console.",
ephemeral=True,
)
except Exception:
logger.exception("Manual YouTube digest failed for %s.", interaction.user)
await interaction.followup.send(
Expand Down
28 changes: 28 additions & 0 deletions tests/test_youtube_api.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import unittest
from datetime import datetime, timezone
from unittest.mock import patch
from unittest.mock import MagicMock

from googleapiclient.errors import HttpError

from utils.youtube_api import YouTubeClient

Expand Down Expand Up @@ -48,6 +51,31 @@ def videos(self):


class YouTubeApiTests(unittest.TestCase):
def test_search_recent_propagates_http_error(self) -> None:
"""HttpError from the API must propagate so callers can distinguish
a genuine empty result from an API failure (e.g. quota exceeded)."""
fake_resp = MagicMock()
fake_resp.status = 403
fake_resp.reason = "quotaExceeded"
error = HttpError(resp=fake_resp, content=b'{"error":{"message":"quotaExceeded"}}')

class _ErrorSearchResource:
def list(self, **kwargs):
raise error

class _ErrorService:
def search(self):
return _ErrorSearchResource()

client = YouTubeClient.__new__(YouTubeClient)
client._service = _ErrorService()

with self.assertRaises(HttpError):
client.search_recent(
channel_id="channel-id",
published_after=datetime(2026, 5, 1, tzinfo=timezone.utc),
)

def test_search_recent_requires_timezone_aware_datetime(self) -> None:
client = YouTubeClient.__new__(YouTubeClient)
client._service = _FakeService()
Expand Down
33 changes: 13 additions & 20 deletions utils/youtube_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,34 +78,27 @@ def search_recent(
- ``thumbnail`` – URL of the high-quality thumbnail image
- ``view_count`` – 0 (placeholder; populate with :meth:`get_video_statistics`)

Returns an empty list when the API call fails.
Raises :class:`googleapiclient.errors.HttpError` if the API call fails
so callers can distinguish a genuine empty result from an API error.
"""
if published_after.tzinfo is None:
raise ValueError(
"published_after must be a timezone-aware datetime (e.g. use timezone.utc)"
)

published_after_str = published_after.strftime("%Y-%m-%dT%H:%M:%SZ")
try:
response = (
self._service.search()
.list(
part="snippet",
channelId=channel_id,
order="date",
type="video",
publishedAfter=published_after_str,
maxResults=max_results,
)
.execute()
response = (
self._service.search()
.list(
part="snippet",
channelId=channel_id,
order="date",
type="video",
publishedAfter=published_after_str,
maxResults=max_results,
)
except HttpError as exc:
logger.error(
"YouTube API search error (channel=%s): %s",
channel_id,
exc,
)
return []
.execute()
)

videos: List[dict] = []
for item in response.get("items", []):
Expand Down
Loading