You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

169 lines
6.1 KiB
Python

"""
YouTube Data API v3 adapter - collect video mentions from whitelisted channels.
Gracefully skips if YOUTUBE_API_KEY is not configured.
"""
import logging
from datetime import datetime, timedelta, timezone
from typing import Dict, List, Optional
import aiohttp
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.core.config import settings
from app.models.overlay_raw_event import OverlayVideoEvent
from app.models.overlay_registry import YouTubeChannelRegistry
from app.services.overlay.entity_resolver import EntityResolver
logger = logging.getLogger(__name__)
YOUTUBE_SEARCH_URL = "https://www.googleapis.com/youtube/v3/search"
YOUTUBE_VIDEOS_URL = "https://www.googleapis.com/youtube/v3/videos"
class YouTubeAdapter:
"""Collect YouTube video data from whitelisted channels."""
def __init__(self):
self.api_key: str = getattr(settings, "YOUTUBE_API_KEY", "")
self.resolver = EntityResolver()
@property
def enabled(self) -> bool:
return bool(self.api_key)
async def _get_channels(self, db: AsyncSession) -> List[YouTubeChannelRegistry]:
result = await db.execute(
select(YouTubeChannelRegistry).where(YouTubeChannelRegistry.active == True)
)
return result.scalars().all()
async def _search_channel_videos(
self, channel_id: str, published_after: datetime
) -> List[dict]:
"""Search for recent videos from a channel via YouTube Data API."""
params = {
"part": "snippet",
"channelId": channel_id,
"type": "video",
"order": "date",
"publishedAfter": published_after.strftime("%Y-%m-%dT%H:%M:%SZ"),
"maxResults": 20,
"key": self.api_key,
}
try:
async with aiohttp.ClientSession() as session:
async with session.get(
YOUTUBE_SEARCH_URL, params=params, timeout=aiohttp.ClientTimeout(total=30)
) as resp:
if resp.status == 403:
logger.warning("YouTube API: quota exceeded or invalid key")
return []
resp.raise_for_status()
data = await resp.json()
return data.get("items", [])
except Exception as e:
logger.error(f"YouTube search error for channel {channel_id}: {e}")
return []
async def _get_video_stats(self, video_ids: List[str]) -> Dict[str, Dict]:
"""Fetch view/comment counts for a list of video IDs."""
if not video_ids:
return {}
params = {
"part": "statistics",
"id": ",".join(video_ids),
"key": self.api_key,
}
try:
async with aiohttp.ClientSession() as session:
async with session.get(
YOUTUBE_VIDEOS_URL, params=params, timeout=aiohttp.ClientTimeout(total=30)
) as resp:
resp.raise_for_status()
data = await resp.json()
stats = {}
for item in data.get("items", []):
vid_id = item["id"]
s = item.get("statistics", {})
stats[vid_id] = {
"view_count": int(s.get("viewCount", 0)),
"comment_count": int(s.get("commentCount", 0)),
}
return stats
except Exception as e:
logger.error(f"YouTube stats fetch error: {e}")
return {}
async def collect(self, db: AsyncSession, days_back: int = 2) -> int:
"""
Collect YouTube videos from whitelisted channels.
Gracefully returns 0 if API key is not configured.
Returns number of new records inserted.
"""
if not self.enabled:
logger.info("YouTube adapter: API key not configured, skipping")
return 0
await self.resolver.load_aliases(db)
channels = await self._get_channels(db)
if not channels:
logger.info("YouTube: no active channels in registry")
return 0
published_after = datetime.now(timezone.utc) - timedelta(days=days_back)
inserted = 0
for channel in channels:
items = await self._search_channel_videos(channel.channel_id, published_after)
video_ids = [
item["id"]["videoId"]
for item in items
if isinstance(item.get("id"), dict) and "videoId" in item["id"]
]
stats = await self._get_video_stats(video_ids)
for item in items:
vid_id = item.get("id", {}).get("videoId") if isinstance(item.get("id"), dict) else None
if not vid_id:
continue
# Check duplicate
existing = await db.execute(
select(OverlayVideoEvent.id).where(OverlayVideoEvent.video_id == vid_id)
)
if existing.first():
continue
snippet = item.get("snippet", {})
title = snippet.get("title", "")
pub_at_str = snippet.get("publishedAt", "")
try:
pub_at = datetime.fromisoformat(pub_at_str.replace("Z", "+00:00"))
except Exception:
pub_at = datetime.now(timezone.utc)
stat = stats.get(vid_id, {})
matched = self.resolver.resolve_from_title(title)
event = OverlayVideoEvent(
video_id=vid_id,
channel_id=channel.channel_id,
title=title,
view_count=stat.get("view_count", 0),
comment_count=stat.get("comment_count", 0),
published_at=pub_at,
matched_symbols=matched,
channel_weight=channel.channel_weight,
)
db.add(event)
inserted += 1
if inserted:
await db.commit()
logger.info(f"YouTube: inserted {inserted} video events")
return inserted