From 277378477a9b0ba3732dd06b6a20bca677fa218a Mon Sep 17 00:00:00 2001 From: jwkim Date: Tue, 25 Aug 2026 09:33:34 +0900 Subject: [PATCH] =?UTF-8?q?=EC=98=A4=EB=8A=98=EC=9D=98=20=EC=9D=91?= =?UTF-8?q?=EC=9B=90=EA=B0=80=20=EC=9E=90=EB=8F=99=20=EC=83=9D=EC=84=B1=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=B4=ED=94=84=EB=9D=BC=EC=9D=B8=20(Suno)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 경기 맥락(전날 결과·순위·연전 차수·선발투수) 조립 → 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 --- backend/.env.example | 4 + backend/app/config.py | 12 + backend/app/main.py | 2 + backend/app/models.py | 41 +++ backend/app/routers/songs.py | 60 ++++ backend/app/services/songs.py | 397 +++++++++++++++++++++++++++ backend/app/services/suno.py | 104 +++++++ backend/app/worker.py | 7 + frontend/src/components/MusicBar.tsx | 21 +- frontend/src/lib/api.ts | 21 ++ frontend/src/lib/playlist.ts | 44 +++ 11 files changed, 706 insertions(+), 7 deletions(-) create mode 100644 backend/app/routers/songs.py create mode 100644 backend/app/services/songs.py create mode 100644 backend/app/services/suno.py diff --git a/backend/.env.example b/backend/.env.example index e2e50b7..c75f9e8 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -81,3 +81,7 @@ SMTP_USER= SMTP_PASSWORD= SMTP_FROM=TriplePick SMTP_STARTTLS=true + +# Suno (sunoapi.org 게이트웨이) — 오늘의 응원가 자동 생성 +SUNO_API_KEY= +SONGS_ENABLED=true diff --git a/backend/app/config.py b/backend/app/config.py index 796d533..b7af8be 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -173,6 +173,18 @@ class Settings(BaseSettings): football_refresh_hour: 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. # AZURE_ACS_SENDER 는 검증된 MailFrom 주소(예: donotreply@triplepick.o2o.kr). diff --git a/backend/app/main.py b/backend/app/main.py index 7a8b1f7..4ed4c61 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -21,6 +21,7 @@ from .routers import ( matches, predictions, share, + songs, standings, visits, ) @@ -58,6 +59,7 @@ app.include_router(leaderboard.router) app.include_router(standings.router) app.include_router(admin.router) app.include_router(visits.router) +app.include_router(songs.router) # 공유 미리보기(OG) 프리렌더 — nginx 가 크롤러 UA 의 /match/:id 만 여기로 보낸다. app.include_router(share.router) diff --git a/backend/app/models.py b/backend/app/models.py index cb5ad61..4adb0a0 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -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): """야구(KBO/MLB) 부가 데이터 캐시 — 프리뷰·순위, API 응답·AI 프롬프트 조립용. diff --git a/backend/app/routers/songs.py b/backend/app/routers/songs.py new file mode 100644 index 0000000..c4461ce --- /dev/null +++ b/backend/app/routers/songs.py @@ -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} diff --git a/backend/app/services/songs.py b/backend/app/services/songs.py new file mode 100644 index 0000000..3cfb4a6 --- /dev/null +++ b/backend/app/services/songs.py @@ -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) diff --git a/backend/app/services/suno.py b/backend/app/services/suno.py new file mode 100644 index 0000000..28e75d8 --- /dev/null +++ b/backend/app/services/suno.py @@ -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 diff --git a/backend/app/worker.py b/backend/app/worker.py index 8c2a89d..936dba5 100644 --- a/backend/app/worker.py +++ b/backend/app/worker.py @@ -38,6 +38,7 @@ from .services.email import EmailUnavailable, build_result_email, send_email from .services.grading import apply_result from .services.schedule_fetch import fetch_results, fetch_schedule from .services.schedule_sync import sync_schedule +from .services.songs import tick_songs logging.basicConfig(level=logging.INFO) log = logging.getLogger("triplepick.worker") @@ -496,6 +497,12 @@ def build_scheduler() -> AsyncIOScheduler: settle_baseball, "interval", 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 diff --git a/frontend/src/components/MusicBar.tsx b/frontend/src/components/MusicBar.tsx index dadd026..5b58372 100644 --- a/frontend/src/components/MusicBar.tsx +++ b/frontend/src/components/MusicBar.tsx @@ -1,9 +1,9 @@ -import { useEffect } from "react"; +import { useEffect, useState } from "react"; import { useLocation } from "react-router-dom"; import { usePlayer } from "@/lib/usePlayer"; import { useLang } from "@/lib/useLang"; 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, } = usePlayer(); - // 경로가 바뀌면 해당 경로의 플레이리스트 로드, 없으면 초기화 + // 경로가 바뀌면 해당 경로의 플레이리스트 로드 (오늘의 응원가 API 포함), 없으면 초기화 + const [tracks, setTracks] = useState([]); useEffect(() => { - loadPlaylist(getTracksForPath(pathname)); + let alive = true; + getTracksForPathAsync(pathname).then((ts) => { + if (!alive) return; + setTracks(ts); + loadPlaylist(ts); + }); + return () => { + alive = false; + }; }, [pathname, loadPlaylist]); - if (!hasTrack) return null; - - if (getTracksForPath(pathname).length === 0) return null; + if (!hasTrack || tracks.length === 0) return null; const handleProgressClick = (e: React.MouseEvent) => { const rect = e.currentTarget.getBoundingClientRect(); diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 1156f2f..0068938 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -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 { + return http(`/songs/today?league=${league}`); +} + export function listMatches(lang: Lang, league = ""): Promise { return http(`/matches?lang=${lang}${league ? `&league=${league}` : ""}`); } diff --git a/frontend/src/lib/playlist.ts b/frontend/src/lib/playlist.ts index ef0a46e..cf39f55 100644 --- a/frontend/src/lib/playlist.ts +++ b/frontend/src/lib/playlist.ts @@ -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; return []; } + +// ── 오늘의 응원가 (백엔드 Suno 자동 생성) ───────────────────── +// 하루 단위 캐시 — 같은 세션에서 경로 이동마다 재요청하지 않는다. +import { getTodaySongs, type SongOut } from "./api"; + +let songsPromise: Promise | null = null; +let songsDay = ""; + +function todayKstKey(): string { + return new Date(Date.now() + 9 * 3600_000).toISOString().slice(0, 10); +} + +function fetchSongsCached(): Promise { + 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 { + 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; +}