응원가 영구 보존·종료 경기 노출 — 음원 DB 저장 + 경기별 조회 API

- Suno CDN 임시 URL 만료 대비: 완성 곡 첫 트랙을 워커가 다운로드해
  song_audio(bytea)로 보존하고 audioUrl을 /api/songs/audio/{id}/{idx}로 재작성
  (틱당 5곡, 실패 3회 상한 — 기존 곡도 자동 백필)
- GET /api/songs/match/{match_id} 추가 — 날짜 무관 조회로 종료된 경기
  상세에서도 '응원가 듣기' 버튼 노출
- 프론트 getTeamSongTracks를 오늘 전체 캐시 → 경기별 조회·캐시로 전환
- 음원 서빙은 Range(206) 지원, immutable 캐시 + task_id 캐시버스터

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jwkim 2026-08-28 15:39:16 +09:00
parent bc333d64f4
commit fdb04a2ccc
5 changed files with 186 additions and 26 deletions

View File

@ -19,6 +19,7 @@ from sqlalchemy import (
DateTime,
ForeignKey,
Integer,
LargeBinary,
String,
UniqueConstraint,
func,
@ -302,7 +303,8 @@ class Song(Base):
"""오늘의 응원가 — 경기×팀 단위 Suno 생성 트랙.
가사·스타일은 LLM(경기 맥락 주입)이 쓰고, 음원은 Suno(sunoapi.org)가 생성.
audio/image URL 은 제공자 CDN 을 그대로 사용(당일 소비 콘텐츠라 다운로드 불필요).
Suno CDN URL 은 임시라 완성 후 워커가 음원을 SongAudio 로 내려받아 보존하고
tracks 의 audioUrl 을 자체 서빙 경로(/api/songs/audio/...)로 바꾼다.
"""
__tablename__ = "songs"
@ -341,6 +343,32 @@ class Song(Base):
)
class SongAudio(Base):
"""응원가 음원 원본 — Suno CDN 임시 URL 만료 대비 DB 보존.
노출 트랙(첫 트랙)만 저장한다. 업그레이드(라인업 반영 재생성) 시
같은 (song_id, track_idx) 행을 새 음원으로 교체.
"""
__tablename__ = "song_audio"
__table_args__ = (
UniqueConstraint("song_id", "track_idx", name="uq_song_audio_track"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
song_id: Mapped[int] = mapped_column(
ForeignKey("songs.id", ondelete="CASCADE"), index=True
)
track_idx: Mapped[int] = mapped_column(Integer, default=0)
mime: Mapped[str] = mapped_column(String, default="audio/mpeg")
size: Mapped[int] = mapped_column(Integer, default=0)
data: Mapped[bytes] = mapped_column(LargeBinary)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
class DataCache(Base):
"""야구(KBO/MLB) 부가 데이터 캐시 — 프리뷰·순위, API 응답·AI 프롬프트 조립용.

View File

@ -2,6 +2,7 @@
from __future__ import annotations
import logging
import re
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException, Request, Response
@ -10,8 +11,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
from ..config import settings
from ..database import get_db
from ..models import Match, Song
from ..services.songs import poll_generating, start_due_songs
from ..models import Match, Song, SongAudio
from ..services.songs import persist_audio, poll_generating, start_due_songs
from .admin import require_admin
router = APIRouter(prefix="/api/songs", tags=["songs"])
@ -143,6 +144,67 @@ async def today_songs(league: str = "", db: AsyncSession = Depends(get_db)) -> l
return [_song_out(s) for s in rows if s.tracks]
@router.get("/match/{match_id}")
async def match_songs(match_id: str, db: AsyncSession = Depends(get_db)) -> list[dict]:
"""경기별 응원가 — 날짜 무관. 종료된 경기 상세에서도 그날 곡을 노출한다."""
rows = (
await db.execute(
select(Song)
.where(
Song.match_id == match_id,
Song.status.in_(("complete", "generating")),
)
.order_by(Song.team_code)
)
).scalars().all()
return [_song_out(s) for s in rows if s.tracks]
@router.get("/audio/{song_id}/{track_idx}")
async def song_audio_bytes(
song_id: int,
track_idx: int,
request: Request,
db: AsyncSession = Depends(get_db),
) -> Response:
"""DB 보존 음원 서빙 — Range 지원(재생 위치 탐색용). v= 쿼리로 캐시버스트."""
row = (
await db.execute(
select(SongAudio).where(
SongAudio.song_id == song_id, SongAudio.track_idx == track_idx
)
)
).scalar_one_or_none()
if not row:
raise HTTPException(status_code=404, detail="AUDIO_NOT_FOUND")
data, total = row.data, len(row.data)
headers = {
"Accept-Ranges": "bytes",
"Cache-Control": "public, max-age=31536000, immutable",
}
m = re.fullmatch(r"bytes=(\d*)-(\d*)", request.headers.get("range") or "")
if m and (m.group(1) or m.group(2)):
if m.group(1):
start = int(m.group(1))
end = int(m.group(2)) if m.group(2) else total - 1
else: # suffix range: bytes=-N (마지막 N바이트)
start = max(0, total - int(m.group(2)))
end = total - 1
if start >= total or start > end:
return Response(
status_code=416, headers={"Content-Range": f"bytes */{total}"}
)
end = min(end, total - 1)
headers["Content-Range"] = f"bytes {start}-{end}/{total}"
return Response(
content=data[start : end + 1],
status_code=206,
media_type=row.mime,
headers=headers,
)
return Response(content=data, media_type=row.mime, headers=headers)
@router.post("/callback")
async def suno_callback(request: Request) -> dict:
"""Suno 게이트웨이 callBackUrl 싱크대 — 완료 감지는 워커 폴링이 담당."""
@ -158,4 +220,5 @@ 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}
saved = await persist_audio(db)
return {"ok": True, "started": started, "completed": done, "persisted": saved}

View File

@ -21,7 +21,7 @@ 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 ..models import DataCache, Match, Song, SongAudio
from . import suno
log = logging.getLogger("triplepick.songs")
@ -758,8 +758,76 @@ async def poll_generating(db) -> int:
return done
PERSIST_MAX_FAILS = 3 # 트랙별 다운로드 실패 상한 (만료 URL 무한 재시도 방지)
PERSIST_PER_TICK = 5 # 틱당 다운로드 곡 수 상한 — 틱 지연 방지
async def persist_audio(db, limit: int = PERSIST_PER_TICK) -> int:
"""완성 곡의 노출 트랙(첫 트랙) 음원을 DB(SongAudio)로 보존.
Suno CDN URL 은 임시라 종료된 경기의 응원가도 계속 재생하려면 원본을
내려받아야 한다. audioUrl 이 아직 원격(http)인 complete 곡을 골라
다운로드 → SongAudio 교체 저장 → audioUrl 을 자체 경로로 재작성.
업그레이드로 트랙이 원격 URL 로 갈리면 자동으로 다시 저장된다.
"""
import httpx
rows = (
await db.execute(select(Song).where(Song.status == "complete"))
).scalars().all()
todo = []
for row in rows:
t = (row.tracks or [None])[0]
if not t or not (t.get("audioUrl") or "").startswith("http"):
continue
if (t.get("persistFails") or 0) >= PERSIST_MAX_FAILS:
continue
todo.append(row)
saved = 0
for row in todo[:limit]:
t = dict(row.tracks[0])
try:
async with httpx.AsyncClient(timeout=90, follow_redirects=True) as c:
r = await c.get(t["audioUrl"])
r.raise_for_status()
body = r.content
if not body:
raise ValueError("빈 응답")
mime = (r.headers.get("content-type") or "audio/mpeg").split(";")[0]
except Exception as e: # noqa: BLE001
t["persistFails"] = (t.get("persistFails") or 0) + 1
row.tracks = [t, *row.tracks[1:]]
await db.commit()
log.warning(
"song 음원 보존 실패 %s %s (%d회): %s",
row.match_id, row.team_code, t["persistFails"], e,
)
continue
existing = (
await db.execute(
select(SongAudio).where(
SongAudio.song_id == row.id, SongAudio.track_idx == 0
)
)
).scalar_one_or_none()
if existing:
await db.delete(existing)
await db.flush()
db.add(SongAudio(song_id=row.id, track_idx=0, mime=mime, size=len(body), data=body))
t["sourceUrl"] = t["audioUrl"]
t["audioUrl"] = f"/api/songs/audio/{row.id}/0?v={(row.task_id or '')[:8]}"
t.pop("persistFails", None)
row.tracks = [t, *row.tracks[1:]]
await db.commit()
saved += 1
log.info(
"song 음원 보존: %s %s (%.1fMB)", row.match_id, row.team_code, len(body) / 1e6
)
return saved
async def tick_songs() -> None:
"""워커 주기 작업 — 생성 시작 + 폴링. 미설정 시 no-op."""
"""워커 주기 작업 — 생성 시작 + 폴링 + 음원 보존. 미설정 시 no-op."""
if not _enabled():
return
async with SessionLocal() as db:
@ -771,3 +839,7 @@ async def tick_songs() -> None:
await poll_generating(db)
except Exception as e: # noqa: BLE001
log.error("song poll 오류: %s", e)
try:
await persist_audio(db)
except Exception as e: # noqa: BLE001
log.error("song 음원 보존 오류: %s", e)

View File

@ -64,6 +64,11 @@ export function getTodaySongs(league = ""): Promise<SongOut[]> {
return http<SongOut[]>(`/songs/today${league ? `?league=${league}` : ""}`);
}
// 경기별 응원가 — 날짜 무관이라 종료된 경기 상세에서도 곡이 나온다
export function getMatchSongs(matchId: string): Promise<SongOut[]> {
return http<SongOut[]>(`/songs/match/${matchId}`);
}
export function listMatches(lang: Lang, league = ""): Promise<Match[]> {
return http<Match[]>(`/matches?lang=${lang}${league ? `&league=${league}` : ""}`);
}

View File

@ -25,27 +25,19 @@ export function getTracksForPath(pathname: string): Track[] {
}
// ── 오늘의 응원가 (백엔드 Suno 자동 생성) ─────────────────────
// 하루 단위 캐시 — 같은 세션에서 경로 이동마다 재요청하지 않는다.
import { getTodaySongs, type SongOut } from "./api";
// 경기 단위 조회·캐시 — 날짜 무관이라 종료된 경기 상세에서도 곡이 나온다.
import { getMatchSongs, type SongOut } from "./api";
let songsPromise: Promise<SongOut[]> | null = null;
let songsDay = "";
let songsAt = 0;
// 곡은 라인업 반영 재생성으로 당일 중에도 교체되므로 짧은 TTL 로 재조회
const SONGS_TTL_MS = 5 * 60_000;
const matchSongsCache = new Map<string, { at: number; p: Promise<SongOut[]> }>();
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 || Date.now() - songsAt > SONGS_TTL_MS) {
songsDay = day;
songsAt = Date.now();
songsPromise = getTodaySongs().catch(() => []); // 전 리그
}
return songsPromise;
function fetchMatchSongsCached(matchId: string): Promise<SongOut[]> {
const hit = matchSongsCache.get(matchId);
if (hit && Date.now() - hit.at <= SONGS_TTL_MS) return hit.p;
const p = getMatchSongs(matchId).catch(() => []);
matchSongsCache.set(matchId, { at: Date.now(), p });
return p;
}
function songToTracks(s: SongOut): Track[] {
@ -66,13 +58,13 @@ function songToTracks(s: SongOut): Track[] {
}));
}
// 특정 경기·팀의 오늘의 응원가 트랙 — 상세 페이지 '응원가 듣기' 버튼용.
// 전 팀 동일 로직(그날 생성곡만). 곡이 없으면 빈 배열.
// 특정 경기·팀의 응원가 트랙 — 상세 페이지 '응원가 듣기' 버튼용.
// 종료된 경기도 그날 생성곡을 그대로 노출한다. 곡이 없으면 빈 배열.
export async function getTeamSongTracks(
matchId: string,
teamCode: string,
): Promise<Track[]> {
const songs = await fetchSongsCached();
const songs = await fetchMatchSongsCached(matchId);
return songs
.filter((s) => s.matchId === matchId && s.teamCode === teamCode)
.flatMap(songToTracks);