응원가 영구 보존·종료 경기 노출 — 음원 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:
parent
bc333d64f4
commit
fdb04a2ccc
@ -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 프롬프트 조립용.
|
||||
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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}` : ""}`);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user