오늘의 응원가 자동 생성 파이프라인 (Suno)

- 경기 맥락(전날 결과·순위·연전 차수·선발투수) 조립 → LLM 작사
  (콜앤리스폰스·섹션 태그 형식) → Suno(sunoapi.org) 생성 → songs 테이블
- 워커: 킥오프 150분 전 윈도우 진입 시 생성 시작 + 2분 주기 폴링
- API: GET /api/songs/today · POST /api/songs/callback(싱크대)
  · POST /api/songs/generate(관리자 강제 생성)
- 프론트: MusicBar 가 경기 상세·메인에서 오늘의 응원가를 동적 로드
  (정적 플레이리스트는 폴백 유지)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jwkim 2026-08-25 09:33:34 +09:00
parent 2a8f2bc1fa
commit 277378477a
11 changed files with 706 additions and 7 deletions

View File

@ -81,3 +81,7 @@ SMTP_USER=
SMTP_PASSWORD= SMTP_PASSWORD=
SMTP_FROM=TriplePick <no-reply@triplepick.app> SMTP_FROM=TriplePick <no-reply@triplepick.app>
SMTP_STARTTLS=true SMTP_STARTTLS=true
# Suno (sunoapi.org 게이트웨이) — 오늘의 응원가 자동 생성
SUNO_API_KEY=
SONGS_ENABLED=true

View File

@ -173,6 +173,18 @@ class Settings(BaseSettings):
football_refresh_hour: int = 0 football_refresh_hour: int = 0
football_refresh_minute: int = 0 football_refresh_minute: int = 0
# ── 외부 연동: Suno (sunoapi.org 서드파티 게이트웨이) ─────
# 오늘의 응원가 자동 생성. 키 미설정 시 전체 no-op.
suno_api_key: str = ""
suno_api_base: str = "https://api.sunoapi.org"
suno_model: str = "V5"
# 응원가 생성 전체 스위치 (KBO 만 대상 — 한국어 응원가)
songs_enabled: bool = True
# 킥오프 N분 전부터 생성 시작 (선발투수·라인업 확정 이후 시점)
song_generate_minutes_before: int = 150
# 생성 대상 점검·생성 상태 폴링 주기(초)
song_tick_seconds: int = 120
# ── 외부 연동: 이메일 ───────────────────────────────────── # ── 외부 연동: 이메일 ─────────────────────────────────────
# 1순위: Azure Communication Services(ACS) Email — endpoint + accesskey. # 1순위: Azure Communication Services(ACS) Email — endpoint + accesskey.
# AZURE_ACS_SENDER 는 검증된 MailFrom 주소(예: donotreply@triplepick.o2o.kr). # AZURE_ACS_SENDER 는 검증된 MailFrom 주소(예: donotreply@triplepick.o2o.kr).

View File

@ -21,6 +21,7 @@ from .routers import (
matches, matches,
predictions, predictions,
share, share,
songs,
standings, standings,
visits, visits,
) )
@ -58,6 +59,7 @@ app.include_router(leaderboard.router)
app.include_router(standings.router) app.include_router(standings.router)
app.include_router(admin.router) app.include_router(admin.router)
app.include_router(visits.router) app.include_router(visits.router)
app.include_router(songs.router)
# 공유 미리보기(OG) 프리렌더 — nginx 가 크롤러 UA 의 /match/:id 만 여기로 보낸다. # 공유 미리보기(OG) 프리렌더 — nginx 가 크롤러 UA 의 /match/:id 만 여기로 보낸다.
app.include_router(share.router) app.include_router(share.router)

View File

@ -298,6 +298,47 @@ class RefreshToken(Base):
) )
class Song(Base):
"""오늘의 응원가 — 경기×팀 단위 Suno 생성 트랙.
가사·스타일은 LLM(경기 맥락 주입)이 쓰고, 음원은 Suno(sunoapi.org)가 생성.
audio/image URL 은 제공자 CDN 을 그대로 사용(당일 소비 콘텐츠라 다운로드 불필요).
"""
__tablename__ = "songs"
__table_args__ = (
UniqueConstraint("match_id", "team_code", name="uq_song_match_team"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
league: Mapped[str] = mapped_column(String, index=True)
date_kst: Mapped[date] = mapped_column(Date, index=True) # 경기일 (KST)
match_id: Mapped[str] = mapped_column(
ForeignKey("matches.match_id", ondelete="CASCADE"), index=True
)
team_code: Mapped[str] = mapped_column(String)
team_name: Mapped[str] = mapped_column(String)
title: Mapped[str] = mapped_column(String, default="")
lyrics: Mapped[str] = mapped_column(String, default="")
style: Mapped[str] = mapped_column(String, default="")
task_id: Mapped[str] = mapped_column(String, default="")
# generating(Suno 작업 대기) | complete | failed
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) # 실패 재시도 상한용
# 완성 트랙 [{title, audioUrl, imageUrl, duration}] — 보통 생성당 2곡
tracks: Mapped[list] = mapped_column(JSON, default=list)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
class DataCache(Base): class DataCache(Base):
"""야구(KBO/MLB) 부가 데이터 캐시 — 프리뷰·순위, API 응답·AI 프롬프트 조립용. """야구(KBO/MLB) 부가 데이터 캐시 — 프리뷰·순위, API 응답·AI 프롬프트 조립용.

View File

@ -0,0 +1,60 @@
"""오늘의 응원가 API — 조회(공개) · Suno 콜백 싱크 · 관리자 수동 생성."""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, Request
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from ..database import get_db
from ..models import Song
from ..services.songs import poll_generating, start_due_songs
from .admin import require_admin
router = APIRouter(prefix="/api/songs", tags=["songs"])
KST = timezone(timedelta(hours=9))
def _song_out(s: Song) -> dict:
return {
"matchId": s.match_id,
"league": s.league,
"teamCode": s.team_code,
"teamName": s.team_name,
"title": s.title,
"lyrics": s.lyrics,
"tracks": s.tracks or [],
}
@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")
.order_by(Song.match_id, Song.team_code)
)
).scalars().all()
return [_song_out(s) for s in rows]
@router.post("/callback")
async def suno_callback(request: Request) -> dict:
"""Suno 게이트웨이 callBackUrl 싱크대 — 완료 감지는 워커 폴링이 담당."""
try:
await request.json()
except Exception: # noqa: BLE001 — 본문 형식 무관
pass
return {"ok": True}
@router.post("/generate", dependencies=[Depends(require_admin)])
async def force_generate(db: AsyncSession = Depends(get_db)) -> dict:
"""관리자: 오늘 KBO 전 경기 응원가 즉시 생성 시작 + 폴링 1회."""
started = await start_due_songs(db, force_today=True)
done = await poll_generating(db)
return {"ok": True, "started": started, "completed": done}

View File

@ -0,0 +1,397 @@
"""오늘의 응원가 — 경기 맥락 반영 자동 생성 파이프라인 (KBO 전용).
흐름 (워커 tick_songs, song_tick_seconds 주기):
1) 킥오프 song_generate_minutes_before(기본 150분) 전에 든 경기 → 팀별로
경기 맥락(전날 결과·순위·연전 차수·선발투수) 조립 → LLM 이 가사·스타일 작성
→ Suno 생성 작업 시작 (Song status=generating)
2) generating 행 폴링 → 완료 시 트랙 URL 저장 (status=complete)
실패는 attempts 3회까지 다음 틱에 재시도. 데이터가 비어도(캐시 미스)
가사는 팀·상대·경기 정보만으로 생성한다 — 조용한 전체 실패 없음.
"""
from __future__ import annotations
import json
import logging
import re
from datetime import date, timedelta, timezone
from sqlalchemy import select
from ..config import settings
from ..database import SessionLocal
from ..domain import ensure_aware, now_utc
from ..models import DataCache, Match, Song
from . import suno
log = logging.getLogger("triplepick.songs")
KST = timezone(timedelta(hours=9))
MAX_ATTEMPTS = 3
GENERATE_TIMEOUT_MIN = 30 # Suno 작업이 이 시간 넘게 미완이면 실패 처리
# 스타일 폴백 — LLM 이 style 을 못 주면 이 기본값 사용 (야구장 웅장 앤섬 컨셉)
DEFAULT_STYLE = (
"Korean baseball stadium cheer anthem, powerful brass fanfare, thumping "
"drum corps, group chant call-and-response, gang vocals, energetic crowd "
"shouting, 128bpm, live stadium atmosphere"
)
# LLM 에게 주는 형식 레퍼런스 (가사 구조·스타일 문구의 톤)
_REFERENCE = """[Intro - Brass Fanfare]
(두! 산! 베어스!) (두! 산! 베어스!)
[Verse 1]
잠실의 함성이 하늘을 울려
곰들의 심장이 뜨겁게 뛴다
[Chorus]
두산! (두산!) 베어스! (베어스!)
날려버려 담장 너머로
두산! (두산!) 베어스! (베어스!)
오늘 승리는 우리의 것
[Bridge - Chant]
(두산 승리! 두산 승리!)
잠실을 가득 채운 함성
[Final Chorus - Key Up]
두산! (두산!) 베어스! (베어스!)
잠실 하늘 높이 울려라
[Outro]
(두! 산! 베어스!) 최강 두산!"""
# ── 경기 맥락 조립 ─────────────────────────────────────────────
def _fmt_standing(name: str, st: dict | None) -> str:
if not st:
return f"{name}: 순위 정보 없음"
parts = [f"{st.get('rank')}위"] if st.get("rank") else []
if st.get("w") is not None:
parts.append(f"{st.get('w')}승 {st.get('d') or 0}무 {st.get('l')}패")
if st.get("wra"):
parts.append(f"승률 {st.get('wra')}")
if st.get("gb") not in (None, "", "0.0", 0):
parts.append(f"게임차 {st.get('gb')}")
if st.get("last5"):
parts.append(f"최근 5경기 {st.get('last5')}")
return f"{name}: " + ", ".join(parts)
def _fmt_starter(label: str, s: dict | None) -> str | None:
if not s or not s.get("name"):
return None
bits = [s["name"]]
if s.get("era") not in (None, ""):
bits.append(f"평균자책 {s['era']}")
if s.get("w") is not None:
bits.append(f"{s.get('w')}승 {s.get('l') or 0}패")
return f"{label} 선발: " + " ".join(bits)
async def _yesterday_line(db, m: Match, code: str, name: str) -> str | None:
"""해당 팀의 전날 경기 결과 한 줄 (없으면 None)."""
day = ensure_aware(m.kickoff_at).astimezone(KST).date() - timedelta(days=1)
lo = ensure_aware(m.kickoff_at).astimezone(KST).replace(
hour=0, minute=0, second=0, microsecond=0
) - timedelta(days=1)
hi = lo + timedelta(days=1)
rows = (
await db.execute(
select(Match).where(
Match.league == m.league,
Match.result_outcome.is_not(None),
Match.kickoff_at >= lo.astimezone(timezone.utc),
Match.kickoff_at < hi.astimezone(timezone.utc),
(Match.team_a_code == code) | (Match.team_b_code == code),
)
)
).scalars().all()
if not rows:
return f"어제({day.month}/{day.day})는 경기가 없었다"
g = rows[-1]
is_a = g.team_a_code == code
my, opp = (
(g.result_score_a, g.result_score_b) if is_a else (g.result_score_b, g.result_score_a)
)
opp_name = g.team_b_short if is_a else g.team_a_short
if my is None or opp is None:
return None
verdict = "승리" if my > opp else "패배" if my < opp else "무승부"
margin = abs((my or 0) - (opp or 0))
tight = " (1점차 석패)" if verdict == "패배" and margin == 1 else (
" (1점차 신승)" if verdict == "승리" and margin == 1 else ""
)
return f"어제 {opp_name}전 {my}:{opp} {verdict}{tight}"
async def _series_line(db, m: Match) -> str | None:
"""같은 팀 상대 연전 차수 — '3연전 중 2차전' 형태 (단일 경기면 None)."""
center = ensure_aware(m.kickoff_at).astimezone(KST).date()
lo = center - timedelta(days=3)
hi = center + timedelta(days=4)
pair = {m.team_a_code, m.team_b_code}
rows = (
await db.execute(
select(Match).where(
Match.league == m.league,
Match.team_a_code.in_(pair),
Match.team_b_code.in_(pair),
)
)
).scalars().all()
days: list[date] = sorted(
{
ensure_aware(r.kickoff_at).astimezone(KST).date()
for r in rows
if lo <= ensure_aware(r.kickoff_at).astimezone(KST).date() < hi
}
)
# 오늘을 포함해 연속된 날짜 구간만 자른다
if center not in days:
return None
run = [center]
for d in reversed([d for d in days if d < center]):
if (run[0] - d).days == 1:
run.insert(0, d)
else:
break
for d in [d for d in days if d > center]:
if (d - run[-1]).days == 1:
run.append(d)
else:
break
if len(run) < 2:
return None
idx = run.index(center) + 1
return f"{len(run)}연전 중 {idx}차전"
async def build_context(db, m: Match, side: str) -> 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
opp_name = m.team_b_short if side == "a" else m.team_a_short
opp_code = m.team_b_code if side == "a" else m.team_a_code
home = "홈" if side == "b" else "원정"
kick = ensure_aware(m.kickoff_at).astimezone(KST)
lines = [
f"우리 팀: {name} ({home} 경기)",
f"오늘 경기: {kick.month}/{kick.day} {kick:%H:%M} {m.venue or ''} — 상대 {opp_name}",
]
st_row = await db.get(DataCache, f"standings:{m.league}")
st = st_row.payload if st_row else {}
lines.append(_fmt_standing(name, st.get(code)))
lines.append(_fmt_standing(f"상대 {opp_name}", st.get(opp_code)))
prev_row = await db.get(DataCache, f"preview:{m.match_id}")
prev = prev_row.payload if prev_row else {}
my_starter = _fmt_starter("우리 팀", prev.get("starterA" if side == "a" else "starterB"))
opp_starter = _fmt_starter("상대", prev.get("starterB" if side == "a" else "starterA"))
for s in (my_starter, opp_starter):
if s:
lines.append(s)
vs = prev.get("seasonVs")
if vs:
mine = vs.get("aWin") if side == "a" else vs.get("bWin")
theirs = vs.get("bWin") if side == "a" else vs.get("aWin")
if mine is not None and theirs is not None:
lines.append(f"시즌 상대전적 {mine}승 {vs.get('draw') or 0}무 {theirs}패")
y = await _yesterday_line(db, m, code, name)
if y:
lines.append(y)
series = await _series_line(db, m)
if series:
lines.append(f"오늘은 {opp_name}와의 {series}")
return code, name, "\n".join(x for x in lines if x)
# ── LLM 작사 ───────────────────────────────────────────────────
def _lyrics_prompt(team_name: str, context: str) -> str:
return (
"너는 한국 프로야구 응원가 전문 작사가다. 아래 오늘 경기 정보를 바탕으로 "
f"'{team_name}'의 **오늘의 응원가**를 만들어라.\n\n"
f"[오늘 경기 정보]\n{context}\n\n"
"[요구사항]\n"
"- 야구장에서 수만 관중이 떼창하는 웅장한 스타디움 앤섬\n"
"- 오늘 경기 맥락(어제 결과 설욕/기세, 순위 싸움, 연전 차수, 선발투수)을 "
"가사에 구체적으로 녹일 것 — 선수 실명 사용 가능\n"
"- 괄호로 관중 콜앤리스폰스 파트 표기, 섹션 태그에 연주 지시 포함\n"
"- 분량은 1분 30초~2분 (Verse 2개 + Chorus 반복 + Bridge + Final Chorus)\n\n"
f"[가사 형식 레퍼런스 — 구조와 톤만 참고, 내용은 오늘 경기에 맞게 새로 쓸 것]\n{_REFERENCE}\n\n"
"다음 키를 가진 JSON 객체 하나만 출력하라:\n"
' "title": 곡 제목 (한국어, 25자 이내, 오늘 경기 느낌이 나게),\n'
' "style": 음악 생성기용 영어 스타일 설명 한 줄 — 예: '
f'"{DEFAULT_STYLE}" 처럼 웅장한 야구장 앤섬 계열로, 곡마다 악기·bpm 등을 변주,\n'
' "lyrics": 위 형식의 전체 가사\n'
)
def _parse_json(text: str) -> dict:
t = text.strip()
t = re.sub(r"^```(?:json)?\s*|\s*```$", "", t)
m = re.search(r"\{.*\}", t, re.S)
return json.loads(m.group(0) if m else t)
async def write_lyrics(team_name: str, context: str) -> dict:
"""LLM 으로 {title, style, lyrics} 생성 — Claude 우선, GPT 폴백."""
prompt = _lyrics_prompt(team_name, context)
if settings.anthropic_api_key:
from anthropic import AsyncAnthropic
client = AsyncAnthropic(api_key=settings.anthropic_api_key)
msg = await client.messages.create(
model=settings.anthropic_model,
max_tokens=3000,
messages=[{"role": "user", "content": prompt}],
)
text = "".join(b.text for b in msg.content if getattr(b, "type", "") == "text")
return _parse_json(text)
if settings.openai_api_key:
from openai import AsyncOpenAI
client = AsyncOpenAI(api_key=settings.openai_api_key)
resp = await client.chat.completions.create(
model=settings.openai_model,
messages=[
{"role": "system", "content": "You output only valid JSON."},
{"role": "user", "content": prompt},
],
response_format={"type": "json_object"},
)
return _parse_json(resp.choices[0].message.content or "{}")
raise RuntimeError("작사용 LLM 키 미설정 (ANTHROPIC/OPENAI)")
# ── 생성 시작 · 폴링 ───────────────────────────────────────────
def _enabled() -> bool:
return bool(
settings.songs_enabled
and settings.suno_api_key
and "kbo" in settings.league_list
)
async def _start_one(db, m: Match, side: str) -> bool:
code, name, context = await build_context(db, m, side)
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
piece = await write_lyrics(name, context)
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()
if not lyrics:
raise RuntimeError("LLM 가사 비어있음")
task_id = await suno.start_generation(title, style, lyrics)
if row is None:
row = Song(match_id=m.match_id, team_code=code)
db.add(row)
row.league = m.league
row.date_kst = ensure_aware(m.kickoff_at).astimezone(KST).date()
row.team_name = name
row.title = title
row.style = style
row.lyrics = lyrics
row.task_id = task_id
row.status = "generating"
row.error = ""
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)
return True
async def start_due_songs(db, force_today: bool = False) -> int:
"""생성 윈도우에 든 경기의 팀별 응원가 생성 시작. force_today=오늘 전 경기."""
now = now_utc()
conds = [
Match.league == "kbo",
Match.result_outcome.is_(None),
Match.status.notin_(("cancelled", "finished")),
]
matches = (await db.execute(select(Match).where(*conds))).scalars().all()
today = now.astimezone(KST).date()
started = 0
for m in matches:
kick = ensure_aware(m.kickoff_at)
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
for side in ("a", "b"):
try:
if await _start_one(db, m, side):
started += 1
except Exception as e: # noqa: BLE001 — 팀 단위 독립 실패
await db.rollback()
log.error("song 생성 실패 %s side=%s: %s", m.match_id, side, e)
return started
async def poll_generating(db) -> int:
"""generating 상태 Suno 작업 폴링 → 완료/실패 반영."""
rows = (
await db.execute(select(Song).where(Song.status == "generating"))
).scalars().all()
done = 0
for row in rows:
try:
data = await suno.get_task(row.task_id)
except Exception as e: # noqa: BLE001
log.warning("song 폴링 실패 %s: %s", row.task_id, e)
continue
status = data.get("status") or ""
if status == suno.SUNO_DONE:
tracks = suno.extract_tracks(data)
if tracks:
row.tracks = tracks
row.status = "complete"
done += 1
log.info("song 완료: %s %s (%d트랙)", row.match_id, row.team_name, len(tracks))
else:
row.status = "failed"
row.error = "SUCCESS 인데 트랙 없음"
elif status in suno.SUNO_FAILED:
row.status = "failed"
row.error = 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})"
await db.commit()
return done
async def tick_songs() -> None:
"""워커 주기 작업 — 생성 시작 + 폴링. 미설정 시 no-op."""
if not _enabled():
return
async with SessionLocal() as db:
try:
await start_due_songs(db)
except Exception as e: # noqa: BLE001
log.error("song start 오류: %s", e)
try:
await poll_generating(db)
except Exception as e: # noqa: BLE001
log.error("song poll 오류: %s", e)

