o2o-triple-pick/backend/app/routers/songs.py
jwkim fdb04a2ccc 응원가 영구 보존·종료 경기 노출 — 음원 DB 저장 + 경기별 조회 API
- Suno CDN 임시 URL 만료 대비: 완성 곡 첫 트랙을 워커가 다운로드해
  song_audio(bytea)로 보존하고 audioUrl을 /api/songs/audio/{id}/{idx}로 재작성
  (틱당 5곡, 실패 3회 상한 — 기존 곡도 자동 백필)
- GET /api/songs/match/{match_id} 추가 — 날짜 무관 조회로 종료된 경기
  상세에서도 '응원가 듣기' 버튼 노출
- 프론트 getTeamSongTracks를 오늘 전체 캐시 → 경기별 조회·캐시로 전환
- 음원 서빙은 Range(206) 지원, immutable 캐시 + task_id 캐시버스터

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-28 15:39:16 +09:00

225 lines
8.0 KiB
Python

"""오늘의 응원가 API — 조회(공개) · 커버 합성 · Suno 콜백 싱크 · 관리자 수동 생성."""
from __future__ import annotations
import logging
import re
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 Match, Song, SongAudio
from ..services.songs import persist_audio, 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)
if not logo_url and s.league == "mls":
# MLS 로고는 Match 행의 flag(ESPN PNG URL) 재사용
m = await db.get(Match, s.match_id)
if m:
logo_url = m.team_a_flag if m.team_a_code == s.team_code else m.team_b_flag
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.get("/match/{match_id}")
async def match_songs(match_id: str, db: AsyncSession = Depends(get_db)) -> list[dict]:
"""경기별 응원가 — 날짜 무관. 종료된 경기 상세에서도 그날 곡을 노출한다."""
rows = (
await db.execute(
select(Song)
.where(
Song.match_id == match_id,
Song.status.in_(("complete", "generating")),
)
.order_by(Song.team_code)
)
).scalars().all()
return [_song_out(s) for s in rows if s.tracks]
@router.get("/audio/{song_id}/{track_idx}")
async def song_audio_bytes(
song_id: int,
track_idx: int,
request: Request,
db: AsyncSession = Depends(get_db),
) -> Response:
"""DB 보존 음원 서빙 — Range 지원(재생 위치 탐색용). v= 쿼리로 캐시버스트."""
row = (
await db.execute(
select(SongAudio).where(
SongAudio.song_id == song_id, SongAudio.track_idx == track_idx
)
)
).scalar_one_or_none()
if not row:
raise HTTPException(status_code=404, detail="AUDIO_NOT_FOUND")
data, total = row.data, len(row.data)
headers = {
"Accept-Ranges": "bytes",
"Cache-Control": "public, max-age=31536000, immutable",
}
m = re.fullmatch(r"bytes=(\d*)-(\d*)", request.headers.get("range") or "")
if m and (m.group(1) or m.group(2)):
if m.group(1):
start = int(m.group(1))
end = int(m.group(2)) if m.group(2) else total - 1
else: # suffix range: bytes=-N (마지막 N바이트)
start = max(0, total - int(m.group(2)))
end = total - 1
if start >= total or start > end:
return Response(
status_code=416, headers={"Content-Range": f"bytes */{total}"}
)
end = min(end, total - 1)
headers["Content-Range"] = f"bytes {start}-{end}/{total}"
return Response(
content=data[start : end + 1],
status_code=206,
media_type=row.mime,
headers=headers,
)
return Response(content=data, media_type=row.mime, headers=headers)
@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)
saved = await persist_audio(db)
return {"ok": True, "started": started, "completed": done, "persisted": saved}