diff --git a/.gitignore b/.gitignore index 6bfa059..41135e1 100644 --- a/.gitignore +++ b/.gitignore @@ -32,8 +32,11 @@ media/ *.ipynb_checkpoint* -# Static files -static/ +# Static files (공유 기본 이미지는 예외로 추적) +static/* +!static/images/ +static/images/* +!static/images/ado2_image.png # Log files *.log diff --git a/README.md b/README.md index 7049211..b79bbb8 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 (로컬: http://localhost:3000, 테스트: https://dev.castad.net) +SHARE_DEFAULT_IMAGE_URL= # 포스터 없을 때 OG 이미지 (비우면 API /static/images/ado2_image.png) DEBUG=True # 디버그 모드 (True: 개발, False: 운영) # ================================ diff --git a/app/archive/api/routers/v1/archive.py b/app/archive/api/routers/v1/archive.py index 64721d1..f35f5e9 100644 --- a/app/archive/api/routers/v1/archive.py +++ b/app/archive/api/routers/v1/archive.py @@ -94,9 +94,11 @@ async def get_videos( # 프론트는 반드시 (type, video_id) 쌍으로 식별할 것. task_id=it.task_id, result_movie_url=it.movie_url, + poster_url=it.poster_url, created_at=it.created_at, like_count=it.like_count, comment_count=it.comment_count, + is_liked_by_me=it.is_liked_by_me, ) for it in items ] diff --git a/app/comment/api/routers/v1/comment.py b/app/comment/api/routers/v1/comment.py index 7ba9e7a..95eba3c 100644 --- a/app/comment/api/routers/v1/comment.py +++ b/app/comment/api/routers/v1/comment.py @@ -48,7 +48,7 @@ router = APIRouter(prefix="/comment", tags=["Comment"]) - **parent_id**: 대댓글일 때만 부모 댓글 id (생략 시 최상위 댓글) ## 참고 -- 작성자 정보는 응답에 포함되지 않습니다 (익명 정책). +- 작성자 닉네임/프로필 이미지는 카카오 로그인 정보를 그대로 사용합니다 (클라이언트에서 지정 불가). - 대댓글에 또 대댓글을 다는 것은 불가합니다 (최대 2-depth). """, response_model=CommentCreateResponse, @@ -77,7 +77,7 @@ async def post_comment( session=session, video_id=video_id, user_uuid=current_user.user_uuid, - nickname=body.nickname, + nickname=current_user.nickname, content=body.content, parent_id=body.parent_id, content_type=type, @@ -86,6 +86,7 @@ async def post_comment( return CommentCreateResponse( id=comment.id, nickname=comment.nickname or "익명", + profile_image_url=current_user.profile_image_url, parent_id=comment.parent_id, content=comment.content, created_at=comment.created_at, @@ -108,7 +109,7 @@ async def post_comment( ## 참고 - 최상위 댓글만 페이지네이션됩니다. 각 댓글의 대댓글은 전부 포함됩니다. -- 작성자 정보는 노출되지 않으며, is_mine으로 본인 댓글 여부만 확인 가능합니다. +- 작성자 닉네임/프로필 이미지는 카카오 로그인 정보 기준이며, is_mine으로 본인 댓글 여부도 확인 가능합니다. - 삭제된 댓글은 content=null로 노출됩니다 (대댓글이 있는 경우). """, response_model=PaginatedResponse[CommentItem], diff --git a/app/comment/models.py b/app/comment/models.py index d184be1..ce3a923 100644 --- a/app/comment/models.py +++ b/app/comment/models.py @@ -28,7 +28,8 @@ class Comment(Base): 2-depth 구조 (최상위 댓글 + 대댓글 1단계). parent_id가 NULL이면 최상위 댓글, 값이 있으면 대댓글. - 작성자(user_uuid)는 DB에 저장하지만 API 응답에는 미노출 (익명 정책). + 작성자 닉네임은 카카오 로그인 정보를 작성 시점에 그대로 저장한 스냅샷이며, + 프로필 이미지는 별도 컬럼 없이 응답 시 User 테이블을 조인해 최신값을 조회한다. **ADO2 영상과 썰박스 콘텐츠를 모두 담는다.** 대상은 `video_id` 또는 `content_id` 중 **정확히 하나**만 채워지며, 이를 DB `CHECK` 로 강제한다. MySQL 은 하나의 FK 가 @@ -84,7 +85,7 @@ class Comment(Base): comment="NULL=최상위 댓글, 값=대댓글의 부모 id", ) nickname: Mapped[Optional[str]] = mapped_column( - String(50), nullable=True, comment="댓글 작성자 닉네임 (null이면 익명)" + String(50), nullable=True, comment="댓글 작성자 카카오 닉네임 스냅샷 (null이면 익명)" ) content: Mapped[str] = mapped_column( String(100), nullable=False, comment="댓글 본문 (한글 기준 100자 이내)" diff --git a/app/comment/schemas/comment_schema.py b/app/comment/schemas/comment_schema.py index dc6abb3..eec891e 100644 --- a/app/comment/schemas/comment_schema.py +++ b/app/comment/schemas/comment_schema.py @@ -5,7 +5,6 @@ from pydantic import BaseModel, Field class CommentCreateRequest(BaseModel): - nickname: Optional[str] = Field(None, min_length=1, max_length=50, description="작성자 닉네임 (미입력 시 익명)") content: str = Field(..., min_length=1, max_length=100, description="댓글 본문 (한글 기준 100자 이내)") parent_id: Optional[int] = Field(None, description="대댓글일 때만 부모 댓글 id") @@ -14,7 +13,8 @@ class ReplyItem(BaseModel): """대댓글 응답""" id: int = Field(..., description="댓글 고유 ID") - nickname: str = Field(..., description="작성자 닉네임 (미입력 시 '익명')") + nickname: str = Field(..., description="작성자 닉네임 (카카오 닉네임, 미보유 시 '익명')") + profile_image_url: Optional[str] = Field(None, description="작성자 프로필 이미지 URL (카카오 프로필, 로그인 시점 기준 최신값)") content: Optional[str] = Field(None, description="본문 (소프트 삭제된 경우 null)") is_deleted: bool = Field(..., description="삭제 여부") is_mine: bool = Field(..., description="현재 로그인 사용자의 댓글 여부") @@ -25,7 +25,8 @@ class CommentItem(BaseModel): """최상위 댓글 응답 — replies 포함""" id: int = Field(..., description="댓글 고유 ID") - nickname: str = Field(..., description="작성자 닉네임 (미입력 시 '익명')") + nickname: str = Field(..., description="작성자 닉네임 (카카오 닉네임, 미보유 시 '익명')") + profile_image_url: Optional[str] = Field(None, description="작성자 프로필 이미지 URL (카카오 프로필, 로그인 시점 기준 최신값)") content: Optional[str] = Field(None, description="본문 (소프트 삭제된 경우 null)") is_deleted: bool = Field(..., description="삭제 여부") is_mine: bool = Field(..., description="현재 로그인 사용자의 댓글 여부") @@ -35,7 +36,8 @@ class CommentItem(BaseModel): class CommentCreateResponse(BaseModel): id: int = Field(..., description="생성된 댓글 고유 ID") - nickname: str = Field(..., description="작성자 닉네임 (미입력 시 '익명')") + nickname: str = Field(..., description="작성자 닉네임 (카카오 닉네임, 미보유 시 '익명')") + profile_image_url: Optional[str] = Field(None, description="작성자 프로필 이미지 URL (카카오 프로필)") parent_id: Optional[int] = Field(None, description="부모 댓글 id (대댓글인 경우)") content: str = Field(..., description="댓글 본문") created_at: datetime = Field(..., description="작성 일시") diff --git a/app/comment/services/comment.py b/app/comment/services/comment.py index fee5dbb..dc041b9 100644 --- a/app/comment/services/comment.py +++ b/app/comment/services/comment.py @@ -8,6 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.comment.models import Comment from app.comment.schemas.comment_schema import CommentItem, ReplyItem from app.ssulbox.models import SsulContent +from app.user.models import User from app.utils.pagination import PaginatedResponse from app.video.models import Video @@ -78,6 +79,7 @@ def _build_comment_items( parents: list, replies_map: dict, current_user_uuid: Optional[str], + profile_image_map: dict, ) -> List[CommentItem]: items = [] for c in parents: @@ -86,6 +88,7 @@ def _build_comment_items( ReplyItem( id=r.id, nickname=r.nickname or "익명", + profile_image_url=profile_image_map.get(r.user_uuid), content=None if r.is_deleted else r.content, is_deleted=r.is_deleted, is_mine=(current_user_uuid == r.user_uuid) if current_user_uuid else False, @@ -97,6 +100,7 @@ def _build_comment_items( CommentItem( id=c.id, nickname=c.nickname or "익명", + profile_image_url=profile_image_map.get(c.user_uuid), content=None if c.is_deleted else c.content, is_deleted=c.is_deleted, is_mine=(current_user_uuid == c.user_uuid) if current_user_uuid else False, @@ -111,7 +115,7 @@ async def create_comment( session: AsyncSession, video_id: int, user_uuid: str, - nickname: str, + nickname: Optional[str], content: str, parent_id: Optional[int], content_type: ContentType = "video", @@ -181,6 +185,7 @@ async def list_comments( parents = (await session.execute(parents_q)).scalars().all() replies_map: dict = defaultdict(list) + replies: list = [] if parents: parent_ids = [c.id for c in parents] replies_q = ( @@ -195,7 +200,16 @@ async def list_comments( for r in replies: replies_map[r.parent_id].append(r) - items = _build_comment_items(list(parents), replies_map, current_user_uuid) + # 작성자 프로필 이미지는 스냅샷을 저장하지 않고, 응답 시 User 테이블을 조인해 최신값을 조회한다. + user_uuids = {c.user_uuid for c in parents} | {r.user_uuid for r in replies} + profile_image_map: dict = {} + if user_uuids: + profile_q = select(User.user_uuid, User.profile_image_url).where( + User.user_uuid.in_(user_uuids) + ) + profile_image_map = {uuid: url for uuid, url in (await session.execute(profile_q)).all()} + + items = _build_comment_items(list(parents), replies_map, current_user_uuid, profile_image_map) return PaginatedResponse.create( items=items, diff --git a/app/social/api/routers/v1/seo.py b/app/social/api/routers/v1/seo.py index f92246e..1ebe4c6 100644 --- a/app/social/api/routers/v1/seo.py +++ b/app/social/api/routers/v1/seo.py @@ -7,7 +7,7 @@ SEO 관련 엔드포인트를 제공합니다. import logging -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.ext.asyncio import AsyncSession from app.database.session import get_session @@ -32,9 +32,21 @@ async def youtube_seo_description( current_user: User = Depends(get_current_user), session: AsyncSession = Depends(get_session), ) -> YoutubeDescriptionResponse: + if request_body.content_type == "ssul": + content_id = request_body.video_id + if content_id is None: + try: + content_id = int(request_body.task_id) + except (TypeError, ValueError): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="썰박스 SEO 에는 video_id 또는 숫자 task_id 가 필요합니다.", + ) + return await seo_service.get_ssul_seo(content_id, current_user, session) + return await seo_service.get_youtube_seo_description( - request_body.task_id, current_user, session, - content_type=request_body.content_type, + video_id=request_body.video_id, + task_id=request_body.task_id, ) diff --git a/app/social/constants.py b/app/social/constants.py index f6b1264..fda01bf 100644 --- a/app/social/constants.py +++ b/app/social/constants.py @@ -95,8 +95,6 @@ YOUTUBE_SCOPES = [ "https://www.googleapis.com/auth/userinfo.profile", # 사용자 프로필 ] -YOUTUBE_SEO_HASH = "SEO_Describtion_YT" - # ============================================================================= # Instagram/Facebook OAuth Scopes (추후 구현) # ============================================================================= diff --git a/app/social/schemas/seo_schema.py b/app/social/schemas/seo_schema.py index ee5dee3..9ee71a4 100644 --- a/app/social/schemas/seo_schema.py +++ b/app/social/schemas/seo_schema.py @@ -2,25 +2,36 @@ 소셜 SEO 관련 Pydantic 스키마 """ -from typing import Literal +from typing import Literal, Optional -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, model_validator class YoutubeDescriptionRequest(BaseModel): """유튜브 SEO Description 제안 요청""" - # 썰박스 콘텐츠의 SEO 를 요청할 때는 "ssul" + task_id 자리에 ssul_content.id. - # 종류를 안 밝히면 video 로 간주된다 (기존 호출 호환). content_type: Literal["video", "ssul"] = Field( default="video", description="콘텐츠 종류" ) - task_id: str = Field(..., description="작업 고유 식별자 (ssul 이면 ssul_content.id)") + video_id: Optional[int] = Field( + None, description="ADO2 video.id 또는 썰박스 ssul_content.id" + ) + task_id: Optional[str] = Field( + None, + description="ADO2 작업 UUID. 썰박스는 ssul_content.id 문자열도 허용", + ) + + @model_validator(mode="after") + def require_identifier(self) -> "YoutubeDescriptionRequest": + if self.video_id is None and not self.task_id: + raise ValueError("video_id 또는 task_id 가 필요합니다.") + return self model_config = ConfigDict( json_schema_extra={ "example": { - "task_id": "019c739f-65fc-7d15-8c88-b31be00e588e" + "content_type": "video", + "video_id": 123, } } ) diff --git a/app/social/services/seo_service.py b/app/social/services/seo_service.py index 059cf62..57d3463 100644 --- a/app/social/services/seo_service.py +++ b/app/social/services/seo_service.py @@ -1,93 +1,84 @@ """ 유튜브 SEO 서비스 -SEO description 생성 및 Redis 캐싱 로직을 처리합니다. +ADO2 영상은 제목/설명/해시태그를 video 테이블에 저장합니다. +썰박스는 별도 프롬프트로 생성합니다. """ import json import logging -from fastapi import HTTPException -from redis.asyncio import Redis +from fastapi import HTTPException, status from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from config import db_settings from app.home.models import MarketingIntel, Project -from app.social.constants import YOUTUBE_SEO_HASH from app.social.schemas import YoutubeDescriptionResponse +from app.social.services.sns_metadata import apply_sns_metadata, has_stored_sns_metadata +from app.ssulbox.models import SsulContent from app.user.models import User -from app.utils.prompts.chatgpt_prompt import ChatgptService -from app.utils.prompts.prompts import yt_upload_prompt +from app.video.models import Video logger = logging.getLogger(__name__) -redis_seo_client = Redis( - host=db_settings.REDIS_HOST, - port=db_settings.REDIS_PORT, - db=0, - decode_responses=True, -) - class SeoService: """유튜브 SEO 비즈니스 로직 서비스""" async def get_youtube_seo_description( self, - task_id: str, current_user: User, session: AsyncSession, - content_type: str = "video", + video_id: int | None = None, + task_id: str | None = None, ) -> YoutubeDescriptionResponse: - """ - 유튜브 SEO description 생성 + """저장된 SNS 메타데이터를 반환하거나, 없으면 생성 후 video에 저장합니다.""" + video = None + if video_id is not None: + video = await self._get_owned_video(video_id, current_user.user_uuid, session) + elif task_id: + video = await self._get_owned_video_by_task( + task_id, current_user.user_uuid, session + ) - Redis 캐시 확인 후 miss이면 GPT로 생성하고 캐싱. + if video is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="해당하는 영상을 찾을 수 없습니다.", + ) - content_type="ssul" 이면 task_id 자리에 ssul_content.id(문자열)가 온다. - 캐시 키에 종류 접두를 붙인다 — ADO2 task_id(UUID)와 썰박스 id(숫자)는 - 형식이 달라 실제로 겹치진 않지만, 형식 우연에 기대지 않는다. - """ - cache_key = f"ssul:{task_id}" if content_type == "ssul" else task_id logger.info( - f"[SEO_SERVICE] Try Cache - user: {current_user.user_uuid} / key: {cache_key}" + f"[SEO_SERVICE] Load metadata - user: {current_user.user_uuid} / video_id: {video.id}" ) - cached = await self._get_from_redis(cache_key) - if cached: - return cached - - logger.info(f"[SEO_SERVICE] Cache miss - user: {current_user.user_uuid}") - if content_type == "ssul": - result = await self._generate_ssul_seo(task_id, current_user, session) - else: - result = await self._generate_seo_description(task_id, current_user, session) - await self._set_to_redis(cache_key, result) + if has_stored_sns_metadata(video): + return self._response_from_video(video) + result = await self.generate_and_save_for_video(video.id, session) + if result is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"video_id '{video.id}'에 해당하는 영상을 찾을 수 없습니다.", + ) + await session.commit() return result - async def _generate_ssul_seo( + async def get_ssul_seo( self, - content_id: str, + content_id: int, current_user: User, session: AsyncSession, ) -> YoutubeDescriptionResponse: - """썰박스 콘텐츠용 SEO 생성 — ADO2 와 **다른 프롬프트**(시트 ssul_upload)를 쓴다. - - ADO2 는 업장 마케팅 분석 보고서 기반의 광고 영상 SEO 지만, 썰박스는 - 병맛 역사 썰툰이라 톤이 완전히 다르다. 태그도 GPT 가 함께 만든다 - (ADO2 처럼 재사용할 마케팅 분석 target_keywords 가 없다). - """ + """썰박스 콘텐츠용 SEO 생성 — ADO2 와 다른 프롬프트(시트 ssul_upload)를 쓴다.""" from app.ssulbox.constants import SCENARIO_NAMES - from app.ssulbox.models import SsulContent + from app.utils.prompts.chatgpt_prompt import ChatgptService from app.utils.prompts.prompts import get_ssul_upload_prompt try: content = ( await session.execute( select(SsulContent).where( - SsulContent.id == int(content_id), + SsulContent.id == content_id, SsulContent.user_uuid == current_user.user_uuid, SsulContent.is_deleted.is_(False), ) @@ -125,31 +116,97 @@ class SeoService: detail=f"썰박스 SEO 생성에 실패했습니다. : {str(e)}", ) + async def generate_and_save_for_video( + self, + video_id: int, + session: AsyncSession, + ) -> YoutubeDescriptionResponse | None: + """GPT로 SEO를 생성해 지정한 video 행에 저장합니다. 워커/온디맨드 공용.""" + video_result = await session.execute(select(Video).where(Video.id == video_id)) + video = video_result.scalar_one_or_none() + if video is None: + logger.warning(f"[SEO_SERVICE] Video NOT FOUND - video_id: {video_id}") + return None + + if has_stored_sns_metadata(video): + return self._response_from_video(video) + + result = await self._generate_seo_description(video.task_id, session) + apply_sns_metadata(video, result.title, result.description, result.keywords) + await session.flush() + logger.info(f"[SEO_SERVICE] Saved metadata - video_id: {video_id}") + return result + + async def _get_owned_video( + self, + video_id: int, + user_uuid: str, + session: AsyncSession, + ) -> Video | None: + result = await session.execute( + select(Video) + .join(Project, Project.id == Video.project_id) + .where( + Video.id == video_id, + Project.user_uuid == user_uuid, + Video.is_deleted.is_(False), + ) + ) + return result.scalar_one_or_none() + + async def _get_owned_video_by_task( + self, + task_id: str, + user_uuid: str, + session: AsyncSession, + ) -> Video | None: + result = await session.execute( + select(Video) + .join(Project, Project.id == Video.project_id) + .where( + Video.task_id == task_id, + Project.user_uuid == user_uuid, + Video.is_deleted.is_(False), + ) + .order_by(Video.created_at.desc()) + .limit(1) + ) + return result.scalar_one_or_none() + async def _generate_seo_description( self, task_id: str, - current_user: User, session: AsyncSession, ) -> YoutubeDescriptionResponse: """GPT를 사용하여 SEO description 생성""" - logger.info(f"[SEO_SERVICE] Generating SEO - user: {current_user.user_uuid}") + from app.utils.prompts.chatgpt_prompt import ChatgptService + from app.utils.prompts.prompts import yt_upload_prompt + + logger.info(f"[SEO_SERVICE] Generating SEO - task_id: {task_id}") try: project_result = await session.execute( select(Project) - .where( - Project.task_id == task_id, - Project.user_uuid == current_user.user_uuid, - ) + .where(Project.task_id == task_id) .order_by(Project.created_at.desc()) .limit(1) ) project = project_result.scalar_one_or_none() + if project is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"task_id '{task_id}'에 해당하는 Project를 찾을 수 없습니다.", + ) marketing_result = await session.execute( select(MarketingIntel).where(MarketingIntel.id == project.marketing_intelligence) ) marketing_intelligence = marketing_result.scalar_one_or_none() + if marketing_intelligence is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="마케팅 인텔리전스를 찾을 수 없습니다.", + ) hashtags = marketing_intelligence.intel_result["target_keywords"] @@ -161,12 +218,13 @@ class SeoService: ), "language": project.language, "target_keywords": hashtags, - "industry": project.industry or "", # 크롤 시 분류해 Project에 저장한 업종 enum + "industry": project.industry or "", } - # 업종 분기는 프롬프트 내부 {industry}로 처리하므로 단일 프롬프트 사용 chatgpt = ChatgptService(timeout=180) - yt_seo_output = await chatgpt.generate_structured_output(yt_upload_prompt, yt_seo_input_data) + yt_seo_output = await chatgpt.generate_structured_output( + yt_upload_prompt, yt_seo_input_data + ) return YoutubeDescriptionResponse( title=yt_seo_output.title, @@ -174,6 +232,8 @@ class SeoService: keywords=hashtags, ) + except HTTPException: + raise except Exception as e: logger.error(f"[SEO_SERVICE] EXCEPTION - error: {e}") raise HTTPException( @@ -181,18 +241,12 @@ class SeoService: detail=f"유튜브 SEO 생성에 실패했습니다. : {str(e)}", ) - async def _get_from_redis(self, task_id: str) -> YoutubeDescriptionResponse | None: - field = f"task_id:{task_id}" - yt_seo_info = await redis_seo_client.hget(YOUTUBE_SEO_HASH, field) - if yt_seo_info: - return YoutubeDescriptionResponse(**json.loads(yt_seo_info)) - return None - - async def _set_to_redis(self, task_id: str, yt_seo: YoutubeDescriptionResponse) -> None: - field = f"task_id:{task_id}" - yt_seo_info = json.dumps(yt_seo.model_dump(), ensure_ascii=False) - await redis_seo_client.hset(YOUTUBE_SEO_HASH, field, yt_seo_info) - await redis_seo_client.expire(YOUTUBE_SEO_HASH, 3600) + def _response_from_video(self, video: Video) -> YoutubeDescriptionResponse: + return YoutubeDescriptionResponse( + title=video.title or "", + description=video.description or "", + keywords=list(video.hashtags or []), + ) seo_service = SeoService() diff --git a/app/social/services/sns_metadata.py b/app/social/services/sns_metadata.py new file mode 100644 index 0000000..3294cae --- /dev/null +++ b/app/social/services/sns_metadata.py @@ -0,0 +1,36 @@ +"""SNS 업로드용 영상 메타데이터 비교/반영 헬퍼.""" + +from app.video.models import Video + + +def has_stored_sns_metadata(video: Video) -> bool: + """video 행에 SNS 제목이 이미 저장되어 있는지 확인합니다.""" + return bool(video.title) + + +def sns_metadata_changed( + video: Video, + title: str, + description: str | None, + tags: list[str] | None, +) -> bool: + """게시 폼 값이 저장된 SNS 메타데이터와 다른지 비교합니다.""" + stored_tags = list(video.hashtags or []) + incoming_tags = list(tags or []) + return ( + (video.title or "") != title + or (video.description or "") != (description or "") + or stored_tags != incoming_tags + ) + + +def apply_sns_metadata( + video: Video, + title: str, + description: str | None, + hashtags: list[str] | None, +) -> None: + """video 행에 SNS 메타데이터를 반영합니다.""" + video.title = title + video.description = description + video.hashtags = list(hashtags or []) diff --git a/app/social/services/upload_service.py b/app/social/services/upload_service.py index 023ffda..b252f83 100644 --- a/app/social/services/upload_service.py +++ b/app/social/services/upload_service.py @@ -26,6 +26,7 @@ from app.social.schemas import ( SocialUploadRequest, ) from app.social.services.account_service import SocialAccountService +from app.social.services.sns_metadata import apply_sns_metadata, sns_metadata_changed from app.social.worker.upload_task import process_social_upload from app.user.models import User from app.home.models import Project @@ -113,6 +114,14 @@ class SocialUploadService: detail="영상이 아직 준비되지 않았습니다. 영상 생성이 완료된 후 시도해주세요.", ) + if body.content_type != "ssul" and sns_metadata_changed( + video, body.title, body.description, body.tags + ): + apply_sns_metadata(video, body.title, body.description, body.tags) + logger.info( + f"[UPLOAD_SERVICE] video SNS 메타데이터 갱신 - video_id: {body.video_id}" + ) + # 2. 소셜 계정 조회 및 소유권 검증 account = await self._account_service.get_account_by_id( user_uuid=current_user.user_uuid, diff --git a/app/utils/video_poster.py b/app/utils/video_poster.py new file mode 100644 index 0000000..e0fe665 --- /dev/null +++ b/app/utils/video_poster.py @@ -0,0 +1,173 @@ +"""영상 파일에서 SNS 공유용 포스터 이미지를 생성하고 저장합니다.""" + +import asyncio +from pathlib import Path + +from app.utils.logger import get_logger +from app.utils.upload_blob_as_request import AzureBlobUploader + +logger = get_logger("video_poster") + +FFMPEG_TIMEOUT_SECONDS = 30.0 +FFMPEG_CLEANUP_TIMEOUT_SECONDS = 5.0 +_STDERR_LOG_LIMIT = 500 + + +async def _kill_and_wait(process: asyncio.subprocess.Process) -> None: + """실행 중인 ffmpeg 프로세스를 종료하고 자원을 회수합니다.""" + try: + process.kill() + except ProcessLookupError: + pass + except Exception as exc: + logger.warning( + "[video_poster] ffmpeg 프로세스 종료에 실패했습니다: %s", + exc, + ) + + try: + await asyncio.wait_for( + process.wait(), + timeout=FFMPEG_CLEANUP_TIMEOUT_SECONDS, + ) + except TimeoutError: + logger.warning( + "[video_poster] 종료한 ffmpeg 프로세스 회수 시간이 초과되었습니다 " + "(timeout=%ss)", + FFMPEG_CLEANUP_TIMEOUT_SECONDS, + ) + except Exception as exc: + logger.warning( + "[video_poster] ffmpeg 프로세스 회수에 실패했습니다: %s", + exc, + ) + + +def _format_stderr(stderr: bytes) -> str: + """ffmpeg 표준 오류를 로그에 안전한 길이의 문자열로 변환합니다.""" + return stderr.decode("utf-8", errors="replace").strip()[-_STDERR_LOG_LIMIT:] + + +async def extract_first_frame(video_path: str | Path) -> bytes | None: + """로컬 영상의 첫 프레임을 JPEG 바이트로 추출하고 실패 시 ``None``을 반환합니다.""" + process: asyncio.subprocess.Process | None = None + + try: + process = await asyncio.create_subprocess_exec( + "ffmpeg", + "-nostdin", + "-hide_banner", + "-loglevel", + "error", + "-i", + str(video_path), + "-frames:v", + "1", + "-f", + "image2pipe", + "-c:v", + "mjpeg", + "pipe:1", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + + try: + stdout, stderr = await asyncio.wait_for( + process.communicate(), + timeout=FFMPEG_TIMEOUT_SECONDS, + ) + except TimeoutError: + await _kill_and_wait(process) + logger.warning( + "[video_poster] ffmpeg 첫 프레임 추출 시간이 초과되었습니다 " + "(path=%s, timeout=%ss)", + video_path, + FFMPEG_TIMEOUT_SECONDS, + ) + return None + + if process.returncode != 0: + logger.warning( + "[video_poster] ffmpeg 첫 프레임 추출에 실패했습니다 " + "(path=%s, returncode=%s, stderr=%s)", + video_path, + process.returncode, + _format_stderr(stderr), + ) + return None + + if not stdout: + logger.warning( + "[video_poster] ffmpeg가 빈 이미지를 반환했습니다 (path=%s)", + video_path, + ) + return None + + return stdout + + except asyncio.CancelledError: + if process is not None: + await _kill_and_wait(process) + raise + except Exception as exc: + if process is not None: + await _kill_and_wait(process) + logger.warning( + "[video_poster] 첫 프레임 추출 중 오류가 발생했습니다 " + "(path=%s, error=%s: %s)", + video_path, + type(exc).__name__, + exc, + ) + return None + + +async def generate_and_store_poster( + *, + video_path: str | Path, + user_uuid: str, + task_id: str, + file_stem: str, +) -> str | None: + """첫 프레임을 Blob에 저장하고 공개 URL을 반환하며, 실패 시 ``None``을 반환합니다.""" + try: + image_bytes = await extract_first_frame(video_path) + if image_bytes is None: + return None + + uploader = AzureBlobUploader(user_uuid=user_uuid, task_id=task_id) + uploaded = await uploader.upload_image_bytes( + image_bytes, + f"{file_stem}.jpg", + ) + if not uploaded: + logger.warning( + "[video_poster] 포스터 Blob 업로드에 실패했습니다 " + "(path=%s, task_id=%s)", + video_path, + task_id, + ) + return None + + if not uploader.public_url: + logger.warning( + "[video_poster] 포스터 업로드 후 공개 URL이 비어 있습니다 " + "(path=%s, task_id=%s)", + video_path, + task_id, + ) + return None + + return uploader.public_url + + except Exception as exc: + logger.warning( + "[video_poster] 포스터 생성 또는 저장 중 오류가 발생했습니다 " + "(path=%s, task_id=%s, error=%s: %s)", + video_path, + task_id, + type(exc).__name__, + exc, + ) + return None diff --git a/app/video/api/routers/v1/video.py b/app/video/api/routers/v1/video.py index 1131556..cee90af 100644 --- a/app/video/api/routers/v1/video.py +++ b/app/video/api/routers/v1/video.py @@ -15,7 +15,8 @@ Video API Router 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 select from sqlalchemy.ext.asyncio import AsyncSession @@ -60,9 +61,10 @@ from app.video.worker.video_task import ( _fail_and_refund, download_and_upload_video_to_blob, ) +from app.video.services.share_page import build_video_share_html, get_video_share_data -from config import creatomate_settings +from config import creatomate_settings, prj_settings logger = get_logger("video") @@ -956,6 +958,7 @@ async def get_all_videos( video_id=it.id, store_name=it.store_name, result_movie_url=it.movie_url, + poster_url=it.poster_url, created_at=it.created_at, like_count=it.like_count, is_liked_by_me=it.is_liked_by_me, @@ -1077,6 +1080,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="단일 영상 상세 조회", @@ -1153,6 +1193,7 @@ async def get_video_detail( return VideoDetailResponse( video_id=video.id, result_movie_url=video.result_movie_url, + poster_url=video.poster_url, store_name=project.store_name, region=project.region or _extract_region_from_address(project.detail_region_info), created_at=video.created_at, diff --git a/app/video/models.py b/app/video/models.py index 8fe853c..40c17a8 100644 --- a/app/video/models.py +++ b/app/video/models.py @@ -10,9 +10,11 @@ from sqlalchemy import ( Index, Integer, String, + Text, UniqueConstraint, func, ) +from sqlalchemy.dialects.mysql import JSON from sqlalchemy.orm import Mapped, mapped_column, relationship from app.database.session import Base @@ -40,6 +42,10 @@ class Video(Base): task_id: 영상 생성 작업의 고유 식별자 (UUID7 형식) status: 처리 상태 (pending, processing, completed, failed 등) result_movie_url: 생성된 영상 URL (S3, CDN 경로) + poster_url: 영상 첫 프레임 포스터 이미지 URL (SNS 공유 og:image용) + title: SNS 업로드 제목 + description: SNS 업로드 설명 + hashtags: SNS 해시태그 목록 created_at: 생성 일시 (자동 설정) Relationships: @@ -117,6 +123,30 @@ class Video(Base): comment="생성된 영상 URL", ) + poster_url: Mapped[Optional[str]] = mapped_column( + String(2048), + nullable=True, + comment="영상 첫 프레임 포스터 이미지 URL (SNS 공유용)", + ) + + title: Mapped[Optional[str]] = mapped_column( + String(100), + nullable=True, + comment="SNS 업로드 제목", + ) + + description: Mapped[Optional[str]] = mapped_column( + Text, + nullable=True, + comment="SNS 업로드 설명", + ) + + hashtags: Mapped[Optional[list]] = mapped_column( + JSON, + nullable=True, + comment="SNS 해시태그 목록", + ) + is_deleted: Mapped[bool] = mapped_column( Boolean, nullable=False, diff --git a/app/video/schemas/video_schema.py b/app/video/schemas/video_schema.py index b9a21a9..d6114e1 100644 --- a/app/video/schemas/video_schema.py +++ b/app/video/schemas/video_schema.py @@ -5,7 +5,7 @@ Video API Schemas """ from datetime import datetime -from typing import Any, Dict, Literal, Optional +from typing import Any, Dict, List, Literal, Optional from pydantic import BaseModel, ConfigDict, Field @@ -148,6 +148,7 @@ class VideoListItem(BaseModel): "region": "군산", "task_id": "019123ab-cdef-7890-abcd-ef1234567890", "result_movie_url": "http://localhost:8000/media/2025-01-15/video.mp4", + "poster_url": "http://localhost:8000/media/2025-01-15/video.jpg", "created_at": "2025-01-15T12:00:00" } """ @@ -169,9 +170,17 @@ class VideoListItem(BaseModel): description="작업 고유 식별자 (ADO2 전용. 썰박스는 개념이 없어 빈 문자열)", ) result_movie_url: Optional[str] = Field(None, description="영상 결과 URL") + poster_url: Optional[str] = Field(None, description="영상 첫 프레임 포스터 이미지 URL") + title: Optional[str] = Field(None, description="SNS 업로드 제목") + description: Optional[str] = Field(None, description="SNS 업로드 설명") + hashtags: Optional[List[str]] = Field(None, description="SNS 해시태그 목록") created_at: Optional[datetime] = Field(None, description="생성 일시") like_count: int = Field(0, description="좋아요 수") comment_count: int = Field(0, description="댓글 수 (대댓글 포함)") + is_liked_by_me: bool = Field( + False, + description="현재 로그인 사용자가 좋아요를 눌렀는지", + ) class VideoThumbnailItem(BaseModel): @@ -190,7 +199,8 @@ class VideoThumbnailItem(BaseModel): ) video_id: int = Field(..., description="콘텐츠 고유 ID (type 안에서만 유일)") store_name: str = Field(..., description="업체명") - result_movie_url: str = Field(..., description="영상 URL — 프론트에서