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 # 프로젝트 버전
|
PROJECT_VERSION=0.1.0 # 프로젝트 버전
|
||||||
DESCRIPTION=FastAPI 기반 CastAD 프로젝트 # 프로젝트 설명
|
DESCRIPTION=FastAPI 기반 CastAD 프로젝트 # 프로젝트 설명
|
||||||
ADMIN_BASE_URL=/admin # 관리자 페이지 기본 URL
|
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: 운영)
|
DEBUG=True # 디버그 모드 (True: 개발, False: 운영)
|
||||||
|
|
||||||
# ================================
|
# ================================
|
||||||
|
|||||||
@ -17,7 +17,8 @@ import json
|
|||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from typing import Literal
|
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 import func, or_, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
@ -57,10 +58,11 @@ from app.video.schemas.video_schema import (
|
|||||||
VideoRenderData,
|
VideoRenderData,
|
||||||
VideoThumbnailItem,
|
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 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")
|
logger = get_logger("video")
|
||||||
|
|
||||||
@ -1091,6 +1093,43 @@ async def toggle_like(
|
|||||||
raise HTTPException(status_code=500, detail=f"좋아요 처리에 실패했습니다: {str(e)}")
|
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(
|
@router.get(
|
||||||
"/{video_id}",
|
"/{video_id}",
|
||||||
summary="단일 영상 상세 조회",
|
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_BASE_URL: str = Field(default="/admin")
|
||||||
ADMIN_SESSION_SECRET: str = Field(default="dev-secret-change-me-in-production")
|
ADMIN_SESSION_SECRET: str = Field(default="dev-secret-change-me-in-production")
|
||||||
ADMIN_SESSION_MAX_AGE: int = Field(default=60 * 60 * 8)
|
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)
|
DEBUG: bool = Field(default=True)
|
||||||
TIMEZONE: str = Field(
|
TIMEZONE: str = Field(
|
||||||
default="Asia/Seoul",
|
default="Asia/Seoul",
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user