"""오늘의 응원가 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}