o2o-triple-pick/backend/app/routers/songs.py
jwkim abcdf42aeb 가사 데이터 보강(활약·타율) + 커버는 팀 로고 100%
- 전날 경기 활약: KBO=네이버 record 타자 boxscore(홈런·3안타 이상),
  MLB=boxscore batting — 어제 결과 줄에 "활약: ..." 로 부가
- 확정 라인업에 타자 시즌 타율 병기 (MLB=seasonStats, KBO=최근 경기 hra 맵)
- 커버 합성에서 Suno 배경 제거 — 다크 단색 배경 + 팀 로고 최대 크기

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 10:42:55 +09:00

157 lines
5.5 KiB
Python

"""오늘의 응원가 API — 조회(공개) · 커버 합성 · Suno 콜백 싱크 · 관리자 수동 생성."""
from __future__ import annotations
import logging
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from ..config import settings
from ..database import get_db
from ..models import Song
from ..services.songs import poll_generating, start_due_songs
from .admin import require_admin
router = APIRouter(prefix="/api/songs", tags=["songs"])
log = logging.getLogger("triplepick.songs")
KST = timezone(timedelta(hours=9))
def _cover_path(s: Song) -> str:
# v= 캐시버스터: 업그레이드(재생성)로 task 가 바뀌면 새 커버로
return f"/api/songs/cover/{s.match_id}/{s.team_code}?v={(s.task_id or '')[:8]}"
def _song_out(s: Song) -> dict:
# Suno 는 생성당 2트랙을 주지만 노출은 팀당 1곡만 (첫 트랙).
# 커버는 Suno 아트 + 팀 로고 합성본으로 교체.
tracks = [
{**t, "imageUrl": _cover_path(s)} for t in (s.tracks or [])[:1]
]
return {
"matchId": s.match_id,
"league": s.league,
"teamCode": s.team_code,
"teamName": s.team_name,
"title": s.title,
"lyrics": s.lyrics,
"tracks": tracks,
}
# ── 커버 합성 (Suno 아트 배경 + 팀 로고 중앙) ──────────────────
_COVER_W, _COVER_H = 1200, 630 # OG 권장 규격 — 플레이어 썸네일은 중앙 크롭
_cover_cache: dict[str, bytes] = {}
async def _fetch_bytes(url: str) -> bytes | None:
import httpx
try:
async with httpx.AsyncClient(timeout=15, follow_redirects=True) as c:
r = await c.get(url)
r.raise_for_status()
return r.content
except Exception as e: # noqa: BLE001
log.warning("cover 소스 다운로드 실패 %s: %s", url, e)
return None
def _logo_url(s: Song) -> str | None:
if s.league == "kbo":
return f"{settings.internal_frontend_base}/assets/teams/kbo/{s.team_code.lower()}.png"
if s.league == "mlb":
from ..teams_baseball import MLB_TEAMS
mlb_id = (MLB_TEAMS.get(s.team_code) or {}).get("mlb_id")
if mlb_id:
return f"https://midfield.mlbstatic.com/v1/team/{mlb_id}/spots/500"
return None
def _compose(logo_bytes: bytes | None) -> bytes:
"""다크 단색 배경 + 팀 로고 최대 크기 중앙 배치 (배경 아트 없음)."""
import io
from PIL import Image
canvas = Image.new("RGB", (_COVER_W, _COVER_H), (16, 20, 26))
if logo_bytes:
try:
logo = Image.open(io.BytesIO(logo_bytes)).convert("RGBA")
# 캔버스에 여백 8%만 남기고 최대로 채운다
max_h = round(_COVER_H * 0.84)
max_w = round(_COVER_W * 0.84)
scale = min(max_w / logo.width, max_h / logo.height)
w, h = round(logo.width * scale), round(logo.height * scale)
logo = logo.resize((w, h))
canvas.paste(logo, ((_COVER_W - w) // 2, (_COVER_H - h) // 2), logo)
except Exception as e: # noqa: BLE001
log.warning("cover 로고 처리 실패: %s", e)
out = io.BytesIO()
canvas.save(out, "JPEG", quality=88)
return out.getvalue()
@router.get("/cover/{match_id}/{team_code}")
async def song_cover(
match_id: str, team_code: str, db: AsyncSession = Depends(get_db)
) -> Response:
s = (
await db.execute(
select(Song).where(Song.match_id == match_id, Song.team_code == team_code)
)
).scalar_one_or_none()
if not s:
raise HTTPException(status_code=404, detail="SONG_NOT_FOUND")
key = f"{match_id}:{team_code}"
if key not in _cover_cache:
if len(_cover_cache) > 200:
_cover_cache.clear()
logo_url = _logo_url(s)
logo = await _fetch_bytes(logo_url) if logo_url else None
_cover_cache[key] = _compose(logo)
return Response(
content=_cover_cache[key],
media_type="image/jpeg",
headers={"Cache-Control": "public, max-age=86400"},
)
@router.get("/today")
async def today_songs(league: str = "", db: AsyncSession = Depends(get_db)) -> list[dict]:
"""오늘의 응원가 목록 — league 미지정 시 전 리그(kbo+mlb)."""
today = datetime.now(KST).date()
# 업그레이드(라인업 반영 재생성) 중인 곡도 이전 트랙을 계속 서빙 — 재생 공백 없음
conds = [Song.date_kst == today, Song.status.in_(("complete", "generating"))]
if league:
conds.append(Song.league == league)
rows = (
await db.execute(
select(Song).where(*conds).order_by(Song.match_id, Song.team_code)
)
).scalars().all()
return [_song_out(s) for s in rows if s.tracks]
@router.post("/callback")
async def suno_callback(request: Request) -> dict:
"""Suno 게이트웨이 callBackUrl 싱크대 — 완료 감지는 워커 폴링이 담당."""
try:
await request.json()
except Exception: # noqa: BLE001 — 본문 형식 무관
pass
return {"ok": True}
@router.post("/generate", dependencies=[Depends(require_admin)])
async def force_generate(db: AsyncSession = Depends(get_db)) -> dict:
"""관리자: 오늘 KBO 전 경기 응원가 즉시 생성 시작 + 폴링 1회."""
started = await start_due_songs(db, force_today=True)
done = await poll_generating(db)
return {"ok": True, "started": started, "completed": done}