""" analytics.py - Track video performance and optimize content strategy. Uses YouTube Analytics API to fetch: - Per-video metrics (views, likes, comments, retention) - Channel-level metrics (subscribers, watch time) - Content insights (best performing types, optimal times) Data is stored in StateManager and used for content strategy optimization. """ import json import os import threading import time from datetime import datetime, timedelta from typing import Any, Dict, List, Optional class Analytics: """YouTube Analytics and content strategy optimizer for AutoDub HerStory.""" METRICS_FETCH_INTERVAL = 6 * 3600 # 6 hours between metric fetches def __init__(self, state=None, brain=None): self.state = state self.brain = brain self._lock = threading.Lock() # ================================================================ # YouTube Analytics API # ================================================================ def fetch_video_metrics(self, video_id: str) -> Dict[str, Any]: """Fetch YouTube Analytics for a specific video. Uses YouTube Analytics API (requires OAuth tokens). Falls back to YouTube Data API for basic stats if Analytics unavailable. """ tokens = self._get_youtube_tokens() if not tokens: return {"error": "YouTube not authenticated", "video_id": video_id} # Try YouTube Analytics API first metrics = self._fetch_analytics_api(tokens, video_id) if metrics and "error" not in metrics: return metrics # Fallback: YouTube Data API for basic stats return self._fetch_data_api_stats(tokens, video_id) def fetch_channel_metrics(self) -> Dict[str, Any]: """Fetch channel-level analytics from YouTube.""" tokens = self._get_youtube_tokens() if not tokens: return {"error": "YouTube not authenticated"} try: from google.oauth2.credentials import Credentials from googleapiclient.discovery import build creds = Credentials.from_authorized_user_info(tokens, [ "https://www.googleapis.com/auth/youtube.readonly", ]) yt = build("youtube", "v3", credentials=creds) # Get channel stats channels = yt.channels().list( mine=True, part="statistics,snippet" ).execute() if not channels.get("items"): return {"error": "No channel found"} channel = channels["items"][0] stats = channel.get("statistics", {}) snippet = channel.get("snippet", {}) result = { "channel_title": snippet.get("title", ""), "total_views": int(stats.get("viewCount", 0)), "subscribers": int(stats.get("subscriberCount", 0)), "total_videos": int(stats.get("videoCount", 0)), "fetched_at": datetime.now().isoformat(), } # Store in state self._save_channel_analytics(result) return result except Exception as e: return {"error": f"YouTube API error: {e}"} def update_video_performance(self, video_id: str) -> Dict[str, Any]: """Fetch and store metrics for a processed video. Returns the metrics dict. """ metrics = self.fetch_video_metrics(video_id) if "error" not in metrics: self._save_video_analytics(video_id, metrics) return metrics # ================================================================ # Insights & Analysis # ================================================================ def get_top_performing(self, n: int = 10) -> List[Dict[str, Any]]: """Get top N videos by views/engagement.""" if not self.state or not hasattr(self.state, "_state"): return [] analytics = self.state._state.get("video_analytics", {}) if not analytics: return [] # Sort by views sorted_videos = sorted( analytics.items(), key=lambda x: x[1].get("views", 0), reverse=True, ) return [ { "video_id": vid_id, "views": data.get("views", 0), "likes": data.get("likes", 0), "avg_view_percentage": data.get("avg_view_percentage", 0), "es_title": data.get("es_title", ""), "published_at": data.get("published_at", ""), } for vid_id, data in sorted_videos[:n] ] def get_performance_trend(self, days: int = 30) -> Dict[str, Any]: """Get performance trend over time. Returns daily view counts, upload frequency, and avg performance. """ if not self.state or not hasattr(self.state, "_state"): return {"error": "No state available"} analytics = self.state._state.get("video_analytics", {}) processed = self.state._state.get("processed_videos", {}) # Group by date daily_views = {} daily_uploads = {} for vid_id, data in analytics.items(): fetched_at = data.get("fetched_at", "")[:10] if fetched_at: daily_views[fetched_at] = daily_views.get(fetched_at, 0) + data.get("views", 0) for vid_id, info in processed.items(): date = info.get("date_processed", "")[:10] if date: daily_uploads[date] = daily_uploads.get(date, 0) + 1 # Calculate totals total_views = sum(daily_views.values()) total_uploads = sum(daily_uploads.values()) avg_daily_views = total_views / max(days, 1) return { "period_days": days, "total_views": total_views, "total_uploads": total_uploads, "avg_daily_views": round(avg_daily_views, 1), "daily_views": daily_views, "daily_uploads": daily_uploads, } def get_content_insights(self) -> Dict[str, Any]: """Analyze what content performs best and provide recommendations. Uses title keywords and engagement metrics to identify patterns. """ if not self.state or not hasattr(self.state, "_state"): return {"error": "No state available"} analytics = self.state._state.get("video_analytics", {}) processed = self.state._state.get("processed_videos", {}) if not analytics: return { "top_keywords": [], "recommendations": ["Not enough data yet. Process more videos to get insights."], "avg_views": 0, } # Analyze by title keywords keyword_performance = {} total_views = 0 video_count = 0 for vid_id, data in analytics.items(): views = data.get("views", 0) title = data.get("es_title", "").lower() total_views += views video_count += 1 # Extract significant words (>4 chars, not common words) common_words = {"historia", "español", "short", "shorts", "qué", "como", "pero", "para", "este", "esta", "esto"} words = [w for w in title.split() if len(w) > 4 and w not in common_words] for word in words: if word not in keyword_performance: keyword_performance[word] = {"views": 0, "count": 0} keyword_performance[word]["views"] += views keyword_performance[word]["count"] += 1 # Calculate avg views per keyword keyword_avg = {} for kw, data in keyword_performance.items(): keyword_avg[kw] = round(data["views"] / data["count"], 1) if data["count"] > 0 else 0 # Top keywords by average views top_keywords = sorted(keyword_avg.items(), key=lambda x: x[1], reverse=True)[:10] # Generate recommendations recommendations = [] avg_views = total_views / max(video_count, 1) if top_keywords: best_kw = top_keywords[0][0] recommendations.append(f"Content about '{best_kw}' performs well (avg {top_keywords[0][1]} views)") if avg_views > 0: recommendations.append(f"Average views per video: {avg_views:.0f}") if video_count < 10: recommendations.append("Need more videos for reliable insights (currently {})".format(video_count)) # Optimal upload timing upload_by_hour = {} for vid_id, info in processed.items(): date = info.get("date_processed", "") try: dt = datetime.fromisoformat(date) hour = dt.hour upload_by_hour[hour] = upload_by_hour.get(hour, 0) + 1 except (ValueError, TypeError): pass best_hour = max(upload_by_hour, key=upload_by_hour.get) if upload_by_hour else None if best_hour is not None: recommendations.append(f"Most videos processed around {best_hour}:00 UTC") return { "top_keywords": [{"keyword": kw, "avg_views": avg} for kw, avg in top_keywords], "recommendations": recommendations, "avg_views": round(avg_views, 1), "total_videos_analyzed": video_count, "best_upload_hour": best_hour, } def should_fetch_metrics(self) -> bool: """Check if it's time to update metrics (every 6 hours).""" if not self.state or not hasattr(self.state, "_state"): return True channel = self.state._state.get("channel_analytics", {}) last_updated = channel.get("last_updated", "") if not last_updated: return True try: last_dt = datetime.fromisoformat(last_updated) return (datetime.now() - last_dt).total_seconds() > self.METRICS_FETCH_INTERVAL except (ValueError, TypeError): return True def get_analytics_summary(self) -> Dict[str, Any]: """Quick summary for dashboard.""" if not self.state or not hasattr(self.state, "_state"): return {"error": "No state available"} analytics = self.state._state.get("video_analytics", {}) channel = self.state._state.get("channel_analytics", {}) total_views = sum(d.get("views", 0) for d in analytics.values()) total_likes = sum(d.get("likes", 0) for d in analytics.values()) return { "videos_tracked": len(analytics), "total_views": total_views, "total_likes": total_likes, "subscribers": channel.get("subscribers", 0), "channel_views": channel.get("total_views", 0), "last_updated": channel.get("last_updated", "never"), } def estimate_reach(self, video_id: str) -> Dict[str, Any]: """Estimate video reach based on historical data.""" if not self.state or not hasattr(self.state, "_state"): return {"estimated_views": 0, "confidence": "low"} analytics = self.state._state.get("video_analytics", {}) if not analytics: return {"estimated_views": 0, "confidence": "low", "reason": "No historical data"} # Calculate average performance views_list = [d.get("views", 0) for d in analytics.values() if d.get("views", 0) > 0] if not views_list: return {"estimated_views": 0, "confidence": "low"} avg_views = sum(views_list) / len(views_list) median_views = sorted(views_list)[len(views_list) // 2] confidence = "medium" if len(views_list) >= 5 else "low" if len(views_list) >= 20: confidence = "high" return { "estimated_views": round(median_views), "average_views": round(avg_views), "confidence": confidence, "based_on_videos": len(views_list), } # ================================================================ # Internal Methods # ================================================================ def _get_youtube_tokens(self) -> Optional[Dict]: """Get YouTube OAuth tokens from state.""" if self.state: return self.state.get_youtube_tokens() return None def _fetch_analytics_api(self, tokens: Dict, video_id: str) -> Dict[str, Any]: """Fetch metrics from YouTube Analytics API.""" try: from google.oauth2.credentials import Credentials from googleapiclient.discovery import build creds = Credentials.from_authorized_user_info(tokens, [ "https://www.googleapis.com/auth/yt-analytics.readonly", ]) yt_analytics = build("youtubeAnalytics", "v2", credentials=creds) # Get video metrics for last 30 days end_date = datetime.now().strftime("%Y-%m-%d") start_date = (datetime.now() - timedelta(days=30)).strftime("%Y-%m-%d") response = yt_analytics.reports().query( ids="channel==MINE", startDate=start_date, endDate=end_date, metrics="views,likes,comments,averageViewDuration,averageViewPercentage", filters=f"video=={video_id}", ).execute() rows = response.get("rows", []) if rows: row = rows[0] return { "video_id": video_id, "views": int(row[0]) if len(row) > 0 else 0, "likes": int(row[1]) if len(row) > 1 else 0, "comments": int(row[2]) if len(row) > 2 else 0, "avg_view_duration": float(row[3]) if len(row) > 3 else 0, "avg_view_percentage": float(row[4]) if len(row) > 4 else 0, "fetched_at": datetime.now().isoformat(), "source": "analytics_api", } return {"error": "No data returned", "video_id": video_id} except Exception as e: return {"error": f"Analytics API error: {e}", "video_id": video_id} def _fetch_data_api_stats(self, tokens: Dict, video_id: str) -> Dict[str, Any]: """Fallback: fetch basic stats from YouTube Data API.""" try: from google.oauth2.credentials import Credentials from googleapiclient.discovery import build creds = Credentials.from_authorized_user_info(tokens, [ "https://www.googleapis.com/auth/youtube.readonly", ]) yt = build("youtube", "v3", credentials=creds) response = yt.videos().list( id=video_id, part="statistics,snippet" ).execute() items = response.get("items", []) if not items: return {"error": "Video not found", "video_id": video_id} video = items[0] stats = video.get("statistics", {}) snippet = video.get("snippet", {}) return { "video_id": video_id, "views": int(stats.get("viewCount", 0)), "likes": int(stats.get("likeCount", 0)), "comments": int(stats.get("commentCount", 0)), "es_title": snippet.get("title", ""), "published_at": snippet.get("publishedAt", ""), "fetched_at": datetime.now().isoformat(), "source": "data_api", } except Exception as e: return {"error": f"Data API error: {e}", "video_id": video_id} def _save_video_analytics(self, video_id: str, metrics: Dict[str, Any]): """Save video analytics to state.""" if not self.state or not hasattr(self.state, "_state"): return try: if "video_analytics" not in self.state._state: self.state._state["video_analytics"] = {} self.state._state["video_analytics"][video_id] = metrics # Don't save on every update (too frequent) except Exception as e: print(f"[ANALYTICS] Failed to save video analytics: {e}") def _save_channel_analytics(self, data: Dict[str, Any]): """Save channel analytics to state.""" if not self.state or not hasattr(self.state, "_state"): return try: self.state._state["channel_analytics"] = data self.state.save() except Exception as e: print(f"[ANALYTICS] Failed to save channel analytics: {e}")