o2o-triple-pick/backend/app/services/songs.py
jwkim dbd08e62c7 응원가를 MLB 로 확장 (MLS 제외)
- MLB 라인업: Stats API schedule→gamePk→boxscore battingOrder (양팀 9명 = 발표)
- 작사 프롬프트 리그 분기 (MLB 는 한국 팬 표기 지시)
- /api/songs/today league 미지정 시 전 리그 반환, 프론트는 전 리그 조회
- 상세 페이지 응원가 버튼 KBO+MLB 노출

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

565 lines
22 KiB
Python

"""오늘의 응원가 — 경기 맥락 반영 자동 생성 파이프라인 (KBO 전용).
흐름 (워커 tick_songs, song_tick_seconds 주기):
1) 킥오프 song_generate_minutes_before(기본 150분) 전에 든 경기 → 팀별로
경기 맥락(전날 결과·순위·연전 차수·선발투수) 조립 → LLM 이 가사·스타일 작성
→ Suno 생성 작업 시작 (Song status=generating)
2) generating 행 폴링 → 완료 시 트랙 URL 저장 (status=complete)
실패는 attempts 3회까지 다음 틱에 재시도. 데이터가 비어도(캐시 미스)
가사는 팀·상대·경기 정보만으로 생성한다 — 조용한 전체 실패 없음.
"""
from __future__ import annotations
import json
import logging
import re
from datetime import date, timedelta, timezone
from sqlalchemy import select
from ..config import settings
from ..database import SessionLocal
from ..domain import ensure_aware, now_utc
from ..models import DataCache, Match, Song
from . import suno
log = logging.getLogger("triplepick.songs")
KST = timezone(timedelta(hours=9))
MAX_ATTEMPTS = 3
GENERATE_TIMEOUT_MIN = 30 # Suno 작업이 이 시간 넘게 미완이면 실패 처리
# 스타일 폴백 — LLM 이 style 을 못 주면 이 기본값 사용 (야구장 웅장 앤섬 컨셉)
DEFAULT_STYLE = (
"Korean baseball stadium cheer anthem, powerful brass fanfare, thumping "
"drum corps, group chant call-and-response, gang vocals, energetic crowd "
"shouting, 128bpm, live stadium atmosphere"
)
# LLM 에게 주는 형식 레퍼런스 (가사 구조·스타일 문구의 톤)
_REFERENCE = """[Intro - Brass Fanfare]
(두! 산! 베어스!) (두! 산! 베어스!)
[Verse 1]
잠실의 함성이 하늘을 울려
곰들의 심장이 뜨겁게 뛴다
[Chorus]
두산! (두산!) 베어스! (베어스!)
날려버려 담장 너머로
두산! (두산!) 베어스! (베어스!)
오늘 승리는 우리의 것
[Bridge - Chant]
(두산 승리! 두산 승리!)
잠실을 가득 채운 함성
[Final Chorus - Key Up]
두산! (두산!) 베어스! (베어스!)
잠실 하늘 높이 울려라
[Outro]
(두! 산! 베어스!) 최강 두산!"""
# ── 경기 맥락 조립 ─────────────────────────────────────────────
def _fmt_standing(name: str, st: dict | None) -> str:
if not st:
return f"{name}: 순위 정보 없음"
parts = [f"{st.get('rank')}위"] if st.get("rank") else []
if st.get("w") is not None:
parts.append(f"{st.get('w')}승 {st.get('d') or 0}무 {st.get('l')}패")
if st.get("wra"):
parts.append(f"승률 {st.get('wra')}")
if st.get("gb") not in (None, "", "0.0", 0):
parts.append(f"게임차 {st.get('gb')}")
if st.get("last5"):
parts.append(f"최근 5경기 {st.get('last5')}")
return f"{name}: " + ", ".join(parts)
def _fmt_starter(label: str, s: dict | None) -> str | None:
if not s or not s.get("name"):
return None
bits = [s["name"]]
if s.get("era") not in (None, ""):
bits.append(f"평균자책 {s['era']}")
if s.get("w") is not None:
bits.append(f"{s.get('w')}승 {s.get('l') or 0}패")
return f"{label} 선발: " + " ".join(bits)
async def _yesterday_line(db, m: Match, code: str, name: str) -> str | None:
"""해당 팀의 전날 경기 결과 한 줄 (없으면 None)."""
day = ensure_aware(m.kickoff_at).astimezone(KST).date() - timedelta(days=1)
lo = ensure_aware(m.kickoff_at).astimezone(KST).replace(
hour=0, minute=0, second=0, microsecond=0
) - timedelta(days=1)
hi = lo + timedelta(days=1)
rows = (
await db.execute(
select(Match).where(
Match.league == m.league,
Match.result_outcome.is_not(None),
Match.kickoff_at >= lo.astimezone(timezone.utc),
Match.kickoff_at < hi.astimezone(timezone.utc),
(Match.team_a_code == code) | (Match.team_b_code == code),
)
)
).scalars().all()
if not rows:
return f"어제({day.month}/{day.day})는 경기가 없었다"
g = rows[-1]
is_a = g.team_a_code == code
my, opp = (
(g.result_score_a, g.result_score_b) if is_a else (g.result_score_b, g.result_score_a)
)
opp_name = g.team_b_short if is_a else g.team_a_short
if my is None or opp is None:
return None
verdict = "승리" if my > opp else "패배" if my < opp else "무승부"
margin = abs((my or 0) - (opp or 0))
tight = " (1점차 석패)" if verdict == "패배" and margin == 1 else (
" (1점차 신승)" if verdict == "승리" and margin == 1 else ""
)
return f"어제 {opp_name}전 {my}:{opp} {verdict}{tight}"
async def _series_line(db, m: Match) -> str | None:
"""같은 팀 상대 연전 차수 — '3연전 중 2차전' 형태 (단일 경기면 None)."""
center = ensure_aware(m.kickoff_at).astimezone(KST).date()
lo = center - timedelta(days=3)
hi = center + timedelta(days=4)
pair = {m.team_a_code, m.team_b_code}
rows = (
await db.execute(
select(Match).where(
Match.league == m.league,
Match.team_a_code.in_(pair),
Match.team_b_code.in_(pair),
)
)
).scalars().all()
days: list[date] = sorted(
{
ensure_aware(r.kickoff_at).astimezone(KST).date()
for r in rows
if lo <= ensure_aware(r.kickoff_at).astimezone(KST).date() < hi
}
)
# 오늘을 포함해 연속된 날짜 구간만 자른다
if center not in days:
return None
run = [center]
for d in reversed([d for d in days if d < center]):
if (run[0] - d).days == 1:
run.insert(0, d)
else:
break
for d in [d for d in days if d > center]:
if (d - run[-1]).days == 1:
run.append(d)
else:
break
if len(run) < 2:
return None
idx = run.index(center) + 1
return f"{len(run)}연전 중 {idx}차전"
async def fetch_lineups(m: Match) -> dict | None:
"""오늘 선발 라인업 조회 (발표 전이면 announced=False). KBO=네이버, MLB=공식 API."""
if m.league == "mlb":
return await _fetch_lineups_mlb(m)
return await _fetch_lineups_kbo(m)
async def _fetch_lineups_kbo(m: Match) -> dict | None:
"""네이버 preview fullLineUp — 발표 전엔 선발투수 1명만. 양팀 타자 8명 이상 = 발표."""
import httpx
from .baseball_details import UA, naver_game_id_candidates
async with httpx.AsyncClient(timeout=15) as client:
for gid in naver_game_id_candidates(m):
try:
r = await client.get(
f"{settings.naver_api_base}/schedule/games/{gid}/preview",
headers=UA,
)
r.raise_for_status()
data = r.json()
except Exception: # noqa: BLE001 — gameId 후보 불일치는 다음 후보로
continue
if not data.get("success"):
continue
p = (data.get("result") or {}).get("previewData") or {}
def batters(key: str) -> list[dict]:
fl = ((p.get(key) or {}).get("fullLineUp")) or []
return [
e for e in fl
if e.get("playerName") and e.get("positionName") != "선발투수"
]
away, home = batters("awayTeamLineUp"), batters("homeTeamLineUp")
return {
"away": away,
"home": home,
"announced": len(away) >= 8 and len(home) >= 8,
}
return None
async def _fetch_lineups_mlb(m: Match) -> dict | None:
"""MLB 공식 Stats API — schedule 로 gamePk 해석 후 boxscore battingOrder.
battingOrder 는 라인업 발표 전엔 빈 배열. 양팀 9명 이상 = 발표.
"""
import httpx
from datetime import datetime
from ..teams_baseball import MLB_ID_TO_CODE
from .baseball_sync import match_seq
kick = ensure_aware(m.kickoff_at)
date_kst = kick.astimezone(KST).strftime("%Y%m%d")
start = (kick - timedelta(days=1)).date().isoformat()
end = kick.date().isoformat()
async with httpx.AsyncClient(timeout=15) as c:
r = await c.get(
f"{settings.mlb_api_base}/v1/schedule?sportId=1"
f"&startDate={start}&endDate={end}"
)
r.raise_for_status()
cands: list[tuple[str, int]] = []
for day in r.json().get("dates") or []:
for g in day.get("games") or []:
a = MLB_ID_TO_CODE.get((g["teams"]["away"]["team"] or {}).get("id"))
b = MLB_ID_TO_CODE.get((g["teams"]["home"]["team"] or {}).get("id"))
gd = g.get("gameDate")
if not a or not b or not gd:
continue
g_kst = (
datetime.fromisoformat(gd.replace("Z", "+00:00"))
.astimezone(KST).strftime("%Y%m%d")
)
if a == m.team_a_code and b == m.team_b_code and g_kst == date_kst:
cands.append((gd, g.get("gamePk")))
cands.sort()
idx = match_seq(m.match_id) - 1
pk = cands[idx][1] if idx < len(cands) else None
if not pk:
return None
r2 = await c.get(f"{settings.mlb_api_base}/v1/game/{pk}/boxscore")
r2.raise_for_status()
teams = r2.json().get("teams") or {}
def batters(side_key: str) -> list[dict]:
t = teams.get(side_key) or {}
players = t.get("players") or {}
out = []
for pid in t.get("battingOrder") or []:
p = players.get(f"ID{pid}") or {}
name = (p.get("person") or {}).get("fullName")
if name:
out.append({
"playerName": name,
"positionName": (p.get("position") or {}).get("abbreviation", ""),
})
return out
away, home = batters("away"), batters("home")
return {"away": away, "home": home, "announced": len(away) >= 9 and len(home) >= 9}
def _lineup_line(lineups: dict | None, side: str) -> str | None:
if not lineups or not lineups.get("announced"):
return None
mine = lineups["away" if side == "a" else "home"]
names = ", ".join(
f"{i + 1}번 {e['playerName']}({e.get('positionName', '')})"
for i, e in enumerate(mine[:10])
)
return f"오늘 확정 선발 라인업: {names}" if names else None
async def build_context(
db, m: Match, side: str, lineups: dict | None = None
) -> tuple[str, str, str]:
"""(팀코드, 팀명, 맥락 텍스트). side = 'a'(원정) | 'b'(홈)."""
code = m.team_a_code if side == "a" else m.team_b_code
name = m.team_a_short if side == "a" else m.team_b_short
opp_name = m.team_b_short if side == "a" else m.team_a_short
opp_code = m.team_b_code if side == "a" else m.team_a_code
home = "홈" if side == "b" else "원정"
kick = ensure_aware(m.kickoff_at).astimezone(KST)
lines = [
f"우리 팀: {name} ({home} 경기)",
f"오늘 경기: {kick.month}/{kick.day} {kick:%H:%M} {m.venue or ''} — 상대 {opp_name}",
]
st_row = await db.get(DataCache, f"standings:{m.league}")
st = st_row.payload if st_row else {}
lines.append(_fmt_standing(name, st.get(code)))
lines.append(_fmt_standing(f"상대 {opp_name}", st.get(opp_code)))
prev_row = await db.get(DataCache, f"preview:{m.match_id}")
prev = prev_row.payload if prev_row else {}
my_starter = _fmt_starter("우리 팀", prev.get("starterA" if side == "a" else "starterB"))
opp_starter = _fmt_starter("상대", prev.get("starterB" if side == "a" else "starterA"))
for s in (my_starter, opp_starter):
if s:
lines.append(s)
vs = prev.get("seasonVs")
if vs:
mine = vs.get("aWin") if side == "a" else vs.get("bWin")
theirs = vs.get("bWin") if side == "a" else vs.get("aWin")
if mine is not None and theirs is not None:
lines.append(f"시즌 상대전적 {mine}승 {vs.get('draw') or 0}무 {theirs}패")
y = await _yesterday_line(db, m, code, name)
if y:
lines.append(y)
series = await _series_line(db, m)
if series:
lines.append(f"오늘은 {opp_name}와의 {series}")
lu = _lineup_line(lineups, side)
if lu:
lines.append(lu)
return code, name, "\n".join(x for x in lines if x)
# ── LLM 작사 ───────────────────────────────────────────────────
_LEAGUE_LABEL = {"kbo": "한국 프로야구(KBO)", "mlb": "메이저리그(MLB)"}
def _lyrics_prompt(team_name: str, context: str, league: str = "kbo") -> str:
return (
f"너는 {_LEAGUE_LABEL.get(league, '프로야구')} 응원가 전문 작사가다. "
"아래 오늘 경기 정보를 바탕으로 "
f"'{team_name}'의 **오늘의 응원가**를 한국어로 만들어라"
+ (" (팀명·선수명은 한국 팬에게 익숙한 표기로)" if league == "mlb" else "")
+ ".\n\n"
f"[오늘 경기 정보]\n{context}\n\n"
"[요구사항]\n"
"- 야구장에서 수만 관중이 떼창하는 웅장한 스타디움 앤섬\n"
"- 오늘 경기 맥락(어제 결과 설욕/기세, 순위 싸움, 연전 차수, 선발투수)을 "
"가사에 구체적으로 녹일 것 — 선수 실명 사용 가능\n"
"- 괄호로 관중 콜앤리스폰스 파트 표기, 섹션 태그에 연주 지시 포함\n"
"- 분량은 1분 30초~2분 (Verse 2개 + Chorus 반복 + Bridge + Final Chorus)\n\n"
f"[가사 형식 레퍼런스 — 구조와 톤만 참고, 내용은 오늘 경기에 맞게 새로 쓸 것]\n{_REFERENCE}\n\n"
"다음 키를 가진 JSON 객체 하나만 출력하라:\n"
' "title": 곡 제목 (한국어, 25자 이내, 오늘 경기 느낌이 나게),\n'
' "style": 음악 생성기용 영어 스타일 설명 한 줄 — 예: '
f'"{DEFAULT_STYLE}" 처럼 웅장한 야구장 앤섬 계열로, 곡마다 악기·bpm 등을 변주,\n'
' "lyrics": 위 형식의 전체 가사\n'
)
def _parse_json(text: str) -> dict:
t = text.strip()
t = re.sub(r"^```(?:json)?\s*|\s*```$", "", t)
m = re.search(r"\{.*\}", t, re.S)
return json.loads(m.group(0) if m else t)
async def write_lyrics(team_name: str, context: str, league: str = "kbo") -> dict:
"""LLM 으로 {title, style, lyrics} 생성 — Claude 우선, GPT 폴백."""
prompt = _lyrics_prompt(team_name, context, league)
if settings.anthropic_api_key:
from anthropic import AsyncAnthropic
client = AsyncAnthropic(api_key=settings.anthropic_api_key)
msg = await client.messages.create(
model=settings.anthropic_model,
max_tokens=3000,
messages=[{"role": "user", "content": prompt}],
)
text = "".join(b.text for b in msg.content if getattr(b, "type", "") == "text")
return _parse_json(text)
if settings.openai_api_key:
from openai import AsyncOpenAI
client = AsyncOpenAI(api_key=settings.openai_api_key)
resp = await client.chat.completions.create(
model=settings.openai_model,
messages=[
{"role": "system", "content": "You output only valid JSON."},
{"role": "user", "content": prompt},
],
response_format={"type": "json_object"},
)
return _parse_json(resp.choices[0].message.content or "{}")
raise RuntimeError("작사용 LLM 키 미설정 (ANTHROPIC/OPENAI)")
# ── 생성 시작 · 폴링 ───────────────────────────────────────────
SONG_LEAGUES = ("kbo", "mlb") # 응원가 대상 리그 (MLS 제외)
def _song_leagues() -> list[str]:
return [l for l in SONG_LEAGUES if l in settings.league_list]
def _enabled() -> bool:
return bool(settings.songs_enabled and settings.suno_api_key and _song_leagues())
async def _start_one(db, m: Match, side: str, lineups: dict | None) -> bool:
announced = bool(lineups and lineups.get("announced"))
code, name, context = await build_context(db, m, side, lineups)
row = (
await db.execute(
select(Song).where(Song.match_id == m.match_id, Song.team_code == code)
)
).scalars().first()
if row:
if row.attempts >= MAX_ATTEMPTS:
return False
if row.status == "generating":
return False
# complete 은 '라인업 없이 만든 곡 + 라인업 발표됨'일 때만 재생성(업그레이드)
if row.status == "complete" and (row.with_lineup or not announced):
return False
piece = await write_lyrics(name, context, m.league)
title = str(piece.get("title") or f"{name} 오늘의 응원가").strip()[:40]
style = str(piece.get("style") or DEFAULT_STYLE).strip()
lyrics = str(piece.get("lyrics") or "").strip()
if not lyrics:
raise RuntimeError("LLM 가사 비어있음")
task_id = await suno.start_generation(title, style, lyrics)
upgrade = bool(row and row.status == "complete")
if row is None:
row = Song(match_id=m.match_id, team_code=code)
db.add(row)
row.league = m.league
row.date_kst = ensure_aware(m.kickoff_at).astimezone(KST).date()
row.team_name = name
row.title = title
row.style = style
row.lyrics = lyrics
row.task_id = task_id
row.status = "generating"
row.error = ""
row.with_lineup = announced
# 업그레이드는 기존 트랙을 유지 — 새 곡 완성 시점에 교체 (재생 공백 없음)
if not upgrade:
row.tracks = []
row.attempts = (row.attempts or 0) + 1
await db.commit()
log.info(
"song 생성 시작: %s %s (task=%s, 라인업=%s%s)",
m.match_id, name, task_id, announced, ", 업그레이드" if upgrade else "",
)
return True
async def start_due_songs(db, force_today: bool = False) -> int:
"""생성 윈도우에 든 경기의 팀별 응원가 생성 시작.
윈도우 안에서는 라인업 발표를 기다렸다가 생성하고, 킥오프
song_lineup_fallback_minutes_before 전까지 미발표면 라인업 없이 생성한다.
라인업 없이 만든 곡은 발표 후 자동 재생성(업그레이드).
force_today=오늘 전 경기 즉시(라인업 대기 없이) 생성 — 테스트용.
"""
now = now_utc()
conds = [
Match.league.in_(_song_leagues()),
Match.result_outcome.is_(None),
Match.status.notin_(("cancelled", "finished")),
]
matches = (await db.execute(select(Match).where(*conds))).scalars().all()
today = now.astimezone(KST).date()
started = 0
for m in matches:
kick = ensure_aware(m.kickoff_at)
mins = (kick - now).total_seconds() / 60
if force_today:
if kick.astimezone(KST).date() != today:
continue
elif not (0 < mins <= settings.song_generate_minutes_before):
continue
try:
lineups = await fetch_lineups(m)
except Exception as e: # noqa: BLE001
log.warning("lineup 조회 실패 %s: %s", m.match_id, e)
lineups = None
announced = bool(lineups and lineups.get("announced"))
# 윈도우 내 라인업 대기 — 폴백 시점 전엔 발표될 때까지 생성 보류
if not force_today and not announced and mins > settings.song_lineup_fallback_minutes_before:
continue
for side in ("a", "b"):
try:
if await _start_one(db, m, side, lineups):
started += 1
except Exception as e: # noqa: BLE001 — 팀 단위 독립 실패
await db.rollback()
log.error("song 생성 실패 %s side=%s: %s", m.match_id, side, e)
return started
def _mark_failed(row: Song, error: str) -> None:
"""실패 처리 — 업그레이드 중이었으면(이전 트랙 보유) 이전 곡으로 복귀."""
row.error = error
if row.tracks:
row.status = "complete"
row.with_lineup = False # 다음 틱에 업그레이드 재시도 (attempts 상한 내)
else:
row.status = "failed"
async def poll_generating(db) -> int:
"""generating 상태 Suno 작업 폴링 → 완료/실패 반영."""
rows = (
await db.execute(select(Song).where(Song.status == "generating"))
).scalars().all()
done = 0
for row in rows:
try:
data = await suno.get_task(row.task_id)
except Exception as e: # noqa: BLE001
log.warning("song 폴링 실패 %s: %s", row.task_id, e)
continue
status = data.get("status") or ""
if status == suno.SUNO_DONE:
tracks = suno.extract_tracks(data)
if tracks:
row.tracks = tracks
row.status = "complete"
done += 1
log.info("song 완료: %s %s (%d트랙)", row.match_id, row.team_name, len(tracks))
else:
_mark_failed(row, "SUCCESS 인데 트랙 없음")
elif status in suno.SUNO_FAILED:
_mark_failed(row, f"{status}: {data.get('errorMessage') or ''}"[:300])
log.warning("song 실패: %s %s", row.match_id, row.error)
else:
# 진행 중 — 오래 걸리면 실패 처리 후 재시도 대상으로
created = ensure_aware(row.created_at) if row.created_at else now_utc()
if (now_utc() - created).total_seconds() > GENERATE_TIMEOUT_MIN * 60:
_mark_failed(row, f"타임아웃({status})")
await db.commit()
return done
async def tick_songs() -> None:
"""워커 주기 작업 — 생성 시작 + 폴링. 미설정 시 no-op."""
if not _enabled():
return
async with SessionLocal() as db:
try:
await start_due_songs(db)
except Exception as e: # noqa: BLE001
log.error("song start 오류: %s", e)
try:
await poll_generating(db)
except Exception as e: # noqa: BLE001
log.error("song poll 오류: %s", e)