View File

@ -0,0 +1,104 @@
"""Suno 음악 생성 클라이언트 — sunoapi.org 서드파티 게이트웨이.
공식 Suno API 가 없어 게이트웨이를 쓴다. 흐름:
POST /api/v1/generate (customMode: 가사·스타일·제목) → taskId
GET /api/v1/generate/record-info?taskId= → status 폴링 → sunoData[] (보통 2곡)
오디오/커버는 게이트웨이 CDN URL 을 그대로 쓴다 (당일 소비 콘텐츠).
"""
from __future__ import annotations
import logging
from ..config import settings
log = logging.getLogger("triplepick.suno")
# record-info status 값
SUNO_DONE = "SUCCESS"
SUNO_FAILED = {
"CREATE_TASK_FAILED",
"GENERATE_AUDIO_FAILED",
"CALLBACK_EXCEPTION",
"SENSITIVE_WORD_ERROR",
}
class SunoUnavailable(RuntimeError):
"""SUNO_API_KEY 미설정 등으로 호출 불가."""
class SunoError(RuntimeError):
"""게이트웨이가 에러 코드를 반환."""
def _headers() -> dict:
if not settings.suno_api_key:
raise SunoUnavailable("SUNO_API_KEY 미설정")
return {
"Authorization": f"Bearer {settings.suno_api_key}",
"Content-Type": "application/json",
}
async def start_generation(title: str, style: str, lyrics: str) -> str:
"""생성 작업 시작 → taskId. customMode: 가사(prompt)·style·title 을 그대로 사용."""
import httpx
payload = {
"prompt": lyrics[:4900],
"style": style[:950],
"title": title[:80],
"customMode": True,
"instrumental": False,
"model": settings.suno_model,
# 게이트웨이 필수 파라미터 — 실제 완료 감지는 폴링으로 한다(콜백은 싱크대).
"callBackUrl": f"{settings.public_origin}/api/songs/callback",
}
async with httpx.AsyncClient(timeout=30) as c:
r = await c.post(
f"{settings.suno_api_base}/api/v1/generate",
json=payload,
headers=_headers(),
)
r.raise_for_status()
data = r.json()
if data.get("code") != 200 or not (data.get("data") or {}).get("taskId"):
raise SunoError(f"generate 실패: {data.get('code')} {data.get('msg')}")
return data["data"]["taskId"]
async def get_task(task_id: str) -> dict:
"""작업 상태 조회 — {status, response: {sunoData: [...]}} 형태의 data 반환."""
import httpx
async with httpx.AsyncClient(timeout=30) as c:
r = await c.get(
f"{settings.suno_api_base}/api/v1/generate/record-info",
params={"taskId": task_id},
headers=_headers(),
)
r.raise_for_status()
data = r.json()
if data.get("code") != 200:
raise SunoError(f"record-info 실패: {data.get('code')} {data.get('msg')}")
return data.get("data") or {}
def extract_tracks(task_data: dict) -> list[dict]:
"""record-info data → 표준 트랙 목록 [{title, audioUrl, imageUrl, duration}]."""
items = ((task_data.get("response") or {}).get("sunoData")) or []
out = []
for it in items:
url = it.get("audioUrl") or it.get("sourceAudioUrl") or ""
if not url:
continue
out.append(
{
"title": it.get("title") or "",
"audioUrl": url,
"imageUrl": it.get("imageUrl") or it.get("sourceImageUrl") or "",
"duration": it.get("duration"),
}
)
return out

