diff --git a/backend/app/routers/songs.py b/backend/app/routers/songs.py index d30607f..aacee58 100644 --- a/backend/app/routers/songs.py +++ b/backend/app/routers/songs.py @@ -30,18 +30,16 @@ def _song_out(s: Song) -> dict: @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() # 업그레이드(라인업 반영 재생성) 중인 곡도 이전 트랙을 계속 서빙 — 재생 공백 없음 + conds = [Song.date_kst == today, Song.status.in_(("complete", "generating"))] + if league: + conds.append(Song.league == league) rows = ( await db.execute( - select(Song) - .where( - Song.league == league, - Song.date_kst == today, - Song.status.in_(("complete", "generating")), - ) - .order_by(Song.match_id, Song.team_code) + 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] diff --git a/backend/app/services/songs.py b/backend/app/services/songs.py index 7bfc17f..1a04f46 100644 --- a/backend/app/services/songs.py +++ b/backend/app/services/songs.py @@ -169,10 +169,14 @@ async def _series_line(db, m: Match) -> str | 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 from .baseball_details import UA, naver_game_id_candidates @@ -208,6 +212,69 @@ async def fetch_lineups(m: Match) -> dict | 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: if not lineups or not lineups.get("announced"): return None @@ -268,10 +335,16 @@ async def build_context( # ── 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 ( - "너는 한국 프로야구 응원가 전문 작사가다. 아래 오늘 경기 정보를 바탕으로 " - f"'{team_name}'의 **오늘의 응원가**를 만들어라.\n\n" + f"너는 {_LEAGUE_LABEL.get(league, '프로야구')} 응원가 전문 작사가다. " + "아래 오늘 경기 정보를 바탕으로 " + f"'{team_name}'의 **오늘의 응원가**를 한국어로 만들어라" + + (" (팀명·선수명은 한국 팬에게 익숙한 표기로)" if league == "mlb" else "") + + ".\n\n" f"[오늘 경기 정보]\n{context}\n\n" "[요구사항]\n" "- 야구장에서 수만 관중이 떼창하는 웅장한 스타디움 앤섬\n" @@ -295,9 +368,9 @@ def _parse_json(text: str) -> dict: 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 폴백.""" - prompt = _lyrics_prompt(team_name, context) + prompt = _lyrics_prompt(team_name, context, league) if settings.anthropic_api_key: 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: - return bool( - settings.songs_enabled - and settings.suno_api_key - and "kbo" in settings.league_list - ) + 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: @@ -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): 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] style = str(piece.get("style") or DEFAULT_STYLE).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() conds = [ - Match.league == "kbo", + Match.league.in_(_song_leagues()), Match.result_outcome.is_(None), Match.status.notin_(("cancelled", "finished")), ] diff --git a/frontend/src/components/MatchupHUD.tsx b/frontend/src/components/MatchupHUD.tsx index 03692cf..b08f5c3 100644 --- a/frontend/src/components/MatchupHUD.tsx +++ b/frontend/src/components/MatchupHUD.tsx @@ -160,7 +160,7 @@ export default function MatchupHUD({ match, lang = "ko" }: { match: Match; lang?