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.
171 lines
6.1 KiB
Python
171 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 app.core.http_client import get_http_session
|
|
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:
|
|
session = await get_http_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:
|
|
session = await get_http_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
|