remove endpoint at the video of get_videos
parent
c89e510c98
commit
7f0ae81351
|
|
@ -3,112 +3,6 @@ from typing import Literal, Optional
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
from app.utils.prompts.schemas import MarketingPromptOutput
|
from app.utils.prompts.schemas import MarketingPromptOutput
|
||||||
|
|
||||||
class AttributeInfo(BaseModel):
|
|
||||||
"""음악 속성 정보"""
|
|
||||||
|
|
||||||
genre: str = Field(..., description="음악 장르")
|
|
||||||
vocal: str = Field(..., description="보컬 스타일")
|
|
||||||
tempo: str = Field(..., description="템포")
|
|
||||||
mood: str = Field(..., description="분위기")
|
|
||||||
|
|
||||||
|
|
||||||
class GenerateRequestImg(BaseModel):
|
|
||||||
"""이미지 URL 스키마"""
|
|
||||||
|
|
||||||
url: str = Field(..., description="이미지 URL")
|
|
||||||
name: Optional[str] = Field(None, description="이미지명 (없으면 URL에서 추출)")
|
|
||||||
|
|
||||||
|
|
||||||
class GenerateRequestInfo(BaseModel):
|
|
||||||
"""생성 요청 정보 스키마 (이미지 제외)"""
|
|
||||||
|
|
||||||
customer_name: str = Field(..., description="고객명/가게명")
|
|
||||||
region: str = Field(..., description="지역명")
|
|
||||||
detail_region_info: Optional[str] = Field(None, description="상세 지역 정보")
|
|
||||||
attribute: AttributeInfo = Field(..., description="음악 속성 정보")
|
|
||||||
language: str = Field(
|
|
||||||
default="Korean",
|
|
||||||
description="가사 출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class GenerateRequest(GenerateRequestInfo):
|
|
||||||
"""기본 생성 요청 스키마 (이미지 없음, JSON body)
|
|
||||||
|
|
||||||
이미지 없이 프로젝트 정보만 전달합니다.
|
|
||||||
"""
|
|
||||||
|
|
||||||
model_config = ConfigDict(
|
|
||||||
json_schema_extra={
|
|
||||||
"example": {
|
|
||||||
"customer_name": "스테이 머뭄",
|
|
||||||
"region": "군산",
|
|
||||||
"detail_region_info": "군산 신흥동 말랭이 마을",
|
|
||||||
"attribute": {
|
|
||||||
"genre": "K-Pop",
|
|
||||||
"vocal": "Raspy",
|
|
||||||
"tempo": "110 BPM",
|
|
||||||
"mood": "happy",
|
|
||||||
},
|
|
||||||
"language": "Korean",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class GenerateUrlsRequest(GenerateRequestInfo):
|
|
||||||
"""URL 기반 생성 요청 스키마 (JSON body)
|
|
||||||
|
|
||||||
GenerateRequestInfo를 상속받아 이미지 목록을 추가합니다.
|
|
||||||
"""
|
|
||||||
|
|
||||||
model_config = ConfigDict(
|
|
||||||
json_schema_extra={
|
|
||||||
"example": {
|
|
||||||
"customer_name": "스테이 머뭄",
|
|
||||||
"region": "군산",
|
|
||||||
"detail_region_info": "군산 신흥동 말랭이 마을",
|
|
||||||
"attribute": {
|
|
||||||
"genre": "K-Pop",
|
|
||||||
"vocal": "Raspy",
|
|
||||||
"tempo": "110 BPM",
|
|
||||||
"mood": "happy",
|
|
||||||
},
|
|
||||||
"language": "Korean",
|
|
||||||
"images": [
|
|
||||||
{"url": "https://example.com/images/image_001.jpg"},
|
|
||||||
{"url": "https://example.com/images/image_002.jpg", "name": "외관"},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
images: list[GenerateRequestImg] = Field(
|
|
||||||
..., description="이미지 URL 목록", min_length=1
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class GenerateUploadResponse(BaseModel):
|
|
||||||
"""파일 업로드 기반 생성 응답 스키마"""
|
|
||||||
|
|
||||||
task_id: str = Field(..., description="작업 고유 식별자 (UUID7)")
|
|
||||||
status: Literal["processing", "completed", "failed"] = Field(
|
|
||||||
..., description="작업 상태"
|
|
||||||
)
|
|
||||||
message: str = Field(..., description="응답 메시지")
|
|
||||||
uploaded_count: int = Field(..., description="업로드된 이미지 개수")
|
|
||||||
|
|
||||||
|
|
||||||
class GenerateResponse(BaseModel):
|
|
||||||
"""생성 응답 스키마"""
|
|
||||||
|
|
||||||
task_id: str = Field(..., description="작업 고유 식별자 (UUID7)")
|
|
||||||
status: Literal["processing", "completed", "failed"] = Field(
|
|
||||||
..., description="작업 상태"
|
|
||||||
)
|
|
||||||
message: str = Field(..., description="응답 메시지")
|
|
||||||
|
|
||||||
|
|
||||||
class CrawlingRequest(BaseModel):
|
class CrawlingRequest(BaseModel):
|
||||||
"""크롤링 요청 스키마"""
|
"""크롤링 요청 스키마"""
|
||||||
|
|
||||||
|
|
@ -371,29 +265,6 @@ class ImageUrlItem(BaseModel):
|
||||||
name: Optional[str] = Field(None, description="이미지명 (없으면 URL에서 추출)")
|
name: Optional[str] = Field(None, description="이미지명 (없으면 URL에서 추출)")
|
||||||
|
|
||||||
|
|
||||||
class ImageUploadRequest(BaseModel):
|
|
||||||
"""이미지 업로드 요청 스키마 (JSON body 부분)
|
|
||||||
|
|
||||||
URL 이미지 목록을 전달합니다.
|
|
||||||
바이너리 파일은 multipart/form-data로 별도 전달됩니다.
|
|
||||||
"""
|
|
||||||
|
|
||||||
model_config = ConfigDict(
|
|
||||||
json_schema_extra={
|
|
||||||
"example": {
|
|
||||||
"images": [
|
|
||||||
{"url": "https://example.com/images/image_001.jpg"},
|
|
||||||
{"url": "https://example.com/images/image_002.jpg", "name": "외관"},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
images: Optional[list[ImageUrlItem]] = Field(
|
|
||||||
None, description="외부 이미지 URL 목록"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ImageUploadResultItem(BaseModel):
|
class ImageUploadResultItem(BaseModel):
|
||||||
"""업로드된 이미지 결과 아이템"""
|
"""업로드된 이미지 결과 아이템"""
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ SNS API Schemas
|
||||||
Instagram 업로드 관련 Pydantic 스키마를 정의합니다.
|
Instagram 업로드 관련 Pydantic 스키마를 정의합니다.
|
||||||
"""
|
"""
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any, Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
@ -98,20 +98,6 @@ class Media(BaseModel):
|
||||||
children: Optional[list["Media"]] = None
|
children: Optional[list["Media"]] = None
|
||||||
|
|
||||||
|
|
||||||
class MediaList(BaseModel):
|
|
||||||
"""미디어 목록 응답"""
|
|
||||||
|
|
||||||
data: list[Media] = Field(default_factory=list)
|
|
||||||
paging: Optional[dict[str, Any]] = None
|
|
||||||
|
|
||||||
@property
|
|
||||||
def next_cursor(self) -> Optional[str]:
|
|
||||||
"""다음 페이지 커서"""
|
|
||||||
if self.paging and "cursors" in self.paging:
|
|
||||||
return self.paging["cursors"].get("after")
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
class MediaContainer(BaseModel):
|
class MediaContainer(BaseModel):
|
||||||
"""미디어 컨테이너 상태"""
|
"""미디어 컨테이너 상태"""
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,5 @@
|
||||||
from dataclasses import dataclass, field
|
from typing import Optional
|
||||||
from datetime import datetime
|
|
||||||
from typing import Dict, List, Optional
|
|
||||||
|
|
||||||
from fastapi import Request
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -107,21 +104,6 @@ class GenerateSongResponse(BaseModel):
|
||||||
error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시)")
|
error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시)")
|
||||||
|
|
||||||
|
|
||||||
class PollingSongRequest(BaseModel):
|
|
||||||
"""노래 생성 상태 조회 요청 스키마 (Legacy)
|
|
||||||
|
|
||||||
Note:
|
|
||||||
현재 사용되지 않음. GET /song/status/{song_id} 엔드포인트 사용.
|
|
||||||
|
|
||||||
Example Request:
|
|
||||||
{
|
|
||||||
"task_id": "abc123..."
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
|
|
||||||
task_id: str = Field(..., description="Suno 작업 ID")
|
|
||||||
|
|
||||||
|
|
||||||
class SongClipData(BaseModel):
|
class SongClipData(BaseModel):
|
||||||
"""생성된 노래 클립 정보"""
|
"""생성된 노래 클립 정보"""
|
||||||
|
|
||||||
|
|
@ -234,94 +216,3 @@ class PollingSongResponse(BaseModel):
|
||||||
song_result_url: Optional[str] = Field(
|
song_result_url: Optional[str] = Field(
|
||||||
None, description="노래 결과 URL (Song 테이블 status가 completed일 때 반환)"
|
None, description="노래 결과 URL (Song 테이블 status가 completed일 때 반환)"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# Dataclass Schemas (Legacy)
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class StoreData:
|
|
||||||
id: int
|
|
||||||
created_at: datetime
|
|
||||||
store_name: str
|
|
||||||
store_category: str | None = None
|
|
||||||
store_region: str | None = None
|
|
||||||
store_address: str | None = None
|
|
||||||
store_phone_number: str | None = None
|
|
||||||
store_info: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class AttributeData:
|
|
||||||
id: int
|
|
||||||
attr_category: str
|
|
||||||
attr_value: str
|
|
||||||
created_at: datetime
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class SongSampleData:
|
|
||||||
id: int
|
|
||||||
ai: str
|
|
||||||
ai_model: str
|
|
||||||
sample_song: str
|
|
||||||
season: str | None = None
|
|
||||||
num_of_people: int | None = None
|
|
||||||
people_category: str | None = None
|
|
||||||
genre: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class PromptTemplateData:
|
|
||||||
id: int
|
|
||||||
prompt: str
|
|
||||||
description: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class SongFormData:
|
|
||||||
store_name: str
|
|
||||||
store_id: str
|
|
||||||
prompts: str
|
|
||||||
attributes: Dict[str, str] = field(default_factory=dict)
|
|
||||||
attributes_str: str = ""
|
|
||||||
lyrics_ids: List[int] = field(default_factory=list)
|
|
||||||
llm_model: str = "gpt-5-mini"
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def from_form(cls, request: Request):
|
|
||||||
"""Request의 form 데이터로부터 dataclass 인스턴스 생성"""
|
|
||||||
form_data = await request.form()
|
|
||||||
|
|
||||||
# 고정 필드명들
|
|
||||||
fixed_keys = {"store_info_name", "store_id", "llm_model", "prompts"}
|
|
||||||
|
|
||||||
# lyrics-{id} 형태의 모든 키를 찾아서 ID 추출
|
|
||||||
lyrics_ids = []
|
|
||||||
attributes = {}
|
|
||||||
|
|
||||||
for key, value in form_data.items():
|
|
||||||
if key.startswith("lyrics-"):
|
|
||||||
lyrics_id = key.split("-")[1]
|
|
||||||
lyrics_ids.append(int(lyrics_id))
|
|
||||||
elif key not in fixed_keys:
|
|
||||||
attributes[key] = value
|
|
||||||
|
|
||||||
# attributes를 문자열로 변환
|
|
||||||
attributes_str = (
|
|
||||||
"\r\n\r\n".join([f"{key} : {value}" for key, value in attributes.items()])
|
|
||||||
if attributes
|
|
||||||
else ""
|
|
||||||
)
|
|
||||||
|
|
||||||
return cls(
|
|
||||||
store_name=form_data.get("store_info_name", ""),
|
|
||||||
store_id=form_data.get("store_id", ""),
|
|
||||||
attributes=attributes,
|
|
||||||
attributes_str=attributes_str,
|
|
||||||
lyrics_ids=lyrics_ids,
|
|
||||||
llm_model=form_data.get("llm_model", "gpt-5-mini"),
|
|
||||||
prompts=form_data.get("prompts", ""),
|
|
||||||
)
|
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,6 @@ logger = logging.getLogger(__name__)
|
||||||
from app.user.dependencies import get_current_user
|
from app.user.dependencies import get_current_user
|
||||||
from app.user.models import RefreshToken, User
|
from app.user.models import RefreshToken, User
|
||||||
from app.user.schemas.user_schema import (
|
from app.user.schemas.user_schema import (
|
||||||
AccessTokenResponse,
|
|
||||||
KakaoCodeRequest,
|
KakaoCodeRequest,
|
||||||
KakaoLoginResponse,
|
KakaoLoginResponse,
|
||||||
LoginResponse,
|
LoginResponse,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
from app.user.schemas.user_schema import (
|
from app.user.schemas.user_schema import (
|
||||||
AccessTokenResponse,
|
|
||||||
KakaoCodeRequest,
|
KakaoCodeRequest,
|
||||||
KakaoLoginResponse,
|
KakaoLoginResponse,
|
||||||
KakaoTokenResponse,
|
KakaoTokenResponse,
|
||||||
|
|
@ -12,7 +11,6 @@ from app.user.schemas.user_schema import (
|
||||||
)
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"AccessTokenResponse",
|
|
||||||
"KakaoCodeRequest",
|
"KakaoCodeRequest",
|
||||||
"KakaoLoginResponse",
|
"KakaoLoginResponse",
|
||||||
"KakaoTokenResponse",
|
"KakaoTokenResponse",
|
||||||
|
|
|
||||||
|
|
@ -64,24 +64,6 @@ class TokenResponse(BaseModel):
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class AccessTokenResponse(BaseModel):
|
|
||||||
"""액세스 토큰 갱신 응답"""
|
|
||||||
|
|
||||||
access_token: str = Field(..., description="액세스 토큰")
|
|
||||||
token_type: str = Field(default="Bearer", description="토큰 타입")
|
|
||||||
expires_in: int = Field(..., description="액세스 토큰 만료 시간 (초)")
|
|
||||||
|
|
||||||
model_config = {
|
|
||||||
"json_schema_extra": {
|
|
||||||
"example": {
|
|
||||||
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwiZXhwIjoxNzA1MzE1MjAwfQ.new_token",
|
|
||||||
"token_type": "Bearer",
|
|
||||||
"expires_in": 3600
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class RefreshTokenRequest(BaseModel):
|
class RefreshTokenRequest(BaseModel):
|
||||||
"""토큰 갱신 요청"""
|
"""토큰 갱신 요청"""
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -81,7 +81,6 @@ class AdminRequiredError(AuthException):
|
||||||
from app.user.models import RefreshToken, User
|
from app.user.models import RefreshToken, User
|
||||||
from app.utils.common import generate_uuid
|
from app.utils.common import generate_uuid
|
||||||
from app.user.schemas.user_schema import (
|
from app.user.schemas.user_schema import (
|
||||||
AccessTokenResponse,
|
|
||||||
KakaoUserInfo,
|
KakaoUserInfo,
|
||||||
LoginResponse,
|
LoginResponse,
|
||||||
TokenResponse,
|
TokenResponse,
|
||||||
|
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
from typing import List, Optional
|
|
||||||
|
|
||||||
# Input 정의
|
|
||||||
class YTUploadPromptInput(BaseModel):
|
|
||||||
customer_name : str = Field(..., description = "마케팅 대상 사업체 이름")
|
|
||||||
detail_region_info : str = Field(..., description = "마케팅 대상 지역 상세")
|
|
||||||
marketing_intelligence_summary : Optional[str] = Field(None, description = "마케팅 분석 정보 보고서")
|
|
||||||
language : str= Field(..., description = "영상 언어")
|
|
||||||
target_keywords: List[str] = Field(..., description="태그 키워드 리스트")
|
|
||||||
|
|
||||||
# Output 정의
|
|
||||||
class YTUploadPromptOutput(BaseModel):
|
|
||||||
title:str = Field(..., description="유튜브 영상 제목 - SEO/AEO 최적화")
|
|
||||||
description: str = Field(..., description = "유튜브 영상 설명 - SEO/AEO 최적화")
|
|
||||||
|
|
||||||
|
|
@ -7,7 +7,6 @@ Video API Router
|
||||||
- POST /video/generate/{task_id}: 영상 생성 요청 (task_id로 Project/Lyric/Song 연결)
|
- POST /video/generate/{task_id}: 영상 생성 요청 (task_id로 Project/Lyric/Song 연결)
|
||||||
- GET /video/status/{creatomate_render_id}: Creatomate API 영상 생성 상태 조회
|
- GET /video/status/{creatomate_render_id}: Creatomate API 영상 생성 상태 조회
|
||||||
- GET /video/download/{task_id}: 영상 다운로드 상태 조회 (DB polling)
|
- GET /video/download/{task_id}: 영상 다운로드 상태 조회 (DB polling)
|
||||||
- GET /video/list: 완료된 영상 목록 조회 (페이지네이션)
|
|
||||||
|
|
||||||
사용 예시:
|
사용 예시:
|
||||||
from app.video.api.routers.v1.video import router
|
from app.video.api.routers.v1.video import router
|
||||||
|
|
@ -18,11 +17,10 @@ import json
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query
|
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.database.session import get_session
|
from app.database.session import get_session
|
||||||
from app.dependencies.pagination import PaginationParams, get_pagination_params
|
|
||||||
from app.user.dependencies.auth import get_current_user
|
from app.user.dependencies.auth import get_current_user
|
||||||
from app.user.models import User
|
from app.user.models import User
|
||||||
from app.home.models import Image, Project
|
from app.home.models import Image, Project
|
||||||
|
|
@ -30,13 +28,11 @@ from app.lyric.models import Lyric
|
||||||
from app.song.models import Song, SongTimestamp
|
from app.song.models import Song, SongTimestamp
|
||||||
from app.utils.creatomate import CreatomateService
|
from app.utils.creatomate import CreatomateService
|
||||||
from app.utils.logger import get_logger
|
from app.utils.logger import get_logger
|
||||||
from app.utils.pagination import PaginatedResponse
|
|
||||||
from app.video.models import Video
|
from app.video.models import Video
|
||||||
from app.video.schemas.video_schema import (
|
from app.video.schemas.video_schema import (
|
||||||
DownloadVideoResponse,
|
DownloadVideoResponse,
|
||||||
GenerateVideoResponse,
|
GenerateVideoResponse,
|
||||||
PollingVideoResponse,
|
PollingVideoResponse,
|
||||||
VideoListItem,
|
|
||||||
VideoRenderData,
|
VideoRenderData,
|
||||||
)
|
)
|
||||||
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
|
||||||
|
|
@ -738,126 +734,3 @@ async def download_video(
|
||||||
message="영상 다운로드 조회에 실패했습니다.",
|
message="영상 다운로드 조회에 실패했습니다.",
|
||||||
error_message=str(e),
|
error_message=str(e),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
|
||||||
"/list",
|
|
||||||
summary="생성된 영상 목록 조회",
|
|
||||||
description="""
|
|
||||||
완료된 영상 목록을 페이지네이션하여 조회합니다.
|
|
||||||
|
|
||||||
## 인증
|
|
||||||
**Bearer 토큰 필수** - `Authorization: Bearer {access_token}` 헤더를 포함해야 합니다.
|
|
||||||
|
|
||||||
## 쿼리 파라미터
|
|
||||||
- **page**: 페이지 번호 (1부터 시작, 기본값: 1)
|
|
||||||
- **page_size**: 페이지당 데이터 수 (기본값: 10, 최대: 100)
|
|
||||||
|
|
||||||
## 반환 정보
|
|
||||||
- **items**: 영상 목록 (store_name, region, task_id, result_movie_url, created_at)
|
|
||||||
- **total**: 전체 데이터 수
|
|
||||||
- **page**: 현재 페이지
|
|
||||||
- **page_size**: 페이지당 데이터 수
|
|
||||||
- **total_pages**: 전체 페이지 수
|
|
||||||
- **has_next**: 다음 페이지 존재 여부
|
|
||||||
- **has_prev**: 이전 페이지 존재 여부
|
|
||||||
|
|
||||||
## 사용 예시 (cURL)
|
|
||||||
```bash
|
|
||||||
curl -X GET "http://localhost:8000/video/list?page=1&page_size=10" \\
|
|
||||||
-H "Authorization: Bearer {access_token}"
|
|
||||||
```
|
|
||||||
|
|
||||||
## 참고
|
|
||||||
- status가 'completed'인 영상만 반환됩니다.
|
|
||||||
- 동일한 task_id가 있는 경우 가장 최근에 생성된 1개만 반환됩니다.
|
|
||||||
- created_at 기준 내림차순 정렬됩니다.
|
|
||||||
""",
|
|
||||||
response_model=PaginatedResponse[VideoListItem],
|
|
||||||
responses={
|
|
||||||
200: {"description": "영상 목록 조회 성공"},
|
|
||||||
401: {"description": "인증 실패 (토큰 없음/만료)"},
|
|
||||||
500: {"description": "조회 실패"},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
async def get_videos(
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
session: AsyncSession = Depends(get_session),
|
|
||||||
pagination: PaginationParams = Depends(get_pagination_params),
|
|
||||||
) -> PaginatedResponse[VideoListItem]:
|
|
||||||
"""완료된 영상 목록을 페이지네이션하여 반환합니다."""
|
|
||||||
logger.info(
|
|
||||||
f"[get_videos] START - page: {pagination.page}, page_size: {pagination.page_size}"
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
offset = (pagination.page - 1) * pagination.page_size
|
|
||||||
|
|
||||||
# 서브쿼리: task_id별 최신 Video의 id 조회 (completed 상태만)
|
|
||||||
subquery = (
|
|
||||||
select(func.max(Video.id).label("max_id"))
|
|
||||||
.where(Video.status == "completed")
|
|
||||||
.group_by(Video.task_id)
|
|
||||||
.subquery()
|
|
||||||
)
|
|
||||||
|
|
||||||
# 전체 개수 조회 (task_id별 최신 1개만)
|
|
||||||
count_query = select(func.count()).select_from(subquery)
|
|
||||||
total_result = await session.execute(count_query)
|
|
||||||
total = total_result.scalar() or 0
|
|
||||||
|
|
||||||
# 데이터 조회 (completed 상태, task_id별 최신 1개만, 최신순)
|
|
||||||
query = (
|
|
||||||
select(Video)
|
|
||||||
.where(Video.id.in_(select(subquery.c.max_id)))
|
|
||||||
.order_by(Video.created_at.desc())
|
|
||||||
.offset(offset)
|
|
||||||
.limit(pagination.page_size)
|
|
||||||
)
|
|
||||||
result = await session.execute(query)
|
|
||||||
videos = result.scalars().all()
|
|
||||||
|
|
||||||
# Project 정보 일괄 조회 (N+1 문제 해결)
|
|
||||||
project_ids = [v.project_id for v in videos if v.project_id]
|
|
||||||
projects_map: dict = {}
|
|
||||||
if project_ids:
|
|
||||||
projects_result = await session.execute(
|
|
||||||
select(Project).where(Project.id.in_(project_ids))
|
|
||||||
)
|
|
||||||
projects_map = {p.id: p for p in projects_result.scalars().all()}
|
|
||||||
|
|
||||||
# VideoListItem으로 변환
|
|
||||||
items = []
|
|
||||||
for video in videos:
|
|
||||||
project = projects_map.get(video.project_id)
|
|
||||||
|
|
||||||
item = VideoListItem(
|
|
||||||
video_id=video.id,
|
|
||||||
store_name=project.store_name if project else None,
|
|
||||||
region=project.region if project else None,
|
|
||||||
task_id=video.task_id,
|
|
||||||
result_movie_url=video.result_movie_url,
|
|
||||||
created_at=video.created_at,
|
|
||||||
)
|
|
||||||
items.append(item)
|
|
||||||
|
|
||||||
response = PaginatedResponse.create(
|
|
||||||
items=items,
|
|
||||||
total=total,
|
|
||||||
page=pagination.page,
|
|
||||||
page_size=pagination.page_size,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
f"[get_videos] SUCCESS - total: {total}, page: {pagination.page}, "
|
|
||||||
f"page_size: {pagination.page_size}, items_count: {len(items)}"
|
|
||||||
)
|
|
||||||
return response
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"[get_videos] EXCEPTION - error: {e}")
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=500,
|
|
||||||
detail=f"영상 목록 조회에 실패했습니다: {str(e)}",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue