응원가 영구 보존·종료 경기 노출 — 음원 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);