o2o-site-AEO/solution/site/src/sections/SongPlayer.tsx
hbyang 38bb4be9e5 [feat] solution,postgres-init: 발행하면 이 숙소의 노래가 한 곡 생긴다 — 가사 Gemini · 작곡 Suno
/s/stay 시안의 헤더에는 노래 플레이어가 있는데 그건 손으로 채운 목업이라, 새로 발행한
사이트에는 그 자리가 아예 없었다. 이제 발행이 노래를 만든다.

★ 발행이 노래를 기다린다. BUILD 잡이 스냅샷을 뜨기 **전에** 곡을 만든다 —
  먼저 굽고 나중에 붙이면 사장님이 [사이트 열기] 로 보는 첫 화면에 그 기능이 빠져 있다.
  값은 발행이 30~40초(실측, 상한 5분) 늦어지는 것이고 그건 감수한다.
  단 실패는 발행을 막지 않는다 — 기다리는 것과 막는 것은 다르다. 키가 없거나 작곡이
  실패하면 노래 없이 발행되고 사유가 빌드 로그와 place_songs.last_error 에 남는다.

★ 가사를 우리가 쓴다. Suno 에 주제만 던지면 가사를 저쪽이 짓고, 거기엔 이 숙소에 없는
  것(수영장·조식)이 섞이는데 검증할 방법이 없다 — 다른 모든 문장은 확인된 fact 로만 쓰면서
  노래만 지어낸 말을 싣는 꼴이다. 소개문과 **같은 재료**로 Gemini 가 쓰고 Suno 는 곡만 붙인다.
  가사에 ground_check 는 걸지 않는다(정서는 fact 로 대응되지 않는다). 대신 프롬프트가
  없는 시설·숫자를 말하지 말라고 못 박는다 — 요금을 노래에 넣으면 틀렸을 때 고쳐 부를 수 없다.

★ Suno 주소는 만료된다. 그 주소를 payload 에 실으면 발행 직후엔 재생되고 몇 주 뒤 조용히
  죽는다. mp3 를 받아 보관하고 우리 경로(/s/<slug>/<song_id>.mp3)만 내보낸다.
★ 콜백이 아니라 폴링이다. 우리 백엔드는 Suno 가 닿을 수 있는 주소가 아니라, 콜백을 믿으면
  "요청은 성공했는데 결과가 영영 안 옴" 이 된다.

- services/external/suno.py: 작곡 요청 + record-info 폴링(10초 간격·상한 5분) + 내려받기
- services/external/gemini_text.generate_song · prompts/song.py: 가사·제목·장르
- services/song_service.py: 재료 → 가사 → 작곡 → 파일 보관. ensure_song 을 빌드가 부른다
- build_service: publish 일 때만 ensure_song 을 먼저 부르고 그 뒤 스냅샷(미리보기는 안 만든다 — 유료)
- place_songs 표 신설(init.sql + 0010 마이그레이션 + ORM). 검증 상태가 없다 —
  수집한 사실이 아니라 창작물이라 "맞는가" 가 아니라 "만들어졌는가" 만 묻는다(SongStatus)
- snapshot·site_payload·shared: READY 인 최신 한 곡만 싣는다. audioUrl 은 우리 경로다
- prerender: songs/ 의 파일을 사이트 디렉토리로 복사하고 **지난 발행의 곡은 치운다**
  (발행마다 새 곡이라 안 치우면 1MB 짜리가 쌓이고 블롭에도 그대로 올라간다)
- site/SongPlayer: 헤더의 작은 플레이어. 자동 재생하지 않고, 곡이 없으면 아무것도 안 그린다.
  패널은 hidden 으로 여닫는다 — 조건부 렌더면 닫힌 동안 제목·가사가 DOM 에 없어 크롤러가
  못 읽는다(오디오 안의 말은 어차피 못 듣는다)
