응원가 영구 보존·종료 경기 노출 — 음원 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,
|
DateTime,
|
||||||
ForeignKey,
|
ForeignKey,
|
||||||
Integer,
|
Integer,
|
||||||
|
LargeBinary,
|
||||||
String,
|
String,
|
||||||
UniqueConstraint,
|
UniqueConstraint,
|
||||||
func,
|
func,
|
||||||
@ -302,7 +303,8 @@ class Song(Base):
|
|||||||
"""오늘의 응원가 — 경기×팀 단위 Suno 생성 트랙.
|
"""오늘의 응원가 — 경기×팀 단위 Suno 생성 트랙.
|
||||||
|
|
||||||
가사·스타일은 LLM(경기 맥락 주입)이 쓰고, 음원은 Suno(sunoapi.org)가 생성.
|
가사·스타일은 LLM(경기 맥락 주입)이 쓰고, 음원은 Suno(sunoapi.org)가 생성.
|
||||||
audio/image URL 은 제공자 CDN 을 그대로 사용(당일 소비 콘텐츠라 다운로드 불필요).
|
Suno CDN URL 은 임시라 완성 후 워커가 음원을 SongAudio 로 내려받아 보존하고
|
||||||
|
tracks 의 audioUrl 을 자체 서빙 경로(/api/songs/audio/...)로 바꾼다.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__tablename__ = "songs"
|
__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):
|
class DataCache(Base):
|
||||||
"""야구(KBO/MLB) 부가 데이터 캐시 — 프리뷰·순위, API 응답·AI 프롬프트 조립용.
|
"""야구(KBO/MLB) 부가 데이터 캐시 — 프리뷰·순위, API 응답·AI 프롬프트 조립용.
|
||||||
|
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||||
@ -10,8 +11,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
|
|
||||||
from ..config import settings
|
from ..config import settings
|
||||||
from ..database import get_db
|
from ..database import get_db
|
||||||
from ..models import Match, Song
|
from ..models import Match, Song, SongAudio
|
||||||
from ..services.songs import poll_generating, start_due_songs
|
from ..services.songs import persist_audio, poll_generating, start_due_songs
|
||||||
from .admin import require_admin
|
from .admin import require_admin
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/songs", tags=["songs"])
|
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]
|
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")
|
@router.post("/callback")
|
||||||
async def suno_callback(request: Request) -> dict:
|
async def suno_callback(request: Request) -> dict:
|
||||||
"""Suno 게이트웨이 callBackUrl 싱크대 — 완료 감지는 워커 폴링이 담당."""
|
"""Suno 게이트웨이 callBackUrl 싱크대 — 완료 감지는 워커 폴링이 담당."""
|
||||||
@ -158,4 +220,5 @@ async def force_generate(db: AsyncSession = Depends(get_db)) -> dict:
|
|||||||
"""관리자: 오늘 KBO 전 경기 응원가 즉시 생성 시작 + 폴링 1회."""
|
"""관리자: 오늘 KBO 전 경기 응원가 즉시 생성 시작 + 폴링 1회."""
|
||||||
started = await start_due_songs(db, force_today=True)
|
started = await start_due_songs(db, force_today=True)
|
||||||
done = await poll_generating(db)
|
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 ..config import settings
|
||||||
from ..database import SessionLocal
|
from ..database import SessionLocal
|
||||||
from ..domain import ensure_aware, now_utc
|
from ..domain import ensure_aware, now_utc
|
||||||
from ..models import DataCache, Match, Song
|
from ..models import DataCache, Match, Song, SongAudio
|
||||||
from . import suno
|
from . import suno
|
||||||
|
|
||||||
log = logging.getLogger("triplepick.songs")
|
log = logging.getLogger("triplepick.songs")
|
||||||
@ -758,8 +758,76 @@ async def poll_generating(db) -> int:
|
|||||||
return done
|
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:
|
async def tick_songs() -> None:
|
||||||
"""워커 주기 작업 — 생성 시작 + 폴링. 미설정 시 no-op."""
|
"""워커 주기 작업 — 생성 시작 + 폴링 + 음원 보존. 미설정 시 no-op."""
|
||||||
if not _enabled():
|
if not _enabled():
|
||||||
return
|
return
|
||||||
async with SessionLocal() as db:
|
async with SessionLocal() as db:
|
||||||
@ -771,3 +839,7 @@ async def tick_songs() -> None:
|
|||||||
await poll_generating(db)
|
await poll_generating(db)
|
||||||
except Exception as e: # noqa: BLE001
|
except Exception as e: # noqa: BLE001
|
||||||
log.error("song poll 오류: %s", e)
|
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}` : ""}`);
|
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[]> {
|
export function listMatches(lang: Lang, league = ""): Promise<Match[]> {
|
||||||
return http<Match[]>(`/matches?lang=${lang}${league ? `&league=${league}` : ""}`);
|
return http<Match[]>(`/matches?lang=${lang}${league ? `&league=${league}` : ""}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -25,27 +25,19 @@ export function getTracksForPath(pathname: string): Track[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── 오늘의 응원가 (백엔드 Suno 자동 생성) ─────────────────────
|
// ── 오늘의 응원가 (백엔드 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 로 재조회
|
// 곡은 라인업 반영 재생성으로 당일 중에도 교체되므로 짧은 TTL 로 재조회
|
||||||
const SONGS_TTL_MS = 5 * 60_000;
|
const SONGS_TTL_MS = 5 * 60_000;
|
||||||
|
const matchSongsCache = new Map<string, { at: number; p: Promise<SongOut[]> }>();
|
||||||
|
|
||||||
function todayKstKey(): string {
|
function fetchMatchSongsCached(matchId: string): Promise<SongOut[]> {
|
||||||
return new Date(Date.now() + 9 * 3600_000).toISOString().slice(0, 10);
|
const hit = matchSongsCache.get(matchId);
|
||||||
}
|
if (hit && Date.now() - hit.at <= SONGS_TTL_MS) return hit.p;
|
||||||
|
const p = getMatchSongs(matchId).catch(() => []);
|
||||||
function fetchSongsCached(): Promise<SongOut[]> {
|
matchSongsCache.set(matchId, { at: Date.now(), p });
|
||||||
const day = todayKstKey();
|
return p;
|
||||||
if (!songsPromise || songsDay !== day || Date.now() - songsAt > SONGS_TTL_MS) {
|
|
||||||
songsDay = day;
|
|
||||||
songsAt = Date.now();
|
|
||||||
songsPromise = getTodaySongs().catch(() => []); // 전 리그
|
|
||||||
}
|
|
||||||
return songsPromise;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function songToTracks(s: SongOut): Track[] {
|
function songToTracks(s: SongOut): Track[] {
|
||||||
@ -66,13 +58,13 @@ function songToTracks(s: SongOut): Track[] {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 특정 경기·팀의 오늘의 응원가 트랙 — 상세 페이지 '응원가 듣기' 버튼용.
|
// 특정 경기·팀의 응원가 트랙 — 상세 페이지 '응원가 듣기' 버튼용.
|
||||||
// 전 팀 동일 로직(그날 생성곡만). 곡이 없으면 빈 배열.
|
// 종료된 경기도 그날 생성곡을 그대로 노출한다. 곡이 없으면 빈 배열.
|
||||||
export async function getTeamSongTracks(
|
export async function getTeamSongTracks(
|
||||||
matchId: string,
|
matchId: string,
|
||||||
teamCode: string,
|
teamCode: string,
|
||||||
): Promise<Track[]> {
|
): Promise<Track[]> {
|
||||||
const songs = await fetchSongsCached();
|
const songs = await fetchMatchSongsCached(matchId);
|
||||||
return songs
|
return songs
|
||||||
.filter((s) => s.matchId === matchId && s.teamCode === teamCode)
|
.filter((s) => s.matchId === matchId && s.teamCode === teamCode)
|
||||||
.flatMap(songToTracks);
|
.flatMap(songToTracks);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user