View File

@ -38,6 +38,7 @@ from .services.email import EmailUnavailable, build_result_email, send_email
from .services.grading import apply_result from .services.grading import apply_result
from .services.schedule_fetch import fetch_results, fetch_schedule from .services.schedule_fetch import fetch_results, fetch_schedule
from .services.schedule_sync import sync_schedule from .services.schedule_sync import sync_schedule
from .services.songs import tick_songs
logging.basicConfig(level=logging.INFO) logging.basicConfig(level=logging.INFO)
log = logging.getLogger("triplepick.worker") log = logging.getLogger("triplepick.worker")
@ -496,6 +497,12 @@ def build_scheduler() -> AsyncIOScheduler:
settle_baseball, "interval", settle_baseball, "interval",
seconds=settings.settle_tick_seconds, id="baseball_settle", seconds=settings.settle_tick_seconds, id="baseball_settle",
) )
# 오늘의 응원가: 킥오프 임박 경기 생성 시작 + Suno 작업 폴링
sched.add_job(
tick_songs, "interval",
seconds=settings.song_tick_seconds, id="songs",
next_run_time=now_utc(),
)
return sched return sched

View File

@ -1,9 +1,9 @@
import { useEffect } from "react"; import { useEffect, useState } from "react";
import { useLocation } from "react-router-dom"; import { useLocation } from "react-router-dom";
import { usePlayer } from "@/lib/usePlayer"; import { usePlayer } from "@/lib/usePlayer";
import { useLang } from "@/lib/useLang"; import { useLang } from "@/lib/useLang";
import { dict } from "@/lib/i18n"; import { dict } from "@/lib/i18n";
import { getTracksForPath } from "@/lib/playlist"; import { getTracksForPathAsync, type Track } from "@/lib/playlist";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// 시간 포맷 // 시간 포맷
@ -145,14 +145,21 @@ export default function MusicBar() {
loadPlaylist, loadPlaylist,
} = usePlayer(); } = usePlayer();
// 경로가 바뀌면 해당 경로의 플레이리스트 로드, 없으면 초기화 // 경로가 바뀌면 해당 경로의 플레이리스트 로드 (오늘의 응원가 API 포함), 없으면 초기화
const [tracks, setTracks] = useState<Track[]>([]);
useEffect(() => { useEffect(() => {
loadPlaylist(getTracksForPath(pathname)); let alive = true;
getTracksForPathAsync(pathname).then((ts) => {
if (!alive) return;
setTracks(ts);
loadPlaylist(ts);
});
return () => {
alive = false;
};
}, [pathname, loadPlaylist]); }, [pathname, loadPlaylist]);
if (!hasTrack) return null; if (!hasTrack || tracks.length === 0) return null;
if (getTracksForPath(pathname).length === 0) return null;
const handleProgressClick = (e: React.MouseEvent<HTMLDivElement>) => { const handleProgressClick = (e: React.MouseEvent<HTMLDivElement>) => {
const rect = e.currentTarget.getBoundingClientRect(); const rect = e.currentTarget.getBoundingClientRect();

View File

@ -43,6 +43,27 @@ export class ApiError extends Error {
} }
} }
// ── 오늘의 응원가 ────────────────────────────────────────────
export interface SongTrackOut {
title: string;
audioUrl: string;
imageUrl?: string;
duration?: number;
}
export interface SongOut {
matchId: string;
league: string;
teamCode: string;
teamName: string;
title: string;
lyrics?: string;
tracks: SongTrackOut[];
}
export function getTodaySongs(league = "kbo"): Promise<SongOut[]> {
return http<SongOut[]>(`/songs/today?league=${league}`);
}
export function listMatches(lang: Lang, league = ""): Promise<Match[]> { export function listMatches(lang: Lang, league = ""): Promise<Match[]> {
return http<Match[]>(`/matches?lang=${lang}${league ? `&league=${league}` : ""}`); return http<Match[]>(`/matches?lang=${lang}${league ? `&league=${league}` : ""}`);
} }

