오늘의 응원가 자동 생성 파이프라인 (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:
parent
2a8f2bc1fa
commit
277378477a
@ -81,3 +81,7 @@ SMTP_USER=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_FROM=TriplePick <no-reply@triplepick.app>
|
||||
SMTP_STARTTLS=true
|
||||
|
||||
# Suno (sunoapi.org 게이트웨이) — 오늘의 응원가 자동 생성
|
||||
SUNO_API_KEY=
|
||||
SONGS_ENABLED=true
|
||||
|
||||
@ -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).
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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 프롬프트 조립용.
|
||||
|
||||
|
||||
60
backend/app/routers/songs.py
Normal file
60
backend/app/routers/songs.py
Normal 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}
|
||||
397
backend/app/services/songs.py
Normal file
397
backend/app/services/songs.py
Normal 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)
|
||||
104
backend/app/services/suno.py
Normal file
104
backend/app/services/suno.py
Normal 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
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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<Track[]>([]);
|
||||
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<HTMLDivElement>) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
|
||||
@ -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[]> {
|
||||
return http<Match[]>(`/matches?lang=${lang}${league ? `&league=${league}` : ""}`);
|
||||
}
|
||||
|
||||
@ -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<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;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user