diff --git a/backend/app/config.py b/backend/app/config.py index b7af8be..1f940c6 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -180,8 +180,10 @@ class Settings(BaseSettings): suno_model: str = "V5" # 응원가 생성 전체 스위치 (KBO 만 대상 — 한국어 응원가) songs_enabled: bool = True - # 킥오프 N분 전부터 생성 시작 (선발투수·라인업 확정 이후 시점) + # 킥오프 N분 전부터 생성 윈도우 시작 — 윈도우 안에서 라인업 발표를 기다린다 song_generate_minutes_before: int = 150 + # 킥오프 N분 전까지 라인업이 안 뜨면 라인업 없이 폴백 생성 (곡 없는 날 방지) + song_lineup_fallback_minutes_before: int = 40 # 생성 대상 점검·생성 상태 폴링 주기(초) song_tick_seconds: int = 120 diff --git a/backend/app/models.py b/backend/app/models.py index 4adb0a0..f31f626 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -328,6 +328,8 @@ class Song(Base): status: Mapped[str] = mapped_column(String, default="generating", index=True) error: Mapped[str] = mapped_column(String, default="") attempts: Mapped[int] = mapped_column(Integer, default=0) # 실패 재시도 상한용 + # 라인업 발표 후 생성했는지 — False 면 라인업 공개 시 자동 재생성(업그레이드) 대상 + with_lineup: Mapped[bool] = mapped_column(Boolean, default=False) # 완성 트랙 [{title, audioUrl, imageUrl, duration}] — 보통 생성당 2곡 tracks: Mapped[list] = mapped_column(JSON, default=list) diff --git a/backend/app/routers/songs.py b/backend/app/routers/songs.py index c4461ce..d30607f 100644 --- a/backend/app/routers/songs.py +++ b/backend/app/routers/songs.py @@ -32,14 +32,19 @@ def _song_out(s: Song) -> dict: @router.get("/today") async def today_songs(league: str = "kbo", db: AsyncSession = Depends(get_db)) -> list[dict]: today = datetime.now(KST).date() + # 업그레이드(라인업 반영 재생성) 중인 곡도 이전 트랙을 계속 서빙 — 재생 공백 없음 rows = ( await db.execute( select(Song) - .where(Song.league == league, Song.date_kst == today, Song.status == "complete") + .where( + Song.league == league, + Song.date_kst == today, + Song.status.in_(("complete", "generating")), + ) .order_by(Song.match_id, Song.team_code) ) ).scalars().all() - return [_song_out(s) for s in rows] + return [_song_out(s) for s in rows if s.tracks] @router.post("/callback") diff --git a/backend/app/services/songs.py b/backend/app/services/songs.py index 3cfb4a6..7bfc17f 100644 --- a/backend/app/services/songs.py +++ b/backend/app/services/songs.py @@ -168,7 +168,60 @@ async def _series_line(db, m: Match) -> str | None: return f"{len(run)}연전 중 {idx}차전" -async def build_context(db, m: Match, side: str) -> tuple[str, str, str]: +async def fetch_lineups(m: Match) -> dict | None: + """네이버 preview 에서 오늘 선발 라인업 조회 (발표 전이면 announced=False). + + 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 + + +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 @@ -207,6 +260,9 @@ async def build_context(db, m: Match, side: str) -> tuple[str, str, str]: 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) @@ -278,15 +334,22 @@ def _enabled() -> bool: ) -async def _start_one(db, m: Match, side: str) -> bool: - code, name, context = await build_context(db, m, side) +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 and (row.status != "failed" or row.attempts >= MAX_ATTEMPTS): - return False + 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) title = str(piece.get("title") or f"{name} 오늘의 응원가").strip()[:40] @@ -296,6 +359,7 @@ async def _start_one(db, m: Match, side: str) -> bool: 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) @@ -308,15 +372,27 @@ async def _start_one(db, m: Match, side: str) -> bool: row.task_id = task_id row.status = "generating" row.error = "" - row.tracks = [] + 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)", m.match_id, name, task_id) + 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: - """생성 윈도우에 든 경기의 팀별 응원가 생성 시작. force_today=오늘 전 경기.""" + """생성 윈도우에 든 경기의 팀별 응원가 생성 시작. + + 윈도우 안에서는 라인업 발표를 기다렸다가 생성하고, 킥오프 + song_lineup_fallback_minutes_before 전까지 미발표면 라인업 없이 생성한다. + 라인업 없이 만든 곡은 발표 후 자동 재생성(업그레이드). + force_today=오늘 전 경기 즉시(라인업 대기 없이) 생성 — 테스트용. + """ now = now_utc() conds = [ Match.league == "kbo", @@ -328,16 +404,24 @@ async def start_due_songs(db, force_today: bool = False) -> int: 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 - else: - mins = (kick - now).total_seconds() / 60 - if not (0 < mins <= settings.song_generate_minutes_before): - 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): + if await _start_one(db, m, side, lineups): started += 1 except Exception as e: # noqa: BLE001 — 팀 단위 독립 실패 await db.rollback() @@ -345,6 +429,16 @@ async def start_due_songs(db, force_today: bool = False) -> int: 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 = ( @@ -366,18 +460,15 @@ async def poll_generating(db) -> int: done += 1 log.info("song 완료: %s %s (%d트랙)", row.match_id, row.team_name, len(tracks)) else: - row.status = "failed" - row.error = "SUCCESS 인데 트랙 없음" + _mark_failed(row, "SUCCESS 인데 트랙 없음") elif status in suno.SUNO_FAILED: - row.status = "failed" - row.error = f"{status}: {data.get('errorMessage') or ''}"[:300] + _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: - row.status = "failed" - row.error = f"타임아웃({status})" + _mark_failed(row, f"타임아웃({status})") await db.commit() return done