응원가를 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>
This commit is contained in:
jwkim 2026-08-25 10:05:27 +09:00
parent 0fc7598d88
commit dbd08e62c7
5 changed files with 102 additions and 28 deletions

View File

@ -30,18 +30,16 @@ def _song_out(s: Song) -> dict:
@router.get("/today") @router.get("/today")
async def today_songs(league: str = "kbo", db: AsyncSession = Depends(get_db)) -> list[dict]: async def today_songs(league: str = "", db: AsyncSession = Depends(get_db)) -> list[dict]:
"""오늘의 응원가 목록 — league 미지정 시 전 리그(kbo+mlb)."""
today = datetime.now(KST).date() today = datetime.now(KST).date()
# 업그레이드(라인업 반영 재생성) 중인 곡도 이전 트랙을 계속 서빙 — 재생 공백 없음 # 업그레이드(라인업 반영 재생성) 중인 곡도 이전 트랙을 계속 서빙 — 재생 공백 없음
conds = [Song.date_kst == today, Song.status.in_(("complete", "generating"))]
if league:
conds.append(Song.league == league)
rows = ( rows = (
await db.execute( await db.execute(
select(Song) select(Song).where(*conds).order_by(Song.match_id, Song.team_code)
.where(
Song.league == league,
Song.date_kst == today,
Song.status.in_(("complete", "generating")),
)
.order_by(Song.match_id, Song.team_code)
) )
).scalars().all() ).scalars().all()
return [_song_out(s) for s in rows if s.tracks] return [_song_out(s) for s in rows if s.tracks]

View File

