페이지네이션 유틸로 이동, 글로벌 depends 정의, lyrics 엔드포인트 기반 리스트 출력 완료

모든 테스트 패스
insta v0.2.0-song
bluebamus 2025-12-26 10:03:17 +09:00
parent 5f06c3c59d
commit 03a80afc71
3 changed files with 267 additions and 1 deletions

View File

@ -0,0 +1,121 @@
"""
Pagination Dependencies
페이지네이션 관련 의존성 주입을 정의합니다.
사용 예시:
from app.dependencies.pagination import PaginationParams, get_pagination_params, paginated_query
# 방법 1: 기본 파라미터만 사용
@router.get("/items", response_model=PaginatedResponse[ItemModel])
async def get_items(
pagination: PaginationParams = Depends(get_pagination_params),
session: AsyncSession = Depends(get_session),
):
...
# 방법 2: paginated_query 사용 (get_paginated 래핑)
@router.get("/items", response_model=PaginatedResponse[ItemModel])
async def get_items(
session: AsyncSession = Depends(get_session),
pagination: PaginationParams = Depends(get_pagination_params),
):
return await paginated_query(
session=session,
model=Item,
item_schema=ItemModel,
pagination=pagination,
filters={"status": "completed"},
)
"""
from dataclasses import dataclass
from typing import Annotated, Any, Callable, Dict, Optional, Type, TypeVar
from fastapi import Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.utils.pagination import PaginatedResponse, get_paginated
T = TypeVar("T")
ModelT = TypeVar("ModelT")
@dataclass
class PaginationParams:
"""페이지네이션 파라미터를 담는 데이터 클래스"""
page: int
page_size: int
def get_pagination_params(
page: Annotated[int, Query(ge=1, description="페이지 번호 (1부터 시작)")] = 1,
page_size: Annotated[
int, Query(ge=1, le=100, description="페이지당 데이터 수 (최대 100)")
] = 10,
) -> PaginationParams:
"""페이지네이션 파라미터를 주입하는 의존성 함수
Args:
page: 페이지 번호 (1부터 시작, 기본값: 1)
page_size: 페이지당 데이터 (기본값: 10, 최대: 100)
Returns:
PaginationParams: 페이지네이션 파라미터 객체
"""
return PaginationParams(page=page, page_size=page_size)
async def paginated_query(
session: AsyncSession,
model: Type[ModelT],
item_schema: Type[T],
pagination: PaginationParams,
filters: Optional[Dict[str, Any]] = None,
order_by: Optional[str] = "created_at",
order_desc: bool = True,
transform_fn: Optional[Callable[[ModelT], T]] = None,
) -> PaginatedResponse[T]:
"""페이지네이션 쿼리를 실행하는 헬퍼 함수
PaginationParams를 받아서 get_paginated를 호출합니다.
Args:
session: SQLAlchemy AsyncSession
model: SQLAlchemy 모델 클래스 (: Song, Lyric, Video)
item_schema: Pydantic 스키마 클래스 (: SongListItem)
pagination: 페이지네이션 파라미터 (get_pagination_params에서 주입)
filters: 필터 조건 딕셔너리 (: {"status": "completed"})
order_by: 정렬 기준 컬럼명 (기본값: "created_at")
order_desc: 내림차순 정렬 여부 (기본값: True)
transform_fn: 모델을 스키마로 변환하는 함수 (None이면 자동 변환)
Returns:
PaginatedResponse[T]: 페이지네이션된 응답
Usage:
@router.get("/songs", response_model=PaginatedResponse[SongListItem])
async def get_songs(
session: AsyncSession = Depends(get_session),
pagination: PaginationParams = Depends(get_pagination_params),
):
return await paginated_query(
session=session,
model=Song,
item_schema=SongListItem,
pagination=pagination,
filters={"status": "completed"},
)
"""
return await get_paginated(
session=session,
model=model,
item_schema=item_schema,
page=pagination.page,
page_size=pagination.page_size,
filters=filters,
order_by=order_by,
order_desc=order_desc,
transform_fn=transform_fn,
)

View File

