feat: image generation overhaul (model-aware text, dim clamping, \.30 pricing), event-driven dashboard cache invalidation, SEO insights (AI visibility, GSC, keyword gap), YouTube OAuth/publish, blog writer & content planning improvements, scheduler monitoring updates

This commit is contained in:
ajaysi
2026-05-30 07:58:22 +05:30
parent aaf94049da
commit 64f1f88cdd
129 changed files with 8796 additions and 8755 deletions

View File

@@ -0,0 +1,493 @@
"""
YouTube OAuth2 Service
Handles Google OAuth2 authentication for YouTube Data API v3.
Supports token encryption, auto-refresh, and per-user multi-token storage.
Pattern: follows GSCService (Google OAuth flow) + WordPressOAuthService (Fernet encryption + rich schema).
"""
import os
import json
import secrets
import sqlite3
from typing import Optional, Dict, Any, List
from datetime import datetime, timedelta
from google.auth.transport.requests import Request as GoogleRequest
from google.oauth2.credentials import Credentials
from google_auth_oauthlib.flow import Flow
from googleapiclient.discovery import build
from cryptography.fernet import Fernet
from loguru import logger
from services.database import get_user_db_path
class YouTubeOAuthService:
"""Manages YouTube OAuth2 authentication flow and token storage."""
SCOPES = [
"https://www.googleapis.com/auth/youtube.upload",
"https://www.googleapis.com/auth/youtube.readonly",
"https://www.googleapis.com/auth/youtube.force-ssl",
]
def __init__(self, db_path: Optional[str] = None):
self.db_path = db_path
# Load Google OAuth credentials
self.client_id = os.getenv("GOOGLE_CLIENT_ID", "")
self.client_secret = os.getenv("GOOGLE_CLIENT_SECRET", "")
self.project_id = os.getenv("GOOGLE_PROJECT_ID", "alwrity")
# Redirect URI
default_redirect = "http://localhost:8000/api/youtube/oauth/callback"
self.redirect_uri = os.getenv("YOUTUBE_REDIRECT_URI", default_redirect)
# Token encryption
self.token_encryption_key = os.getenv(
"YOUTUBE_TOKEN_ENCRYPTION_KEY"
) or os.getenv("OAUTH_TOKEN_ENCRYPTION_KEY")
self._fernet: Fernet = self._initialize_fernet()
self._migration_done: set = set()
# Build client config for google_auth_oauthlib
self.client_config = self._build_client_config()
# Validate
if not self.client_id or not self.client_secret:
logger.error(
"YouTube OAuth: GOOGLE_CLIENT_ID or GOOGLE_CLIENT_SECRET not set. "
"YouTube upload will not work until these are configured."
)
def _initialize_fernet(self) -> Fernet:
if not self.token_encryption_key:
raise ValueError(
"YOUTUBE_TOKEN_ENCRYPTION_KEY (or OAUTH_TOKEN_ENCRYPTION_KEY) is not set. "
"OAuth tokens must be encrypted at rest. "
"Generate a key: python -c \"from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())\""
)
try:
return Fernet(self.token_encryption_key.encode("utf-8"))
except Exception as e:
raise ValueError(f"Invalid YOUTUBE_TOKEN_ENCRYPTION_KEY: {e}")
def _encrypt_token(self, token: Optional[str]) -> Optional[str]:
if not token:
return None
return self._fernet.encrypt(token.encode("utf-8")).decode("utf-8")
def _decrypt_token(self, token_blob: Optional[str]) -> Optional[str]:
if not token_blob:
return None
try:
return self._fernet.decrypt(token_blob.encode("utf-8")).decode("utf-8")
except Exception as e:
logger.error(f"YouTube OAuth: token decryption failed: {e}")
return None
def _is_likely_encrypted_blob(self, value: Optional[str]) -> bool:
return bool(value and value.startswith("gAAAAA"))
def _build_client_config(self) -> Optional[Dict[str, Any]]:
if not self.client_id or not self.client_secret:
return None
return {
"web": {
"client_id": self.client_id,
"client_secret": self.client_secret,
"project_id": self.project_id,
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token",
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
"redirect_uris": [self.redirect_uri],
"javascript_origins": [],
}
}
def _get_db_path(self, user_id: str) -> str:
return get_user_db_path(user_id)
def _init_db(self, user_id: str):
db_path = self._get_db_path(user_id)
os.makedirs(os.path.dirname(db_path), exist_ok=True)
with sqlite3.connect(db_path) as conn:
cursor = conn.cursor()
cursor.execute("""
CREATE TABLE IF NOT EXISTS youtube_oauth_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
access_token TEXT NOT NULL,
refresh_token TEXT,
token_type TEXT DEFAULT 'bearer',
expires_at TIMESTAMP,
scope TEXT,
channel_id TEXT,
channel_name TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
is_active BOOLEAN DEFAULT TRUE
)
""")
cursor.execute("""
CREATE TABLE IF NOT EXISTS youtube_oauth_states (
id INTEGER PRIMARY KEY AUTOINCREMENT,
state TEXT NOT NULL UNIQUE,
user_id TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP DEFAULT (datetime('now', '+10 minutes'))
)
""")
conn.commit()
logger.debug(f"YouTube OAuth tables initialized for user {user_id}")
def _migrate_plaintext_tokens_if_needed(self, conn: sqlite3.Connection, user_id: str) -> None:
if not self._fernet or user_id in self._migration_done:
return
cursor = conn.cursor()
cursor.execute(
"SELECT id, access_token, refresh_token FROM youtube_oauth_tokens WHERE user_id = ?",
(user_id,),
)
rows = cursor.fetchall()
migrated = 0
for token_id, access_token, refresh_token in rows:
needs_access = access_token and not self._is_likely_encrypted_blob(access_token)
needs_refresh = refresh_token and not self._is_likely_encrypted_blob(refresh_token)
if not (needs_access or needs_refresh):
continue
enc_access = self._encrypt_token(access_token) if needs_access else access_token
enc_refresh = self._encrypt_token(refresh_token) if needs_refresh else refresh_token
cursor.execute(
"UPDATE youtube_oauth_tokens SET access_token = ?, refresh_token = ?, updated_at = datetime('now') WHERE id = ? AND user_id = ?",
(enc_access, enc_refresh, token_id, user_id),
)
migrated += 1
if migrated:
conn.commit()
logger.info(f"YouTube OAuth token migration completed for user {user_id}; rows={migrated}")
self._migration_done.add(user_id)
def generate_authorization_url(self, user_id: str) -> Optional[str]:
"""Generate Google OAuth authorization URL for YouTube scopes."""
try:
if not self.client_config:
logger.error("YouTube OAuth: client config not available")
return None
self._init_db(user_id)
flow = Flow.from_client_config(
self.client_config,
scopes=self.SCOPES,
redirect_uri=self.redirect_uri,
autogenerate_code_verifier=False,
)
random_state = secrets.token_urlsafe(32)
state = f"{user_id}:{random_state}"
authorization_url, _ = flow.authorization_url(
access_type="offline",
include_granted_scopes="true",
prompt="consent",
state=state,
)
# Store state for callback verification
db_path = self._get_db_path(user_id)
with sqlite3.connect(db_path) as conn:
cursor = conn.cursor()
cursor.execute(
"INSERT OR REPLACE INTO youtube_oauth_states (state, user_id) VALUES (?, ?)",
(state, user_id),
)
conn.commit()
logger.info(f"YouTube OAuth URL generated for user {user_id}")
return authorization_url
except Exception as e:
logger.error(f"YouTube OAuth: failed to generate auth URL for {user_id}: {e}")
return None
def handle_oauth_callback(self, authorization_code: str, state: str) -> Dict[str, Any]:
"""
Handle OAuth callback — exchange code for tokens, store them.
Returns: dict with 'success' key. On success also 'channel_id', 'channel_name'.
"""
try:
if ":" not in state:
logger.error(f"YouTube OAuth: invalid state format: {state}")
return {"success": False, "error": "Invalid state format"}
user_id = state.split(":")[0]
db_path = self._get_db_path(user_id)
if not os.path.exists(db_path):
logger.error(f"YouTube OAuth: user DB not found for {user_id}")
return {"success": False, "error": "User database not found"}
# Verify state
with sqlite3.connect(db_path) as conn:
cursor = conn.cursor()
cursor.execute("SELECT user_id FROM youtube_oauth_states WHERE state = ?", (state,))
if not cursor.fetchone():
logger.error(f"YouTube OAuth: invalid/expired state for {user_id}")
return {"success": False, "error": "Invalid or expired state"}
if not self.client_config:
return {"success": False, "error": "Client config not loaded"}
# Exchange code for tokens
flow = Flow.from_client_config(
self.client_config,
scopes=self.SCOPES,
redirect_uri=self.redirect_uri,
autogenerate_code_verifier=False,
)
flow.fetch_token(code=authorization_code)
google_credentials = flow.credentials
# Clean up state
try:
with sqlite3.connect(db_path) as conn:
conn.execute("DELETE FROM youtube_oauth_states WHERE state = ?", (state,))
conn.commit()
except Exception as cleanup_err:
logger.warning(f"YouTube OAuth: state cleanup failed: {cleanup_err}")
# Fetch channel info
channel_info = self._fetch_channel_info(google_credentials)
# Save tokens
save_result = self._save_tokens(
user_id=user_id,
credentials=google_credentials,
channel_id=channel_info.get("channel_id", ""),
channel_name=channel_info.get("channel_name", ""),
)
if not save_result:
return {"success": False, "error": "Failed to save tokens"}
logger.info(f"YouTube OAuth: user {user_id} authorized — channel: {channel_info.get('channel_name', 'unknown')}")
return {
"success": True,
"channel_id": channel_info.get("channel_id", ""),
"channel_name": channel_info.get("channel_name", ""),
}
except Exception as e:
logger.error(f"YouTube OAuth: callback error: {e}")
return {"success": False, "error": str(e)}
def _fetch_channel_info(self, credentials: Credentials) -> Dict[str, str]:
"""Fetch authenticated user's YouTube channel info."""
try:
youtube = build("youtube", "v3", credentials=credentials, cache_discovery=False)
request = youtube.channels().list(part="snippet", mine=True)
response = request.execute()
items = response.get("items", [])
if items:
return {
"channel_id": items[0].get("id", ""),
"channel_name": items[0].get("snippet", {}).get("title", ""),
}
logger.warning("YouTube OAuth: no channel found for authenticated user")
return {"channel_id": "", "channel_name": ""}
except Exception as e:
logger.error(f"YouTube OAuth: failed to fetch channel info: {e}")
return {"channel_id": "", "channel_name": ""}
def _save_tokens(
self,
user_id: str,
credentials: Credentials,
channel_id: str = "",
channel_name: str = "",
) -> bool:
"""Save OAuth tokens to per-user database with encryption."""
try:
self._init_db(user_id)
db_path = self._get_db_path(user_id)
expires_at = None
if credentials.expiry:
expires_at = credentials.expiry.strftime("%Y-%m-%d %H:%M:%S")
enc_access = self._encrypt_token(credentials.token) or ""
enc_refresh = self._encrypt_token(credentials.refresh_token)
with sqlite3.connect(db_path) as conn:
self._migrate_plaintext_tokens_if_needed(conn, user_id)
cursor = conn.cursor()
cursor.execute(
"""
INSERT INTO youtube_oauth_tokens
(user_id, access_token, refresh_token, token_type, expires_at, scope, channel_id, channel_name)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
(
user_id,
enc_access,
enc_refresh,
"bearer",
expires_at,
" ".join(self.SCOPES),
channel_id,
channel_name,
),
)
conn.commit()
logger.info(f"YouTube OAuth: tokens saved for user {user_id}")
return True
except Exception as e:
logger.error(f"YouTube OAuth: failed to save tokens for {user_id}: {e}")
return False
def get_valid_credentials(self, user_id: str, token_id: Optional[int] = None) -> Optional[Credentials]:
"""
Load and (if needed) refresh credentials for a user.
Args:
user_id: Clerk user ID
token_id: Specific token row ID; if None, uses the most recent active token.
Returns:
google.oauth2.credentials.Credentials or None
"""
try:
db_path = self._get_db_path(user_id)
if not os.path.exists(db_path):
return None
with sqlite3.connect(db_path) as conn:
cursor = conn.cursor()
if token_id:
cursor.execute(
"SELECT id, access_token, refresh_token, expires_at FROM youtube_oauth_tokens WHERE id = ? AND user_id = ? AND is_active = 1",
(token_id, user_id),
)
else:
cursor.execute(
"SELECT id, access_token, refresh_token, expires_at FROM youtube_oauth_tokens WHERE user_id = ? AND is_active = 1 ORDER BY created_at DESC LIMIT 1",
(user_id,),
)
row = cursor.fetchone()
if not row:
logger.warning(f"YouTube OAuth: no active tokens for user {user_id}")
return None
db_id, enc_access, enc_refresh, expires_at_str = row
access_token = self._decrypt_token(enc_access)
refresh_token = self._decrypt_token(enc_refresh)
if not access_token:
logger.error(f"YouTube OAuth: cannot decrypt access token for user {user_id}")
return None
# Build Credentials object (Google lib handles refresh automatically)
creds = Credentials(
token=access_token,
refresh_token=refresh_token,
token_uri="https://oauth2.googleapis.com/token",
client_id=self.client_id,
client_secret=self.client_secret,
scopes=self.SCOPES,
)
# Auto-refresh if expired
if creds.expired:
if creds.refresh_token:
try:
creds.refresh(GoogleRequest())
self._update_stored_token(user_id, db_id, creds)
logger.info(f"YouTube OAuth: token refreshed for user {user_id}")
except Exception as e:
logger.error(f"YouTube OAuth: token refresh failed for {user_id}: {e}")
return None
else:
logger.warning(f"YouTube OAuth: token expired, no refresh token for {user_id}")
return None
return creds
except Exception as e:
logger.error(f"YouTube OAuth: get_valid_credentials error for {user_id}: {e}")
return None
def _update_stored_token(self, user_id: str, token_id: int, credentials: Credentials):
"""Update stored token after refresh."""
try:
db_path = self._get_db_path(user_id)
enc_access = self._encrypt_token(credentials.token) or ""
enc_refresh = self._encrypt_token(credentials.refresh_token)
expires_at = None
if credentials.expiry:
expires_at = credentials.expiry.strftime("%Y-%m-%d %H:%M:%S")
with sqlite3.connect(db_path) as conn:
conn.execute(
"UPDATE youtube_oauth_tokens SET access_token = ?, refresh_token = ?, expires_at = ?, updated_at = datetime('now') WHERE id = ? AND user_id = ?",
(enc_access, enc_refresh, expires_at, token_id, user_id),
)
conn.commit()
except Exception as e:
logger.error(f"YouTube OAuth: failed to update stored token for {user_id}: {e}")
def get_connection_status(self, user_id: str) -> Dict[str, Any]:
"""Get YouTube connection status for a user."""
try:
db_path = self._get_db_path(user_id)
if not os.path.exists(db_path):
return {"connected": False, "channels": []}
with sqlite3.connect(db_path) as conn:
cursor = conn.cursor()
cursor.execute(
"SELECT id, channel_id, channel_name, expires_at, created_at, is_active FROM youtube_oauth_tokens WHERE user_id = ? ORDER BY created_at DESC",
(user_id,),
)
rows = cursor.fetchall()
channels = []
for row in rows:
channel = {
"token_id": row[0],
"channel_id": row[1] or "",
"channel_name": row[2] or "",
"expires_at": row[3],
"connected_at": row[4],
"is_active": bool(row[5]),
}
channels.append(channel)
return {"connected": len(channels) > 0, "channels": channels}
except Exception as e:
logger.error(f"YouTube OAuth: connection status error for {user_id}: {e}")
return {"connected": False, "channels": [], "error": str(e)}
def revoke_token(self, user_id: str, token_id: int) -> bool:
"""Deactivate a specific token."""
try:
db_path = self._get_db_path(user_id)
with sqlite3.connect(db_path) as conn:
conn.execute(
"UPDATE youtube_oauth_tokens SET is_active = 0, updated_at = datetime('now') WHERE id = ? AND user_id = ?",
(token_id, user_id),
)
conn.commit()
logger.info(f"YouTube OAuth: token {token_id} revoked for user {user_id}")
return True
except Exception as e:
logger.error(f"YouTube OAuth: revoke error for {user_id}: {e}")
return False

View File

@@ -0,0 +1,230 @@
"""
YouTube Publish Service
Uploads videos to YouTube via the YouTube Data API v3.
Uses stored OAuth credentials from YouTubeOAuthService.
Supports resumable upload for large files.
"""
import os
import tempfile
from typing import Optional, Dict, Any, List
from pathlib import Path
import httpx
from googleapiclient.discovery import build
from googleapiclient.http import MediaFileUpload
from google.oauth2.credentials import Credentials as GoogleCredentials
from loguru import logger
from services.youtube.youtube_oauth_service import YouTubeOAuthService
class YouTubePublishService:
"""Upload videos to YouTube using stored OAuth credentials."""
MAX_RETRIES = 3
CHUNK_SIZE = 50 * 1024 * 1024 # 50MB chunks for resumable upload
DOWNLOAD_TIMEOUT = 300 # 5 minutes to download source video
def __init__(self, oauth_service: YouTubeOAuthService):
self.oauth_service = oauth_service
def publish_video(
self,
user_id: str,
token_id: int,
video_source: str,
title: str,
description: str = "",
tags: Optional[List[str]] = None,
privacy_status: str = "unlisted",
category_id: str = "22",
made_for_kids: bool = False,
language: str = "en",
) -> Dict[str, Any]:
"""
Upload a video to YouTube.
Args:
user_id: Clerk user ID
token_id: OAuth token row ID (which YouTube channel to publish to)
video_source: URL or local file path to the video
title: Video title (max 100 chars)
description: Video description
tags: List of tags
privacy_status: 'public', 'private', or 'unlisted'
category_id: YouTube category ID (default '22' = People & Blogs)
made_for_kids: Whether content is made for children
language: Video language (ISO 639-1 code)
Returns:
dict with 'success', 'video_id', 'video_url', 'error' keys
"""
temp_path = None
is_temp = False
try:
# Validate title length
if len(title) > 100:
title = title[:97] + "..."
# Get valid credentials
creds = self.oauth_service.get_valid_credentials(user_id, token_id)
if not creds:
return {
"success": False,
"error": "YouTube auth failed. Please reconnect your YouTube channel.",
}
# Resolve video file path (download if URL)
video_path, was_downloaded = self._resolve_video_source(video_source)
if not video_path:
return {"success": False, "error": "Video source file not found or could not be downloaded."}
temp_path = video_path
is_temp = was_downloaded
# Validate file
file_size = os.path.getsize(video_path)
if file_size == 0:
return {"success": False, "error": "Video file is empty."}
logger.info(
f"YouTube publish: starting upload for user {user_id}, "
f"title='{title}', size={file_size / 1024 / 1024:.1f}MB, privacy={privacy_status}"
)
# Build YouTube API client
youtube = build("youtube", "v3", credentials=creds, cache_discovery=False)
# Prepare video metadata
body = {
"snippet": {
"title": title,
"description": description,
"tags": tags or [],
"categoryId": category_id,
"defaultLanguage": language,
},
"status": {
"privacyStatus": privacy_status,
"selfDeclaredMadeForKids": made_for_kids,
},
}
# Upload with resumable media
media = MediaFileUpload(
video_path,
chunksize=self.CHUNK_SIZE,
resumable=True,
)
request = youtube.videos().insert(
part=",".join(body.keys()),
body=body,
media_body=media,
)
response = None
last_error = None
for attempt in range(self.MAX_RETRIES):
try:
response = request.execute()
break
except Exception as e:
last_error = e
logger.warning(
f"YouTube publish upload attempt {attempt + 1}/{self.MAX_RETRIES} "
f"failed for user {user_id}: {e}"
)
if attempt < self.MAX_RETRIES - 1:
import time
time.sleep(2 ** attempt)
if not response:
error_msg = str(last_error or "Upload failed after retries")
logger.error(f"YouTube publish: upload failed for user {user_id}: {error_msg}")
return {"success": False, "error": error_msg}
video_id = response.get("id", "")
video_url = f"https://youtu.be/{video_id}" if video_id else ""
logger.info(
f"YouTube publish: upload complete for user {user_id}"
f"video_id={video_id}, url={video_url}"
)
return {
"success": True,
"video_id": video_id,
"video_url": video_url,
"title": title,
"privacy_status": privacy_status,
}
except Exception as e:
logger.error(f"YouTube publish: error for user {user_id}: {e}")
return {"success": False, "error": str(e)}
finally:
if temp_path and is_temp:
try:
os.unlink(temp_path)
except Exception:
pass
def _resolve_video_source(self, video_source: str):
"""
Resolve video source to a local file path.
Returns (path, is_temp) tuple. If video_source is a URL, download it to a temp file.
"""
if video_source.startswith(("http://", "https://", "ftp://")):
path = self._download_video(video_source)
return (path, True) if path else (None, False)
local_path = Path(video_source)
if local_path.exists():
return (str(local_path.resolve()), False)
logger.error(f"YouTube publish: video source not found: {video_source}")
return (None, False)
def _download_video(self, url: str) -> Optional[str]:
"""Download a video from URL to a temporary file."""
try:
suffix = self._guess_extension(url) or ".mp4"
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=suffix)
tmp_path = tmp.name
tmp.close()
logger.info(f"YouTube publish: downloading video from {url}")
with httpx.Client(timeout=self.DOWNLOAD_TIMEOUT, follow_redirects=True) as client:
with client.stream("GET", url) as response:
response.raise_for_status()
with open(tmp_path, "wb") as f:
for chunk in response.iter_bytes(chunk_size=8 * 1024 * 1024):
f.write(chunk)
file_size = os.path.getsize(tmp_path)
logger.info(f"YouTube publish: downloaded {file_size / 1024 / 1024:.1f}MB to {tmp_path}")
return tmp_path
except Exception as e:
logger.error(f"YouTube publish: download failed from {url}: {e}")
if "tmp_path" in locals():
try:
os.unlink(tmp_path)
except Exception:
pass
return None
@staticmethod
def _guess_extension(url: str) -> str:
"""Guess file extension from URL."""
path = url.split("?")[0] # Strip query params
_, ext = os.path.splitext(path)
if ext.lower() in (".mp4", ".mov", ".avi", ".mkv", ".webm", ".flv", ".wmv"):
return ext
return ".mp4"