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:
169
backend/api/youtube/oauth_router.py
Normal file
169
backend/api/youtube/oauth_router.py
Normal file
@@ -0,0 +1,169 @@
|
||||
"""
|
||||
YouTube OAuth Router
|
||||
Handles YouTube Data API v3 OAuth2 authentication flow.
|
||||
Uses shared build_oauth_callback_html for popup-compatible callback responses.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from loguru import logger
|
||||
|
||||
from middleware.auth_middleware import get_current_user, get_optional_user
|
||||
from services.youtube.youtube_oauth_service import YouTubeOAuthService
|
||||
from services.integrations.oauth_callback_utils import build_oauth_callback_html
|
||||
|
||||
router = APIRouter(prefix="/youtube/oauth", tags=["youtube-oauth"])
|
||||
|
||||
|
||||
def get_oauth_service() -> YouTubeOAuthService:
|
||||
try:
|
||||
return YouTubeOAuthService()
|
||||
except ValueError as e:
|
||||
logger.error(f"YouTube OAuth service init failed: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/auth/url")
|
||||
def get_youtube_auth_url(
|
||||
user: dict = Depends(get_current_user),
|
||||
service: YouTubeOAuthService = Depends(get_oauth_service),
|
||||
):
|
||||
"""Generate YouTube OAuth authorization URL. Frontend opens this in a popup."""
|
||||
try:
|
||||
user_id = user.get("id")
|
||||
if not user_id:
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
|
||||
auth_url = service.generate_authorization_url(user_id)
|
||||
if not auth_url:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Failed to generate authorization URL. Check server logs.",
|
||||
)
|
||||
|
||||
logger.info(f"YouTube OAuth URL generated for user {user_id}")
|
||||
return {"auth_url": auth_url}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error generating YouTube auth URL: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/callback")
|
||||
def handle_youtube_callback(
|
||||
code: str = Query(None),
|
||||
state: str = Query(None),
|
||||
error: str = Query(None),
|
||||
request: Request = None,
|
||||
service: YouTubeOAuthService = Depends(get_oauth_service),
|
||||
):
|
||||
"""
|
||||
Handle OAuth callback from Google.
|
||||
|
||||
Returns HTML with postMessage to the opener popup window (GSC/WordPress pattern).
|
||||
Supports JSON response via ?format=json for server-side flows.
|
||||
"""
|
||||
# User denied authorization
|
||||
if error:
|
||||
logger.warning(f"YouTube OAuth: user denied authorization: {error}")
|
||||
html = build_oauth_callback_html(
|
||||
payload={"type": "YOUTUBE_OAUTH_ERROR", "error": error},
|
||||
title="Authorization Denied",
|
||||
heading="Authorization Denied",
|
||||
message=f"You denied the authorization request. {error}",
|
||||
)
|
||||
return _response_as_html(request, html)
|
||||
|
||||
# Validate parameters
|
||||
if not code or not state:
|
||||
logger.error("YouTube OAuth: missing code or state parameters")
|
||||
html = build_oauth_callback_html(
|
||||
payload={"type": "YOUTUBE_OAUTH_ERROR", "error": "Missing authorization code or state"},
|
||||
title="Authorization Failed",
|
||||
heading="Missing Parameters",
|
||||
message="The authorization request was missing required parameters. Please try again.",
|
||||
)
|
||||
return _response_as_html(request, html)
|
||||
|
||||
# Exchange code for tokens
|
||||
result = service.handle_oauth_callback(authorization_code=code, state=state)
|
||||
|
||||
if result.get("success"):
|
||||
channel_name = result.get("channel_name", "your channel")
|
||||
html = build_oauth_callback_html(
|
||||
payload={
|
||||
"type": "YOUTUBE_OAUTH_SUCCESS",
|
||||
"channel_id": result.get("channel_id", ""),
|
||||
"channel_name": channel_name,
|
||||
},
|
||||
title="YouTube Connected",
|
||||
heading="YouTube Connected!",
|
||||
message=f"Successfully connected to {channel_name}. You can now close this window.",
|
||||
)
|
||||
logger.info(f"YouTube OAuth callback succeeded for channel: {channel_name}")
|
||||
return _response_as_html(request, html)
|
||||
|
||||
error_msg = result.get("error", "Unknown error during authorization")
|
||||
logger.error(f"YouTube OAuth callback failed: {error_msg}")
|
||||
html = build_oauth_callback_html(
|
||||
payload={"type": "YOUTUBE_OAUTH_ERROR", "error": error_msg},
|
||||
title="Connection Failed",
|
||||
heading="Connection Failed",
|
||||
message=f"Failed to connect YouTube: {error_msg}. Please try again.",
|
||||
)
|
||||
return _response_as_html(request, html)
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
def get_youtube_status(
|
||||
user: dict = Depends(get_current_user),
|
||||
service: YouTubeOAuthService = Depends(get_oauth_service),
|
||||
):
|
||||
"""Check YouTube connection status for the authenticated user."""
|
||||
try:
|
||||
user_id = user.get("id")
|
||||
status = service.get_connection_status(user_id)
|
||||
return {"success": True, **status}
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking YouTube OAuth status: {e}")
|
||||
return {"success": False, "connected": False, "channels": [], "error": str(e)}
|
||||
|
||||
|
||||
@router.delete("/disconnect/{token_id}")
|
||||
def disconnect_youtube(
|
||||
token_id: int,
|
||||
user: dict = Depends(get_current_user),
|
||||
service: YouTubeOAuthService = Depends(get_oauth_service),
|
||||
):
|
||||
"""Deactivate a YouTube OAuth token."""
|
||||
try:
|
||||
user_id = user.get("id")
|
||||
result = service.revoke_token(user_id, token_id)
|
||||
if result:
|
||||
return {"success": True, "message": "YouTube disconnected"}
|
||||
return {"success": False, "message": "Failed to disconnect"}
|
||||
except Exception as e:
|
||||
logger.error(f"Error disconnecting YouTube: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
|
||||
def _response_as_html(request: Request, html: str):
|
||||
"""Return HTML response, or JSON if ?format=json is present."""
|
||||
if request and request.query_params.get("format") == "json":
|
||||
from fastapi.responses import JSONResponse
|
||||
import json as json_lib
|
||||
|
||||
# Extract payload from HTML for JSON response
|
||||
try:
|
||||
payload_start = html.index('"type":')
|
||||
payload_end = html.index("</script>", payload_start)
|
||||
snippet = html[payload_start : payload_end - 3]
|
||||
payload = json_lib.loads("{" + snippet + "}")
|
||||
return JSONResponse(content=payload)
|
||||
except Exception:
|
||||
return JSONResponse(content={"success": False, "error": "OAuth processing completed"})
|
||||
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
return HTMLResponse(content=html, headers={"Cross-Origin-Opener-Policy": "unsafe-none"})
|
||||
218
backend/api/youtube/publish_router.py
Normal file
218
backend/api/youtube/publish_router.py
Normal file
@@ -0,0 +1,218 @@
|
||||
"""
|
||||
YouTube Publish Router
|
||||
Handles video upload/publishing to YouTube via the Data API v3.
|
||||
Uses stored OAuth credentials for authentication.
|
||||
"""
|
||||
|
||||
from typing import Optional, List
|
||||
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks, Query
|
||||
from pydantic import BaseModel, Field
|
||||
from loguru import logger
|
||||
|
||||
from middleware.auth_middleware import get_current_user
|
||||
from services.youtube.youtube_oauth_service import YouTubeOAuthService
|
||||
from services.youtube.youtube_publish_service import YouTubePublishService
|
||||
from .oauth_router import get_oauth_service
|
||||
from .task_manager import task_manager
|
||||
|
||||
router = APIRouter(prefix="/youtube/publish", tags=["youtube-publish"])
|
||||
|
||||
|
||||
class PublishRequest(BaseModel):
|
||||
token_id: int = Field(..., description="YouTube OAuth token row ID (which channel to publish to)")
|
||||
video_source: str = Field(..., description="URL or local file path to the video")
|
||||
title: str = Field(..., min_length=1, max_length=100, description="Video title (max 100 chars)")
|
||||
description: str = Field("", description="Video description")
|
||||
tags: List[str] = Field(default_factory=list, description="Video tags")
|
||||
privacy_status: str = Field("unlisted", pattern="^(public|private|unlisted)$", description="Privacy status")
|
||||
category_id: str = Field("22", description="YouTube category ID (default: People & Blogs)")
|
||||
made_for_kids: bool = Field(False, description="Whether content is made for children")
|
||||
|
||||
|
||||
class PublishResponse(BaseModel):
|
||||
success: bool
|
||||
task_id: Optional[str] = None
|
||||
video_id: Optional[str] = None
|
||||
video_url: Optional[str] = None
|
||||
error: Optional[str] = None
|
||||
message: str = ""
|
||||
|
||||
|
||||
def get_publish_service(
|
||||
oauth_service: YouTubeOAuthService = Depends(get_oauth_service),
|
||||
) -> YouTubePublishService:
|
||||
return YouTubePublishService(oauth_service)
|
||||
|
||||
|
||||
@router.post("", response_model=PublishResponse)
|
||||
def start_publish(
|
||||
request: PublishRequest,
|
||||
background_tasks: BackgroundTasks,
|
||||
user: dict = Depends(get_current_user),
|
||||
publish_service: YouTubePublishService = Depends(get_publish_service),
|
||||
):
|
||||
"""Start publishing a video to YouTube as a background task."""
|
||||
try:
|
||||
user_id = user.get("id")
|
||||
if not user_id:
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
|
||||
# Verify token belongs to user
|
||||
oauth_service = publish_service.oauth_service
|
||||
status = oauth_service.get_connection_status(user_id)
|
||||
tokens = [c for c in status.get("channels", []) if c["token_id"] == request.token_id and c["is_active"]]
|
||||
if not tokens:
|
||||
raise HTTPException(status_code=400, detail="Invalid or inactive token_id")
|
||||
|
||||
# Create background task
|
||||
task_id = task_manager.create_task("youtube_publish")
|
||||
logger.info(
|
||||
f"YouTube publish: created task {task_id} for user {user_id}, "
|
||||
f"title='{request.title[:50]}', channel={tokens[0].get('channel_name', 'unknown')}"
|
||||
)
|
||||
|
||||
background_tasks.add_task(
|
||||
_execute_publish_task,
|
||||
task_id=task_id,
|
||||
user_id=user_id,
|
||||
token_id=request.token_id,
|
||||
video_source=request.video_source,
|
||||
title=request.title,
|
||||
description=request.description,
|
||||
tags=request.tags,
|
||||
privacy_status=request.privacy_status,
|
||||
category_id=request.category_id,
|
||||
made_for_kids=request.made_for_kids,
|
||||
publish_service=publish_service,
|
||||
)
|
||||
|
||||
return PublishResponse(
|
||||
success=True,
|
||||
task_id=task_id,
|
||||
message="Publishing to YouTube started. Poll task_id for progress.",
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"YouTube publish: error starting task: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/{task_id}", response_model=PublishResponse)
|
||||
def get_publish_status(
|
||||
task_id: str,
|
||||
user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Check the status of a YouTube publish task."""
|
||||
try:
|
||||
user_id = user.get("id")
|
||||
if not user_id:
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
|
||||
task_status = task_manager.get_task_status(task_id)
|
||||
if not task_status:
|
||||
return PublishResponse(
|
||||
success=False,
|
||||
error="Task not found",
|
||||
message="Publish task not found (may have expired).",
|
||||
)
|
||||
|
||||
status = task_status.get("status", "unknown")
|
||||
result = task_status.get("result") or {}
|
||||
error = task_status.get("error")
|
||||
|
||||
if status == "completed":
|
||||
return PublishResponse(
|
||||
success=True,
|
||||
task_id=task_id,
|
||||
video_id=result.get("video_id"),
|
||||
video_url=result.get("video_url"),
|
||||
message=task_status.get("message", "Published successfully"),
|
||||
)
|
||||
elif status == "failed":
|
||||
return PublishResponse(
|
||||
success=False,
|
||||
task_id=task_id,
|
||||
error=error or result.get("error", "Publish failed"),
|
||||
message=task_status.get("message", "Publish failed"),
|
||||
)
|
||||
else:
|
||||
return PublishResponse(
|
||||
success=False,
|
||||
task_id=task_id,
|
||||
message=task_status.get("message", "Publishing in progress..."),
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"YouTube publish: status check error: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
def _execute_publish_task(
|
||||
task_id: str,
|
||||
user_id: str,
|
||||
token_id: int,
|
||||
video_source: str,
|
||||
title: str,
|
||||
description: str,
|
||||
tags: List[str],
|
||||
privacy_status: str,
|
||||
category_id: str,
|
||||
made_for_kids: bool,
|
||||
publish_service: YouTubePublishService,
|
||||
):
|
||||
"""Background task to execute video publish."""
|
||||
logger.info(f"YouTube publish: background task {task_id} starting for user {user_id}")
|
||||
|
||||
try:
|
||||
task_manager.update_task_status(
|
||||
task_id, "processing", progress=10.0, message="Preparing video for upload..."
|
||||
)
|
||||
|
||||
result = publish_service.publish_video(
|
||||
user_id=user_id,
|
||||
token_id=token_id,
|
||||
video_source=video_source,
|
||||
title=title,
|
||||
description=description,
|
||||
tags=tags,
|
||||
privacy_status=privacy_status,
|
||||
category_id=category_id,
|
||||
made_for_kids=made_for_kids,
|
||||
)
|
||||
|
||||
if result.get("success"):
|
||||
task_manager.update_task_status(
|
||||
task_id,
|
||||
"completed",
|
||||
progress=100.0,
|
||||
message=f"Published successfully: {result.get('video_url', '')}",
|
||||
result=result,
|
||||
)
|
||||
logger.info(
|
||||
f"YouTube publish: task {task_id} completed — "
|
||||
f"video_id={result.get('video_id')}, url={result.get('video_url')}"
|
||||
)
|
||||
else:
|
||||
error_msg = result.get("error", "Unknown publish error")
|
||||
logger.error(f"YouTube publish: task {task_id} failed: {error_msg}")
|
||||
task_manager.update_task_status(
|
||||
task_id,
|
||||
"failed",
|
||||
error=error_msg,
|
||||
message="Publish failed",
|
||||
result=result,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"YouTube publish: background task {task_id} error: {e}")
|
||||
task_manager.update_task_status(
|
||||
task_id,
|
||||
"failed",
|
||||
error=str(e),
|
||||
message="Publish error",
|
||||
result={"error": str(e)},
|
||||
)
|
||||
@@ -30,6 +30,8 @@ from .task_manager import task_manager
|
||||
from .handlers import avatar as avatar_handlers
|
||||
from .handlers import images as image_handlers
|
||||
from .handlers import audio as audio_handlers
|
||||
from .oauth_router import router as youtube_oauth_router
|
||||
from .publish_router import router as youtube_publish_router
|
||||
|
||||
router = APIRouter(prefix="/youtube", tags=["youtube"])
|
||||
logger = get_service_logger("api.youtube")
|
||||
@@ -41,10 +43,12 @@ from .paths import (
|
||||
ensure_youtube_media_dirs,
|
||||
)
|
||||
|
||||
# Include sub-routers for avatar, images, and audio
|
||||
# Include sub-routers for avatar, images, audio, and OAuth
|
||||
router.include_router(avatar_handlers.router)
|
||||
router.include_router(image_handlers.router)
|
||||
router.include_router(audio_handlers.router)
|
||||
router.include_router(youtube_oauth_router)
|
||||
router.include_router(youtube_publish_router)
|
||||
|
||||
|
||||
# Request/Response Models
|
||||
|
||||
Reference in New Issue
Block a user