- azure_static: .mp3 content-type 과 immutable 캐시. 블롭 업로드는 발행이 사이트째 한다 —
  업로더를 하나 더 두면 같은 컨테이너에 경로·캐시·정리 규칙이 두 벌 생긴다
- compose: solution/site/songs 볼륨. .env.example 에 SUNO_API_KEY·SUNO_CALLBACK_URL

검증: 실제 발행(스테이,머뭄 v15) — 가사 154자 $0.0014 → 작곡 40초 → 1.98MB → 스냅샷(노래 1)
→ 발행 완료. /s/스테이머뭄-99a887f8 200, mp3 200 audio/mpeg, HTML 에 제목·가사·주소 확인,
지난 곡 404. tsc --noEmit · eslint · vitest 55 passed(신규 4) · 백엔드 관련 188 passed
(실패 5건은 전부 컨테이너 환경 유입 — 프론트 소스 부재·SITE_PUBLIC_HOST)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 11:21:16 +09:00

168 lines
7.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useRef, useState } from "react";
import { ListMusic, Pause, Play } from "lucide-react";
import { useSite } from "@site/lib/site-context";
/**
* 이 숙소의 노래 — 헤더의 작은 플레이어.
*
* ★ 무엇인가 · 무엇이 아닌가
* 발행할 때 이 숙소의 사실로 쓴 가사에 곡을 붙여 만든 노래다(백엔드 `song_service`).
* 배경음악처럼 **저절로 틀지 않는다** — 손님이 누를 때만 난다. 자동 재생은 브라우저가
* 막기도 하거니와, 막지 않는 경우가 더 나쁘다(소리가 갑자기 나는 페이지는 닫힌다).
*
* ★ 곡이 없으면 아무것도 그리지 않는다.
* 발행은 노래를 기다리므로 보통은 있다. 그래도 없을 수 있다 — 키가 없거나(SUNO_API_KEY),
* 작곡이 실패하거나, 옛 버전으로 구운 사이트다. 그때 빈 플레이어를 그리면 손님에게는
* **고장난 버튼**이다. 없으면 없는 대로 둔다.
*
* ★ 가사를 함께 싣는다.
* 무슨 노래인지 읽히지 않으면 아무도 누르지 않는다. 그리고 이 가사는 화면에 있는 편이
* 검색·AI 쪽에도 맞다 — 오디오 안의 말은 크롤러가 못 듣는다.
*
* ★ 패널은 조건부 렌더가 아니라 `hidden` 으로 여닫는다.
* `{open && ...}` 로 두면 **닫힌 동안 곡 제목과 가사가 DOM 에 아예 없다.** 서버 렌더 결과가
* 곧 크롤러가 읽는 전부인데(정적 발행), 그러면 이 노래는 검색·AI 쪽에 존재하지 않는 것과
* 같아진다 — 오디오 안의 말은 크롤러가 못 듣기 때문에 가사가 유일한 단서다.
* 그래서 markup 은 늘 있고, 보이고 안 보이고만 바뀐다.
*/
export function SongPlayer() {
const payload = useSite();
const songs = payload.songs ?? [];
const [open, setOpen] = useState(false);
const [playing, setPlaying] = useState<string | null>(null);
const audioRef = useRef<HTMLAudioElement | null>(null);
// 페이지를 떠나거나 컴포넌트가 사라질 때 소리를 끊는다 — 리액트가 노드를 지워도
// 재생 중인 오디오는 계속 난다(브라우저가 소리를 DOM 과 함께 정리하지 않는다).
useEffect(() => () => audioRef.current?.pause(), []);
if (songs.length === 0) return null;
const toggle = (songId: string, url: string) => {
const audio = audioRef.current;
if (!audio) return;
if (playing === songId) {
audio.pause();
setPlaying(null);
return;
}
// 다른 곡을 누르면 하던 것을 끊고 갈아탄다.
audio.src = url;
// 재생은 거절될 수 있다(자동재생 정책·네트워크). 그때 '재생 중' 으로 표시해 두면
// 소리는 안 나는데 멈춤 버튼만 보인다 — 성공했을 때만 상태를 바꾼다.
void audio.play().then(
() => setPlaying(songId),
() => setPlaying(null),
);
};
// 가사는 **고른 곡**의 것을 보여준다. 아직 아무것도 안 눌렀으면(서버 렌더 포함) 첫 곡이다 —
// 그래야 크롤러가 읽는 HTML 에도 가사가 들어간다.
const current = songs.find((song) => song.songId === playing) ?? songs[0];
return (
<div className="relative">
<button
type="button"
onClick={() => setOpen((was) => !was)}
aria-expanded={open}
aria-label="노래 목록"
title="노래 목록"
className="border-line hover:bg-card flex size-9 items-center justify-center rounded-md border transition-colors"
>
{playing ? (
<Pause className="size-4" aria-hidden />
) : (
<ListMusic className="size-4" aria-hidden />
)}
</button>
{/*
★ 오디오 태그는 패널 밖에 둔다. 패널 안에 두면 목록을 닫는 순간 노드가 사라져
듣던 노래가 끊긴다 — 닫고 계속 듣는 것이 이 플레이어의 기본 사용법이다.
★ preload="none": 아무도 안 누를 수도 있는 파일을 모든 방문자에게 내려받게 하지 않는다.
*/}
{/*
★ 첫 곡의 주소를 markup 에 박아 둔다. 누를 때 비로소 src 를 넣으면 **서버 렌더 결과에
오디오 주소가 한 글자도 없다** — 크롤러에게 이 페이지는 소리 없는 페이지다.
preload="none" 이라 주소만 있고 받아 오지는 않는다.
*/}
<audio
ref={audioRef}
src={songs[0]?.audioUrl}
preload="none"
onEnded={() => setPlaying(null)}
/>
<div
hidden={!open}
className="border-line absolute right-0 top-11 z-50 w-[min(22rem,calc(100vw-2rem))] overflow-hidden rounded-lg border shadow-lg"
style={{ backgroundColor: "var(--tpl-surface, #fff)" }}
>
<div className="border-line flex items-center justify-between border-b px-4 py-2.5">
<span className="serif text-[length:var(--fs-sm)]">
{payload.place.name}()
</span>
<button
type="button"
onClick={() => setOpen(false)}
aria-label="닫기"
className="text-muted px-1 text-lg leading-none"
>
×
</button>
</div>
<ul>
{songs.map((song, index) => (
<li
key={song.songId}
className="border-line border-b last:border-b-0"
>
<button
type="button"
onClick={() => toggle(song.songId, song.audioUrl)}
className="hover:bg-card flex w-full items-center gap-3 px-4 py-3 text-left transition-colors"
style={
playing === song.songId
? { color: "var(--color-brand)" }
: undefined
}
>
<span className="text-muted w-4 shrink-0 text-[length:var(--fs-xs)] tabular-nums">
{index + 1}
</span>
{playing === song.songId ? (
<Pause className="size-3.5 shrink-0" aria-hidden />
) : (
<Play className="size-3.5 shrink-0" aria-hidden />
)}
<span className="min-w-0 flex-1 truncate text-[length:var(--fs-sm)]">
{song.title}
</span>
<span className="text-muted shrink-0 truncate text-[length:var(--fs-xs)]">
{payload.place.name}
</span>
</button>
</li>
))}
</ul>
{current?.lyrics && (
<div className="border-line max-h-56 overflow-y-auto border-t px-4 py-3">
{/* 가사는 줄바꿈이 곧 구조다. Suno 가 읽는 구조 태그([Verse])는 손님에게 뜻이 없어 뺀다. */}
<p className="text-muted whitespace-pre-line text-[length:var(--fs-xs)] leading-relaxed">
{current.lyrics
.replace(/^\[.*\]$/gm, "")
.replace(/\n{3,}/g, "\n\n")
.trim()}
</p>
</div>
)}
</div>
</div>
);
}