diff --git a/backend/app/models.py b/backend/app/models.py index f31f626..6ea5719 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -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 프롬프트 조립용. diff --git a/backend/app/routers/songs.py b/backend/app/routers/songs.py index b7276b9..58d543f 100644 --- a/backend/app/routers/songs.py +++ b/backend/app/routers/songs.py @@ -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} diff --git a/backend/app/services/songs.py b/backend/app/services/songs.py index 031ee86..c260c1e 100644 --- a/backend/app/services/songs.py +++ b/backend/app/services/songs.py @@ -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) diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 74ca2bc..33eab75 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -64,6 +64,11 @@ export function getTodaySongs(league = ""): Promise { return http(`/songs/today${league ? `?league=${league}` : ""}`); } +// 경기별 응원가 — 날짜 무관이라 종료된 경기 상세에서도 곡이 나온다 +export function getMatchSongs(matchId: string): Promise { + return http(`/songs/match/${matchId}`); +} + export function listMatches(lang: Lang, league = ""): Promise { return http(`/matches?lang=${lang}${league ? `&league=${league}` : ""}`); } diff --git a/frontend/src/lib/playlist.ts b/frontend/src/lib/playlist.ts index 6379d67..4f6f2de 100644 --- a/frontend/src/lib/playlist.ts +++ b/frontend/src/lib/playlist.ts @@ -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 | null = null; -let songsDay = ""; -let songsAt = 0; // 곡은 라인업 반영 재생성으로 당일 중에도 교체되므로 짧은 TTL 로 재조회 const SONGS_TTL_MS = 5 * 60_000; +const matchSongsCache = new Map }>(); -function todayKstKey(): string { - return new Date(Date.now() + 9 * 3600_000).toISOString().slice(0, 10); -} - -function fetchSongsCached(): Promise { - 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 { + 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 { - const songs = await fetchSongsCached(); + const songs = await fetchMatchSongsCached(matchId); return songs .filter((s) => s.matchId === matchId && s.teamCode === teamCode) .flatMap(songToTracks);