{escaped_title}
+{escaped_description}
+ 영상 보기 +diff --git a/README.md b/README.md index 7049211..785b8b7 100644 --- a/README.md +++ b/README.md @@ -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: 운영) # ================================ diff --git a/app/video/api/routers/v1/video.py b/app/video/api/routers/v1/video.py index 557021d..c1b8370 100644 --- a/app/video/api/routers/v1/video.py +++ b/app/video/api/routers/v1/video.py @@ -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="단일 영상 상세 조회", diff --git a/app/video/services/share_page.py b/app/video/services/share_page.py new file mode 100644 index 0000000..8e9b8fb --- /dev/null +++ b/app/video/services/share_page.py @@ -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""" + +
+ + +{escaped_description}
+ 영상 보기 +