feat(video): 영상 공유용 Open Graph 페이지 라우트 추가
This commit is contained in:
parent
8e9e12fe0b
commit
11c358da3c
@ -69,6 +69,8 @@ PROJECT_DOMAIN=localhost:8000 # 프로젝트 도메인 (호스트:포
|
||||
PROJECT_VERSION=0.1.0 # 프로젝트 버전
|
||||
DESCRIPTION=FastAPI 기반 CastAD 프로젝트 # 프로젝트 설명
|
||||
ADMIN_BASE_URL=/admin # 관리자 페이지 기본 URL
|
||||
SHARE_FRONTEND_URL=https://ado2.o2osolution.ai # 공유 링크가 이동할 프론트엔드 공개 URL
|
||||
SHARE_DEFAULT_IMAGE_URL=https://ado2.o2osolution.ai/assets/images/hero-background.png # 포스터가 없을 때의 절대 이미지 URL
|
||||
DEBUG=True # 디버그 모드 (True: 개발, False: 운영)
|
||||
|
||||
# ================================
|
||||
|
||||
@ -17,7 +17,8 @@ import json
|
||||
from collections import defaultdict
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@ -57,10 +58,11 @@ from app.video.schemas.video_schema import (
|
||||
VideoRenderData,
|
||||
VideoThumbnailItem,
|
||||
)
|
||||
from app.video.services.share_page import build_video_share_html, get_video_share_data
|
||||
from app.video.worker.video_task import download_and_upload_video_to_blob
|
||||
|
||||
|
||||
from config import creatomate_settings
|
||||
from config import creatomate_settings, prj_settings
|
||||
|
||||
logger = get_logger("video")
|
||||
|
||||
@ -1091,6 +1093,43 @@ async def toggle_like(
|
||||
raise HTTPException(status_code=500, detail=f"좋아요 처리에 실패했습니다: {str(e)}")
|
||||
|
||||
|
||||
@router.get(
|
||||
"/share/{video_id}",
|
||||
response_class=HTMLResponse,
|
||||
summary="영상 공유용 Open Graph 페이지",
|
||||
description="영상별 제목, 설명, 포스터 메타데이터가 포함된 공개 HTML을 반환합니다.",
|
||||
responses={
|
||||
200: {"description": "공유 메타데이터 HTML 반환"},
|
||||
404: {"description": "공유 가능한 완료 영상을 찾을 수 없음"},
|
||||
},
|
||||
)
|
||||
async def get_video_share_page(
|
||||
video_id: int,
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> HTMLResponse:
|
||||
"""공개 공유 페이지를 반환하고 일반 브라우저는 영상 상세로 이동시킵니다."""
|
||||
share_data = await get_video_share_data(session, video_id)
|
||||
if share_data is None:
|
||||
raise HTTPException(status_code=404, detail="공유 가능한 영상을 찾을 수 없습니다.")
|
||||
|
||||
share_url = str(request.url).split("?", maxsplit=1)[0]
|
||||
html = build_video_share_html(
|
||||
share_data,
|
||||
share_url=share_url,
|
||||
frontend_base_url=prj_settings.SHARE_FRONTEND_URL,
|
||||
configured_default_image_url=prj_settings.SHARE_DEFAULT_IMAGE_URL,
|
||||
)
|
||||
return HTMLResponse(
|
||||
content=html,
|
||||
headers={
|
||||
"Cache-Control": "public, max-age=300",
|
||||
"Referrer-Policy": "no-referrer",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{video_id}",
|
||||
summary="단일 영상 상세 조회",
|
||||
|
||||
161
app/video/services/share_page.py
Normal file
161
app/video/services/share_page.py
Normal file
@ -0,0 +1,161 @@
|
||||
"""영상 공유 링크용 Open Graph HTML 생성 서비스."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from html import escape
|
||||
from urllib.parse import urlsplit, urlunsplit
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.home.models import Project
|
||||
from app.video.models import Video
|
||||
|
||||
FALLBACK_FRONTEND_URL = "https://ado2.o2osolution.ai"
|
||||
DEFAULT_SHARE_IMAGE_PATH = "/assets/images/hero-background.png"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class VideoShareData:
|
||||
"""공유 페이지에 필요한 영상 및 프로젝트 정보."""
|
||||
|
||||
video_id: int
|
||||
poster_url: str | None
|
||||
store_name: str
|
||||
region: str
|
||||
|
||||
|
||||
async def get_video_share_data(
|
||||
session: AsyncSession,
|
||||
video_id: int,
|
||||
) -> VideoShareData | None:
|
||||
"""공유 가능한 완료 영상을 프로젝트 정보와 함께 조회합니다."""
|
||||
result = await session.execute(
|
||||
select(
|
||||
Video.id,
|
||||
Video.poster_url,
|
||||
Project.store_name,
|
||||
Project.region,
|
||||
)
|
||||
.join(Project, Video.project_id == Project.id)
|
||||
.where(
|
||||
Video.id == video_id,
|
||||
Video.status == "completed",
|
||||
Video.is_deleted.is_(False),
|
||||
Project.is_deleted.is_(False),
|
||||
)
|
||||
)
|
||||
row = result.one_or_none()
|
||||
if row is None:
|
||||
return None
|
||||
|
||||
return VideoShareData(
|
||||
video_id=row.id,
|
||||
poster_url=row.poster_url,
|
||||
store_name=row.store_name,
|
||||
region=row.region,
|
||||
)
|
||||
|
||||
|
||||
def build_video_share_html(
|
||||
data: VideoShareData,
|
||||
*,
|
||||
share_url: str,
|
||||
frontend_base_url: str,
|
||||
configured_default_image_url: str = "",
|
||||
) -> str:
|
||||
"""영상별 OG 메타데이터와 상세 화면 이동 기능을 포함한 HTML을 생성합니다."""
|
||||
frontend_base = _normalise_frontend_base_url(frontend_base_url)
|
||||
detail_url = f"{frontend_base}/video/{data.video_id}"
|
||||
fallback_image_url = _resolve_default_image_url(
|
||||
configured_default_image_url,
|
||||
frontend_base,
|
||||
)
|
||||
image_url = _absolute_http_url(data.poster_url) or fallback_image_url
|
||||
|
||||
store_name = _normalise_text(data.store_name, "ADO2 영상")
|
||||
region = _normalise_text(data.region, "")
|
||||
title = f"{store_name} | ADO2"
|
||||
description = (
|
||||
f"{region} · ADO2 AI 마케팅 영상"
|
||||
if region
|
||||
else "ADO2 AI 마케팅 영상"
|
||||
)
|
||||
|
||||
escaped_title = escape(title, quote=True)
|
||||
escaped_description = escape(description, quote=True)
|
||||
escaped_image_url = escape(image_url, quote=True)
|
||||
escaped_share_url = escape(_absolute_http_url(share_url) or detail_url, quote=True)
|
||||
escaped_detail_url = escape(detail_url, quote=True)
|
||||
|
||||
return f"""<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{escaped_title}</title>
|
||||
<meta name="description" content="{escaped_description}">
|
||||
<link rel="canonical" href="{escaped_share_url}">
|
||||
|
||||
<meta property="og:title" content="{escaped_title}">
|
||||
<meta property="og:description" content="{escaped_description}">
|
||||
<meta property="og:image" content="{escaped_image_url}">
|
||||
<meta property="og:image:alt" content="{escaped_title}">
|
||||
<meta property="og:url" content="{escaped_share_url}">
|
||||
<meta property="og:type" content="video.other">
|
||||
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="{escaped_title}">
|
||||
<meta name="twitter:description" content="{escaped_description}">
|
||||
<meta name="twitter:image" content="{escaped_image_url}">
|
||||
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>{escaped_title}</h1>
|
||||
<p>{escaped_description}</p>
|
||||
<a id="continue-link" href="{escaped_detail_url}">영상 보기</a>
|
||||
</main>
|
||||
<script>
|
||||
window.location.replace(document.getElementById("continue-link").href);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
def _normalise_text(value: str | None, fallback: str) -> str:
|
||||
"""메타데이터용 텍스트에서 불필요한 공백을 제거합니다."""
|
||||
normalised = " ".join((value or "").split())
|
||||
return normalised or fallback
|
||||
|
||||
|
||||
def _normalise_frontend_base_url(value: str) -> str:
|
||||
"""프론트엔드 기준 URL을 안전한 절대 HTTP(S) URL로 정규화합니다."""
|
||||
absolute_url = _absolute_http_url(value) or FALLBACK_FRONTEND_URL
|
||||
parts = urlsplit(absolute_url)
|
||||
path = parts.path.rstrip("/")
|
||||
return urlunsplit((parts.scheme, parts.netloc, path, "", ""))
|
||||
|
||||
|
||||
def _resolve_default_image_url(configured_url: str, frontend_base: str) -> str:
|
||||
"""설정된 기본 이미지 또는 프론트엔드의 공개 기본 이미지를 반환합니다."""
|
||||
configured_absolute_url = _absolute_http_url(configured_url)
|
||||
if configured_absolute_url:
|
||||
return configured_absolute_url
|
||||
return f"{frontend_base}{DEFAULT_SHARE_IMAGE_PATH}"
|
||||
|
||||
|
||||
def _absolute_http_url(value: str | None) -> str | None:
|
||||
"""값이 절대 HTTP(S) URL인 경우에만 정리된 문자열을 반환합니다."""
|
||||
candidate = (value or "").strip()
|
||||
if not candidate:
|
||||
return None
|
||||
|
||||
try:
|
||||
parts = urlsplit(candidate)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
if parts.scheme.lower() not in {"http", "https"} or not parts.hostname:
|
||||
return None
|
||||
return candidate
|
||||
@ -33,6 +33,14 @@ class ProjectSettings(BaseSettings):
|
||||
ADMIN_BASE_URL: str = Field(default="/admin")
|
||||
ADMIN_SESSION_SECRET: str = Field(default="dev-secret-change-me-in-production")
|
||||
ADMIN_SESSION_MAX_AGE: int = Field(default=60 * 60 * 8)
|
||||
SHARE_FRONTEND_URL: str = Field(
|
||||
default="https://ado2.o2osolution.ai",
|
||||
description="영상 공유 링크가 이동할 프론트엔드 공개 기준 URL",
|
||||
)
|
||||
SHARE_DEFAULT_IMAGE_URL: str = Field(
|
||||
default="https://ado2.o2osolution.ai/assets/images/hero-background.png",
|
||||
description="포스터가 없는 영상 공유 시 사용할 절대 이미지 URL",
|
||||
)
|
||||
DEBUG: bool = Field(default=True)
|
||||
TIMEZONE: str = Field(
|
||||
default="Asia/Seoul",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user