from datetime import datetime from typing import TYPE_CHECKING, List, Optional from sqlalchemy import ( BigInteger, Boolean, CheckConstraint, DateTime, ForeignKey, 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 if TYPE_CHECKING: from app.comment.models import Comment from app.home.models import Project from app.lyric.models import Lyric from app.song.models import Song from app.user.models import User class Video(Base): """ 영상 결과 테이블 최종 생성된 영상의 결과 URL을 저장합니다. Creatomate 서비스를 통해 이미지와 노래를 결합한 영상 결과입니다. Attributes: id: 고유 식별자 (자동 증가) project_id: 연결된 Project의 id (외래키) lyric_id: 연결된 Lyric의 id (외래키) song_id: 연결된 Song의 id (외래키) 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: project: 연결된 Project lyric: 연결된 Lyric song: 연결된 Song comments: 영상 댓글 목록 likes: 영상 좋아요 목록 """ __tablename__ = "video" __table_args__ = ( Index("idx_video_task_id", "task_id"), Index("idx_video_project_id", "project_id"), Index("idx_video_lyric_id", "lyric_id"), Index("idx_video_song_id", "song_id"), Index("idx_video_is_deleted", "is_deleted"), { "mysql_engine": "InnoDB", "mysql_charset": "utf8mb4", "mysql_collate": "utf8mb4_unicode_ci", }, ) id: Mapped[int] = mapped_column( Integer, primary_key=True, nullable=False, autoincrement=True, comment="고유 식별자", ) project_id: Mapped[int] = mapped_column( Integer, ForeignKey("project.id", ondelete="CASCADE"), nullable=False, comment="연결된 Project의 id", ) lyric_id: Mapped[int] = mapped_column( Integer, ForeignKey("lyric.id", ondelete="CASCADE"), nullable=False, comment="연결된 Lyric의 id", ) song_id: Mapped[int] = mapped_column( Integer, ForeignKey("song.id", ondelete="CASCADE"), nullable=False, comment="연결된 Song의 id", ) task_id: Mapped[str] = mapped_column( String(36), nullable=False, comment="영상 생성 작업 고유 식별자 (UUID7)", ) creatomate_render_id: Mapped[Optional[str]] = mapped_column( String(64), nullable=True, comment="Creatomate API 렌더 ID", ) status: Mapped[str] = mapped_column( String(50), nullable=False, comment="처리 상태 (processing, completed, failed)", ) result_movie_url: Mapped[Optional[str]] = mapped_column( String(2048), nullable=True, 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, default=False, comment="소프트 삭제 여부 (True: 삭제됨)", ) created_at: Mapped[datetime] = mapped_column( DateTime, nullable=False, server_default=func.now(), comment="생성 일시", ) # Relationships project: Mapped["Project"] = relationship( "Project", back_populates="videos", ) lyric: Mapped["Lyric"] = relationship( "Lyric", back_populates="videos", ) song: Mapped["Song"] = relationship( "Song", back_populates="videos", ) # comment/video_reaction 이 video_id 와 content_id 두 FK 를 갖게 되어 # 어느 쪽으로 조인할지 명시해야 한다(없으면 AmbiguousForeignKeysError). comments: Mapped[List["Comment"]] = relationship( "Comment", foreign_keys="Comment.video_id", back_populates="video", cascade="all, delete-orphan", lazy="noload", ) reactions: Mapped[List["VideoReaction"]] = relationship( "VideoReaction", foreign_keys="VideoReaction.video_id", back_populates="video", cascade="all, delete-orphan", lazy="noload", ) def __repr__(self) -> str: def truncate(value: str | None, max_len: int = 10) -> str: if value is None: return "None" return (value[:max_len] + "...") if len(value) > max_len else value return ( f"" ) class VideoReaction(Base): """ 영상 반응 테이블 사용자가 영상에 반응(현재는 좋아요)을 남기면 생성, 다시 누르면 삭제(토글). 향후 reaction_type 컬럼 추가로 다양한 반응 종류 확장 가능. **ADO2 영상과 썰박스 콘텐츠를 모두 담는다.** 대상은 `video_id` 또는 `content_id` 중 **정확히 하나**만 채워지며 DB `CHECK` 로 강제한다. 1인 1회 보장은 유니크 두 개로 나눠서 한다. MySQL 은 NULL 을 서로 다른 값으로 취급하므로, 썰박스 행(video_id IS NULL)이 아무리 많아도 `uq_video_reaction_user_video` 에 걸리지 않는다 — 각자 자기 유니크만 지킨다. """ __tablename__ = "video_reaction" __table_args__ = ( CheckConstraint( "(video_id IS NULL) <> (content_id IS NULL)", name="ck_video_reaction_one_target", ), UniqueConstraint("user_uuid", "video_id", name="uq_video_reaction_user_video"), UniqueConstraint( "user_uuid", "content_id", name="uq_video_reaction_user_content" ), Index("idx_video_reaction_video_id", "video_id"), # 카운트 집계가 content_id 로 묶으므로 선행 컬럼 인덱스가 필요하다 # (유니크는 user_uuid 가 앞이라 이 용도로 못 쓴다). Index("idx_video_reaction_content_id", "content_id"), Index("idx_video_reaction_user_uuid", "user_uuid"), { "mysql_engine": "InnoDB", "mysql_charset": "utf8mb4", "mysql_collate": "utf8mb4_unicode_ci", }, ) id: Mapped[int] = mapped_column( Integer, primary_key=True, autoincrement=True, comment="고유 식별자" ) # 대상은 아래 둘 중 **정확히 하나**만 채워진다 (ck_video_reaction_one_target). video_id: Mapped[Optional[int]] = mapped_column( Integer, ForeignKey("video.id", ondelete="CASCADE"), nullable=True, comment="ADO2 영상 id (썰박스 반응이면 NULL)", ) content_id: Mapped[Optional[int]] = mapped_column( # ssul_content.id 는 BIGINT 다. INT 로 두면 FK 타입 불일치(errno 3780). BigInteger, ForeignKey("ssul_content.id", ondelete="CASCADE"), nullable=True, comment="썰박스 콘텐츠 id (ADO2 반응이면 NULL)", ) user_uuid: Mapped[str] = mapped_column( String(36), ForeignKey("user.user_uuid", ondelete="CASCADE"), nullable=False, comment="반응한 사용자 UUID", ) created_at: Mapped[datetime] = mapped_column( DateTime, nullable=False, server_default=func.now(), comment="반응 일시", ) # 썰박스 반응이면 None 이다. 접근하는 쪽에서 반드시 방어할 것. video: Mapped[Optional["Video"]] = relationship( "Video", foreign_keys=[video_id], back_populates="reactions" ) user: Mapped["User"] = relationship("User", back_populates="video_reactions")