@ -14,10 +14,14 @@ Song API Router
""" """
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
from sqlalchemy import select from sqlalchemy import func, 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.home.models import Project from app.home.models import Project
from app.lyric.models import Lyric from app.lyric.models import Lyric
from app.song.models import Song from app.song.models import Song
@ -26,8 +30,10 @@ from app.song.schemas.song_schema import (
GenerateSongRequest, GenerateSongRequest,
GenerateSongResponse, GenerateSongResponse,
PollingSongResponse, PollingSongResponse,
SongListItem,
) )
from app.song.worker.song_task import download_and_save_song from app.song.worker.song_task import download_and_save_song
from app.utils.pagination import PaginatedResponse
from app.utils.suno import SunoService from app.utils.suno import SunoService
@ -392,3 +398,117 @@ async def download_song(
message="노래 다운로드 조회에 실패했습니다.", message="노래 다운로드 조회에 실패했습니다.",
error_message=str(e), error_message=str(e),
) )
@router.get(
"s/",
summary="생성된 노래 목록 조회",
description="""
완료된 노래 목록을 페이지네이션하여 조회합니다.
## 쿼리 파라미터
- **page**: 페이지 번호 (1부터 시작, 기본값: 1)
- **page_size**: 페이지당 데이터 (기본값: 10, 최대: 100)
## 반환 정보
- **items**: 노래 목록 (store_name, region, task_id, language, song_result_url, created_at)
- **total**: 전체 데이터
- **page**: 현재 페이지
- **page_size**: 페이지당 데이터
- **total_pages**: 전체 페이지
- **has_next**: 다음 페이지 존재 여부
- **has_prev**: 이전 페이지 존재 여부
## 사용 예시
```
GET /songs/?page=1&page_size=10
```
## 참고
- status가 'completed' 노래만 반환됩니다.
- created_at 기준 내림차순 정렬됩니다.
""",
response_model=PaginatedResponse[SongListItem],
responses={
200: {"description": "노래 목록 조회 성공"},
500: {"description": "조회 실패"},
},
)
async def get_songs(
session: AsyncSession = Depends(get_session),
pagination: PaginationParams = Depends(get_pagination_params),
) -> PaginatedResponse[SongListItem]:
"""완료된 노래 목록을 페이지네이션하여 반환합니다."""
print(f"[get_songs] START - page: {pagination.page}, page_size: {pagination.page_size}")
try:
offset = (pagination.page - 1) * pagination.page_size
# 서브쿼리: task_id별 최신 Song의 id 조회 (completed 상태만)
subquery = (
select(func.max(Song.id).label("max_id"))
.where(Song.status == "completed")
.group_by(Song.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(Song)
.where(Song.id.in_(select(subquery.c.max_id)))
.order_by(Song.created_at.desc())
.offset(offset)
.limit(pagination.page_size)
)
result = await session.execute(query)
songs = result.scalars().all()
# Project 정보와 함께 SongListItem으로 변환
items = []
for song in songs:
# Project 조회 (song.project_id 직접 사용)
project_result = await session.execute(
select(Project).where(Project.id == song.project_id)
)
project = project_result.scalar_one_or_none()
item = SongListItem(
store_name=project.store_name if project else None,
region=project.region if project else None,
task_id=song.task_id,
language=song.language,
song_result_url=song.song_result_url,
created_at=song.created_at,
)
items.append(item)
# 개별 아이템 로그
print(
f"[get_songs] Item - store_name: {item.store_name}, region: {item.region}, "
f"task_id: {item.task_id}, language: {item.language}, "
f"song_result_url: {item.song_result_url}, created_at: {item.created_at}"
)
response = PaginatedResponse.create(
items=items,
total=total,
page=pagination.page,
page_size=pagination.page_size,
)
print(
f"[get_songs] SUCCESS - total: {total}, page: {pagination.page}, "
f"page_size: {pagination.page_size}, items_count: {len(items)}"
)
return response
except Exception as e:
print(f"[get_songs] EXCEPTION - error: {e}")
raise HTTPException(
status_code=500,
detail=f"노래 목록 조회에 실패했습니다: {str(e)}",
)

View File

@ -184,6 +184,31 @@ class PollingSongResponse(BaseModel):
error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시)") error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시)")
class SongListItem(BaseModel):
"""노래 목록 아이템 스키마
Usage:
GET /songs 응답의 개별 노래 정보
Example:
{
"store_name": "스테이 머뭄",
"region": "군산",
"task_id": "019123ab-cdef-7890-abcd-ef1234567890",
"language": "Korean",
"song_result_url": "http://localhost:8000/media/2025-01-15/스테이머뭄.mp3",
"created_at": "2025-01-15T12:00:00"
}
"""
store_name: Optional[str] = Field(None, description="업체명")
region: Optional[str] = Field(None, description="지역명")
task_id: str = Field(..., description="작업 고유 식별자")
language: Optional[str] = Field(None, description="언어")
song_result_url: Optional[str] = Field(None, description="노래 결과 URL")
created_at: Optional[datetime] = Field(None, description="생성 일시")
class DownloadSongResponse(BaseModel): class DownloadSongResponse(BaseModel):
"""노래 다운로드 응답 스키마 """노래 다운로드 응답 스키마