View File

@ -43,3 +43,47 @@ export function getTracksForPath(pathname: string): Track[] {
if (/^\/match\/KBO_(OB_[A-Z]+|[A-Z]+_OB)_\d{8}$/.test(pathname)) return DOOSAN_TRACKS; if (/^\/match\/KBO_(OB_[A-Z]+|[A-Z]+_OB)_\d{8}$/.test(pathname)) return DOOSAN_TRACKS;
return []; return [];
} }
// ── 오늘의 응원가 (백엔드 Suno 자동 생성) ─────────────────────
// 하루 단위 캐시 — 같은 세션에서 경로 이동마다 재요청하지 않는다.
import { getTodaySongs, type SongOut } from "./api";
let songsPromise: Promise<SongOut[]> | null = null;
let songsDay = "";
function todayKstKey(): string {
return new Date(Date.now() + 9 * 3600_000).toISOString().slice(0, 10);
}
function fetchSongsCached(): Promise<SongOut[]> {
const day = todayKstKey();
if (!songsPromise || songsDay !== day) {
songsDay = day;
songsPromise = getTodaySongs("kbo").catch(() => []);
}
return songsPromise;
}
function songToTracks(s: SongOut): Track[] {
const base = s.title || `${s.teamName} 응원가`;
return (s.tracks || [])
.filter((t) => t.audioUrl)
.map((t, i) => ({
title: s.tracks.length > 1 ? `${base} #${i + 1}` : base,
artist: `오늘의 ${s.teamName} 응원가`,
src: t.audioUrl,
cover: t.imageUrl,
}));
}
// 경로 → 트랙 목록 (동적 응원가 포함). 경기 상세는 그 경기 두 팀의 곡,
// 메인("/")은 오늘 전 경기 곡. 동적 곡이 없으면 기존 정적 목록 그대로.
export async function getTracksForPathAsync(pathname: string): Promise<Track[]> {
const staticTracks = getTracksForPath(pathname);
const m = pathname.match(/^\/match\/(KBO_.+)$/);
if (!m && pathname !== "/") return staticTracks;
const songs = await fetchSongsCached();
const picked = m ? songs.filter((s) => s.matchId === m[1]) : songs;
const dyn = picked.flatMap(songToTracks);
return dyn.length ? [...dyn, ...staticTracks] : staticTracks;
}