songs API에 dateKst(경기일) 추가, OG 공유 제목·프론트 shareText·artist에 적용 — 매일 갱신되는 콘텐츠임을 드러낸다 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
227 lines
8.2 KiB
Python
227 lines
8.2 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,
|
|
# 제작일(경기일 KST) — 공유·플레이어 문구의 "8월 28일 OO 응원가" 표기용
|
|
"dateKst": s.date_kst.isoformat() if s.date_kst else None,
|
|
"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}
|