응원가를 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")
|
@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]
|
||||||
|
|||||||
@ -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")),
|
||||||
]
|
]
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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[]> {
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user