페이지네이션 유틸로 이동, 글로벌 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 sqlalchemy import select
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.session import get_session
from app.dependencies.pagination import (
PaginationParams,
get_pagination_params,
)
from app.home.models import Project
from app.lyric.models import Lyric
from app.song.models import Song
@ -26,8 +30,10 @@ from app.song.schemas.song_schema import (
GenerateSongRequest,
GenerateSongResponse,
PollingSongResponse,
SongListItem,
)
from app.song.worker.song_task import download_and_save_song
from app.utils.pagination import PaginatedResponse
from app.utils.suno import SunoService
@ -392,3 +398,117 @@ async def download_song(
message="노래 다운로드 조회에 실패했습니다.",
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="에러 메시지 (실패 시)")
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):
"""노래 다운로드 응답 스키마