@ -169,10 +169,14 @@ async def _series_line(db, m: Match) -> str | None:
async def fetch_lineups(m: Match) -> dict | None: async def fetch_lineups(m: Match) -> dict | None:
"""네이버 preview 에서 오늘 선발 라인업 조회 (발표 전이면 announced=False). """오늘 선발 라인업 조회 (발표 전이면 announced=False). KBO=네이버, MLB=공식 API."""
if m.league == "mlb":
return await _fetch_lineups_mlb(m)
return await _fetch_lineups_kbo(m)
fullLineUp 은 발표 전엔 선발투수 1명만 담긴다 — 양팀 타자 8명 이상이면 발표로 간주.
""" async def _fetch_lineups_kbo(m: Match) -> dict | None:
"""네이버 preview fullLineUp — 발표 전엔 선발투수 1명만. 양팀 타자 8명 이상 = 발표."""
import httpx import httpx
from .baseball_details import UA, naver_game_id_candidates from .baseball_details import UA, naver_game_id_candidates
@ -208,6 +212,69 @@ async def fetch_lineups(m: Match) -> dict | None:
return None 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: def _lineup_line(lineups: dict | None, side: str) -> str | None:
if not lineups or not lineups.get("announced"): if not lineups or not lineups.get("announced"):
return None return None
@ -268,10 +335,16 @@ async def build_context(
# ── LLM 작사 ─────────────────────────────────────────────────── # ── LLM 작사 ───────────────────────────────────────────────────
def _lyrics_prompt(team_name: str, context: str) -> str: _LEAGUE_LABEL = {"kbo": "한국 프로야구(KBO)", "mlb": "메이저리그(MLB)"}
def _lyrics_prompt(team_name: str, context: str, league: str = "kbo") -> str:
return ( return (
"너는 한국 프로야구 응원가 전문 작사가다. 아래 오늘 경기 정보를 바탕으로 " f"너는 {_LEAGUE_LABEL.get(league, '프로야구')} 응원가 전문 작사가다. "
f"'{team_name}'의 **오늘의 응원가**를 만들어라.\n\n" "아래 오늘 경기 정보를 바탕으로 "
f"'{team_name}'의 **오늘의 응원가**를 한국어로 만들어라"
+ (" (팀명·선수명은 한국 팬에게 익숙한 표기로)" if league == "mlb" else "")
+ ".\n\n"
f"[오늘 경기 정보]\n{context}\n\n" f"[오늘 경기 정보]\n{context}\n\n"
"[요구사항]\n" "[요구사항]\n"
"- 야구장에서 수만 관중이 떼창하는 웅장한 스타디움 앤섬\n" "- 야구장에서 수만 관중이 떼창하는 웅장한 스타디움 앤섬\n"
@ -295,9 +368,9 @@ def _parse_json(text: str) -> dict:
return json.loads(m.group(0) if m else t) return json.loads(m.group(0) if m else t)
async def write_lyrics(team_name: str, context: str) -> dict: async def write_lyrics(team_name: str, context: str, league: str = "kbo") -> dict:
"""LLM 으로 {title, style, lyrics} 생성 — Claude 우선, GPT 폴백.""" """LLM 으로 {title, style, lyrics} 생성 — Claude 우선, GPT 폴백."""
prompt = _lyrics_prompt(team_name, context) prompt = _lyrics_prompt(team_name, context, league)
if settings.anthropic_api_key: if settings.anthropic_api_key:
from anthropic import AsyncAnthropic from anthropic import AsyncAnthropic
@ -326,12 +399,15 @@ async def write_lyrics(team_name: str, context: str) -> dict:
# ── 생성 시작 · 폴링 ─────────────────────────────────────────── # ── 생성 시작 · 폴링 ───────────────────────────────────────────
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: def _enabled() -> bool:
return bool( return bool(settings.songs_enabled and settings.suno_api_key and _song_leagues())
settings.songs_enabled
and settings.suno_api_key
and "kbo" in settings.league_list
)
async def _start_one(db, m: Match, side: str, lineups: dict | None) -> bool: async def _start_one(db, m: Match, side: str, lineups: dict | None) -> bool:
@ -351,7 +427,7 @@ async def _start_one(db, m: Match, side: str, lineups: dict | None) -> bool:
if row.status == "complete" and (row.with_lineup or not announced): if row.status == "complete" and (row.with_lineup or not announced):
return False return False
piece = await write_lyrics(name, context) piece = await write_lyrics(name, context, m.league)
title = str(piece.get("title") or f"{name} 오늘의 응원가").strip()[:40] title = str(piece.get("title") or f"{name} 오늘의 응원가").strip()[:40]
style = str(piece.get("style") or DEFAULT_STYLE).strip() style = str(piece.get("style") or DEFAULT_STYLE).strip()
lyrics = str(piece.get("lyrics") or "").strip() lyrics = str(piece.get("lyrics") or "").strip()
@ -395,7 +471,7 @@ async def start_due_songs(db, force_today: bool = False) -> int:
""" """
now = now_utc() now = now_utc()
conds = [ conds = [
Match.league == "kbo", Match.league.in_(_song_leagues()),
Match.result_outcome.is_(None), Match.result_outcome.is_(None),
Match.status.notin_(("cancelled", "finished")), Match.status.notin_(("cancelled", "finished")),
] ]

View File

@ -160,7 +160,7 @@ export default function MatchupHUD({ match, lang = "ko" }: { match: Match; lang?
<div className="mt-5 grid grid-cols-[1fr_auto_1fr] items-center gap-3"> <div className="mt-5 grid grid-cols-[1fr_auto_1fr] items-center gap-3">
<div className="flex flex-col items-center gap-2.5"> <div className="flex flex-col items-center gap-2.5">
<TeamFlag team={left} className={flagCls} /> <TeamFlag team={left} className={flagCls} />
{match.league === "kbo" && ( {isBaseball && (
<CheerSongButton matchId={match.matchId} teamCode={left.code} lang={lang} /> <CheerSongButton matchId={match.matchId} teamCode={left.code} lang={lang} />
)} )}
</div> </div>
@ -183,7 +183,7 @@ export default function MatchupHUD({ match, lang = "ko" }: { match: Match; lang?
</div> </div>
<div className="flex flex-col items-center gap-2.5"> <div className="flex flex-col items-center gap-2.5">
<TeamFlag team={right} className={flagCls} /> <TeamFlag team={right} className={flagCls} />
{match.league === "kbo" && ( {isBaseball && (
<CheerSongButton matchId={match.matchId} teamCode={right.code} lang={lang} /> <CheerSongButton matchId={match.matchId} teamCode={right.code} lang={lang} />
)} )}
</div> </div>

View File

@ -60,8 +60,8 @@ export interface SongOut {
tracks: SongTrackOut[]; tracks: SongTrackOut[];
} }
export function getTodaySongs(league = "kbo"): Promise<SongOut[]> { export function getTodaySongs(league = ""): Promise<SongOut[]> {
return http<SongOut[]>(`/songs/today?league=${league}`); return http<SongOut[]>(`/songs/today${league ? `?league=${league}` : ""}`);
} }
export function listMatches(lang: Lang, league = ""): Promise<Match[]> { export function listMatches(lang: Lang, league = ""): Promise<Match[]> {

View File

@ -57,7 +57,7 @@ function fetchSongsCached(): Promise<SongOut[]> {
const day = todayKstKey(); const day = todayKstKey();
if (!songsPromise || songsDay !== day) { if (!songsPromise || songsDay !== day) {
songsDay = day; songsDay = day;
songsPromise = getTodaySongs("kbo").catch(() => []); songsPromise = getTodaySongs().catch(() => []); // 전 리그(kbo+mlb)
} }
return songsPromise; return songsPromise;
} }