응원가를 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:
parent
0fc7598d88
commit
dbd08e62c7
@ -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]
|
||||
|
||||
@ -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")),
|
||||
]
|
||||
|
||||
@ -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="flex flex-col items-center gap-2.5">
|
||||
<TeamFlag team={left} className={flagCls} />
|
||||
{match.league === "kbo" && (
|
||||
{isBaseball && (
|
||||
<CheerSongButton matchId={match.matchId} teamCode={left.code} lang={lang} />
|
||||
)}
|
||||
</div>
|
||||
@ -183,7 +183,7 @@ export default function MatchupHUD({ match, lang = "ko" }: { match: Match; lang?
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-2.5">
|
||||
<TeamFlag team={right} className={flagCls} />
|
||||
{match.league === "kbo" && (
|
||||
{isBaseball && (
|
||||
<CheerSongButton matchId={match.matchId} teamCode={right.code} lang={lang} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -60,8 +60,8 @@ export interface SongOut {
|
||||
tracks: SongTrackOut[];
|
||||
}
|
||||
|
||||
export function getTodaySongs(league = "kbo"): Promise<SongOut[]> {
|
||||
return http<SongOut[]>(`/songs/today?league=${league}`);
|
||||
export function getTodaySongs(league = ""): Promise<SongOut[]> {
|
||||
return http<SongOut[]>(`/songs/today${league ? `?league=${league}` : ""}`);
|
||||
}
|
||||
|
||||
export function listMatches(lang: Lang, league = ""): Promise<Match[]> {
|
||||
|
||||
@ -57,7 +57,7 @@ function fetchSongsCached(): Promise<SongOut[]> {
|
||||
const day = todayKstKey();
|
||||
if (!songsPromise || songsDay !== day) {
|
||||
songsDay = day;
|
||||
songsPromise = getTodaySongs("kbo").catch(() => []);
|
||||
songsPromise = getTodaySongs().catch(() => []); // 전 리그(kbo+mlb)
|
||||
}
|
||||
return songsPromise;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user