[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>
This commit is contained in:
parent
703f831273
commit
38bb4be9e5
@ -36,6 +36,13 @@ KAKAO_REST_API_KEY=
|
|||||||
GEMINI_API_KEY=
|
GEMINI_API_KEY=
|
||||||
# 디코딩된 키(인코딩 키는 이중 인코딩된다)
|
# 디코딩된 키(인코딩 키는 이중 인코딩된다)
|
||||||
TOUR_API_KEY=
|
TOUR_API_KEY=
|
||||||
|
# 발행할 때 이 숙소의 노래를 한 곡 만든다(가사 Gemini → 작곡 Suno).
|
||||||
|
# 비우면 그 단계만 건너뛴다 — 발행은 그대로 된다.
|
||||||
|
# ★ 콜백은 쓰지 않고 폴링한다(우리 서버는 Suno 가 닿을 수 있는 주소가 아니다).
|
||||||
|
# 그래도 API 가 필수로 요구하는 필드라 값을 채워 보낸다.
|
||||||
|
SUNO_API_KEY=
|
||||||
|
SUNO_CALLBACK_URL=https://example.com/api/suno/callback
|
||||||
|
|
||||||
|
|
||||||
# 구글 로그인. 비우면 구글 로그인만 꺼진다(서버는 뜨고, 화면에 버튼도 안 뜬다).
|
# 구글 로그인. 비우면 구글 로그인만 꺼진다(서버는 뜨고, 화면에 버튼도 안 뜬다).
|
||||||
# Google Cloud Console > API 및 서비스 > 사용자 인증 정보 > OAuth 2.0 클라이언트 ID(웹 애플리케이션)
|
# Google Cloud Console > API 및 서비스 > 사용자 인증 정보 > OAuth 2.0 클라이언트 ID(웹 애플리케이션)
|
||||||
|
|||||||
@ -49,6 +49,9 @@ services:
|
|||||||
SCHEDULER_ENABLED: "1"
|
SCHEDULER_ENABLED: "1"
|
||||||
volumes:
|
volumes:
|
||||||
- ./solution/site/payloads:/app/out/payloads
|
- ./solution/site/payloads:/app/out/payloads
|
||||||
|
# 노래 파일. payload 와 나란히 둔다 — 백엔드가 mp3 를 여기 떨구면 프리렌더가
|
||||||
|
# 사이트 디렉토리로 복사한다(services/song_service · site/scripts/prerender.ts).
|
||||||
|
- ./solution/site/songs:/app/out/songs
|
||||||
# ★ 스키마 마이그레이션 SQL. 이미지에 굽지 않고 마운트한다 — 파일이 자주 늘고,
|
# ★ 스키마 마이그레이션 SQL. 이미지에 굽지 않고 마운트한다 — 파일이 자주 늘고,
|
||||||
# 이미 세운 DB 를 따라오게 하는 것이 목적이라 코드 배포와 별개로 돌 수 있어야 한다.
|
# 이미 세운 DB 를 따라오게 하는 것이 목적이라 코드 배포와 별개로 돌 수 있어야 한다.
|
||||||
- ./postgres-init:/app/postgres-init:ro
|
- ./postgres-init:/app/postgres-init:ro
|
||||||
@ -81,6 +84,9 @@ services:
|
|||||||
disable: true
|
disable: true
|
||||||
volumes:
|
volumes:
|
||||||
- ./solution/site/payloads:/app/out/payloads
|
- ./solution/site/payloads:/app/out/payloads
|
||||||
|
# 노래 파일. payload 와 나란히 둔다 — 백엔드가 mp3 를 여기 떨구면 프리렌더가
|
||||||
|
# 사이트 디렉토리로 복사한다(services/song_service · site/scripts/prerender.ts).
|
||||||
|
- ./solution/site/songs:/app/out/songs
|
||||||
- site-out:/app/out/sites:ro
|
- site-out:/app/out/sites:ro
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
- "host.docker.internal:host-gateway"
|
- "host.docker.internal:host-gateway"
|
||||||
@ -119,6 +125,9 @@ services:
|
|||||||
retries: 3
|
retries: 3
|
||||||
volumes:
|
volumes:
|
||||||
- ./solution/site/payloads:/app/out/payloads
|
- ./solution/site/payloads:/app/out/payloads
|
||||||
|
# 노래 파일. payload 와 나란히 둔다 — 백엔드가 mp3 를 여기 떨구면 프리렌더가
|
||||||
|
# 사이트 디렉토리로 복사한다(services/song_service · site/scripts/prerender.ts).
|
||||||
|
- ./solution/site/songs:/app/out/songs
|
||||||
ports:
|
ports:
|
||||||
# ★ 내부망에만 연다. 0.0.0.0 으로 열면 API 를 가른 의미가 없다.
|
# ★ 내부망에만 연다. 0.0.0.0 으로 열면 API 를 가른 의미가 없다.
|
||||||
- "${ADMIN_API_BIND:-127.0.0.1}:${ADMIN_API_PORT_PUBLIC:-9801}:9801"
|
- "${ADMIN_API_BIND:-127.0.0.1}:${ADMIN_API_PORT_PUBLIC:-9801}:9801"
|
||||||
|
|||||||
@ -13,8 +13,14 @@
|
|||||||
```
|
```
|
||||||
backend (Python) ──쓴다──▶ out/payloads/<slug>.json ◀──읽는다── prerender (Node)
|
backend (Python) ──쓴다──▶ out/payloads/<slug>.json ◀──읽는다── prerender (Node)
|
||||||
out/payloads/.status/<slug>.json ──보고──▶
|
out/payloads/.status/<slug>.json ──보고──▶
|
||||||
|
──쓴다──▶ out/songs/<song_id>.mp3 ◀──복사──
|
||||||
```
|
```
|
||||||
|
|
||||||
|
★ 노래(mp3)도 **같은 약속**을 쓴다(2026-09-11). 백엔드는 발행물 디렉토리를 모른 채 파일을
|
||||||
|
`out/songs/` 에 떨구고, 굽는 쪽인 프리렌더가 `out/s/<slug>/` 로 복사한다. 백엔드가 발행물
|
||||||
|
디렉토리에 직접 쓰기 시작하면 이 경계가 무너진다 — 그때부터 두 쪽이 out/ 의 모양을 함께
|
||||||
|
알아야 한다.
|
||||||
|
|
||||||
이 경계가 있어서 렌더링을 통째로 갈아엎어도 백엔드는 안 건드린다. 반대도 같다.
|
이 경계가 있어서 렌더링을 통째로 갈아엎어도 백엔드는 안 건드린다. 반대도 같다.
|
||||||
**둘을 직접 붙이자는 제안은 이 문서를 근거로 거절한다** — 붙이는 순간 파이썬 프로세스가
|
**둘을 직접 붙이자는 제안은 이 문서를 근거로 거절한다** — 붙이는 순간 파이썬 프로세스가
|
||||||
React 를 렌더해야 하고, 그때부터 디자인 수정에 백엔드 배포가 필요해진다.
|
React 를 렌더해야 하고, 그때부터 디자인 수정에 백엔드 배포가 필요해진다.
|
||||||
|
|||||||
@ -13,7 +13,7 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 0. 표 14개, 스키마는 `public` 한 벌
|
## 0. 표 15개, 스키마는 `public` 한 벌
|
||||||
|
|
||||||
도메인별 스키마(`company`·`place`·`fact`·`local`·`site`·`job`)는 2026-09-09 에 걷어냈다.
|
도메인별 스키마(`company`·`place`·`fact`·`local`·`site`·`job`)는 2026-09-09 에 걷어냈다.
|
||||||
스키마 한정자가 붙는 순간부터 ORM·raw SQL·테스트 픽스처가 각자 그 이름을 들고 다녀야 했다.
|
스키마 한정자가 붙는 순간부터 ORM·raw SQL·테스트 픽스처가 각자 그 이름을 들고 다녀야 했다.
|
||||||
@ -27,13 +27,14 @@ users 사장님 계정
|
|||||||
│ ├ place_photos 사진
|
│ ├ place_photos 사진
|
||||||
│ ├ place_facts ★ 사실. 이 제품의 심장
|
│ ├ place_facts ★ 사실. 이 제품의 심장
|
||||||
│ ├ place_faqs FAQ
|
│ ├ place_faqs FAQ
|
||||||
|
│ ├ place_songs 이 숙소의 노래 — 발행할 때마다 한 곡(가사 Gemini → 작곡 Suno)
|
||||||
│ └ place_area_refs 업장 ↔ 지역콘텐츠 관계(거리 · 숨김)만
|
│ └ place_area_refs 업장 ↔ 지역콘텐츠 관계(거리 · 숨김)만
|
||||||
├ area_contents ★ 지역 콘텐츠 실체 — 키가 region_code 다(place_id 아님)
|
├ area_contents ★ 지역 콘텐츠 실체 — 키가 region_code 다(place_id 아님)
|
||||||
└ sites 발행 사이트 — 사업장당 1개
|
└ sites 발행 사이트 — 사업장당 1개
|
||||||
├ site_sections 섹션 콘텐츠(사장님이 넣은 것 · 서버가 채운 것)
|
├ site_sections 섹션 콘텐츠(사장님이 넣은 것 · 서버가 채운 것)
|
||||||
├ site_versions ★ 빌드 버전 — snapshot 박제
|
├ site_versions ★ 빌드 버전 — snapshot 박제
|
||||||
└ site_publish_logs 발행 시도 기록(반려 사유 포함)
|
└ site_publish_logs 발행 시도 기록(반려 사유 포함)
|
||||||
jobs 작업 큐 — 수집 · 비전 · 소개문 · 빌드 · 지역이야기
|
jobs 작업 큐 — 수집 · 비전 · 소개문 · 빌드 · 지역이야기 · 노래
|
||||||
```
|
```
|
||||||
|
|
||||||
**FK 제약은 걸지 않는다**(관계 컬럼만 둔다). 삭제는 전부 소프트 삭제(`deleted`)이고,
|
**FK 제약은 걸지 않는다**(관계 컬럼만 둔다). 삭제는 전부 소프트 삭제(`deleted`)이고,
|
||||||
@ -137,6 +138,22 @@ jobs 작업 큐 — 수집 · 비전 · 소개문 ·
|
|||||||
`source_fact_ids` 가 비면 **발행 게이트가 반려한다.** 확보된 fact 만 근거로 쓴다는 규칙이
|
`source_fact_ids` 가 비면 **발행 게이트가 반려한다.** 확보된 fact 만 근거로 쓴다는 규칙이
|
||||||
데이터 모양으로 강제된 자리다.
|
데이터 모양으로 강제된 자리다.
|
||||||
|
|
||||||
|
### `place_songs` — 이 숙소의 노래
|
||||||
|
|
||||||
|
발행할 때마다 한 곡 만든다. 가사는 소개문과 **같은 재료**(확인된 fact + 조사 근거 + 소개문)로
|
||||||
|
Gemini 가 쓰고, 곡은 Suno 가 붙인다.
|
||||||
|
|
||||||
|
★ **검증 상태(`FactStatus`)가 없다.** 노래는 수집한 사실이 아니라 우리가 만든 창작물이라
|
||||||
|
"맞는가" 를 물을 대상이 아니다. 상태는 "만들어졌는가" 하나다(`SongStatus`:
|
||||||
|
`GENERATING` · `READY` · `FAILED`). 스냅샷은 **`READY` 만** 싣는다.
|
||||||
|
|
||||||
|
★ **`origin_url`(Suno 가 준 주소)은 발행본에 나가지 않는다.** 만료되는 주소라 그대로 실으면
|
||||||
|
발행 직후에는 재생되고 몇 주 뒤 조용히 죽는다. mp3 를 받아 `solution/site/songs/<song_id>.mp3`
|
||||||
|
에 두고, 프리렌더가 사이트 디렉토리로 복사한 것(`/s/<slug>/<song_id>.mp3`)만 나간다.
|
||||||
|
표에는 추적용으로만 남긴다.
|
||||||
|
|
||||||
|
★ 새 곡이 실패해도 직전 곡이 그대로 남는다 — `latest_ready` 가 `READY` 중 최신 하나를 고른다.
|
||||||
|
|
||||||
### `area_contents` + `place_area_refs` — 지역 콘텐츠
|
### `area_contents` + `place_area_refs` — 지역 콘텐츠
|
||||||
|
|
||||||
★ **키가 `region_code` 다.** 같은 지역에 사이트가 몇 개 생기든 외부 조회는 1회.
|
★ **키가 `region_code` 다.** 같은 지역에 사이트가 몇 개 생기든 외부 조회는 1회.
|
||||||
|
|||||||
@ -5,6 +5,68 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 2026-09-11 — 발행하면 이 숙소의 노래가 한 곡 생긴다 (가사 Gemini → 작곡 Suno)
|
||||||
|
|
||||||
|
**무슨 일** — `/s/stay` 시안에는 헤더에 노래 플레이어가 있는데, 그건 손으로 채운 목업이라
|
||||||
|
새로 발행한 사이트에는 그 자리가 아예 없었다. 이제 발행이 노래를 만든다.
|
||||||
|
|
||||||
|
**흐름** — ★ **발행이 노래를 기다린다.**
|
||||||
|
```
|
||||||
|
발행 누름 → BUILD 잡
|
||||||
|
1. 가사(Gemini) → 2. 작곡(Suno, 실측 30~40초 · 상한 5분)
|
||||||
|
3. mp3 를 out/songs/ 에 보관
|
||||||
|
4. 스냅샷 → 게이트 → 발행 ← 여기서 비로소 사이트가 나간다
|
||||||
|
프리렌더가 mp3 를 사이트 디렉토리로 복사
|
||||||
|
```
|
||||||
|
|
||||||
|
**왜 기다리나** — 먼저 굽고 나중에 붙이는 방식으로 먼저 만들어 봤는데, 그러면 발행 직후의
|
||||||
|
사이트에는 노래가 없고 몇 분 뒤 조용히 생긴다. 사장님이 [사이트 열기] 로 보는 **첫 화면에
|
||||||
|
그 기능이 빠져 있다.** 값은 발행이 그만큼 늦어지는 것이고, 그건 감수한다.
|
||||||
|
★ 단 실패는 발행을 막지 않는다 — 기다리는 것과 막는 것은 다르다. 키가 없거나 작곡이 실패하면
|
||||||
|
노래 없이 발행되고 사유가 빌드 로그와 `place_songs.last_error` 에 남는다.
|
||||||
|
★ 미리보기 빌드(publish=false)에는 만들지 않는다. 유료 호출이라 눌러 보는 것만으로 돈이 나가면 안 된다.
|
||||||
|
|
||||||
|
**왜 가사를 우리가 쓰나** — Suno 에 "군산 한옥 숙소 노래" 라고만 던지면 가사를 저쪽이 짓는다.
|
||||||
|
그 가사에는 이 숙소에 없는 것(수영장·조식·오션뷰)이 섞이고 우리는 검증할 방법이 없다 —
|
||||||
|
사이트의 다른 모든 문장은 확인된 fact 로만 쓰는데 노래만 지어낸 말을 싣는 꼴이다.
|
||||||
|
→ 가사는 **소개문과 같은 재료**(확인된 fact + 조사 근거 + 소개문)로 Gemini 가 쓰고,
|
||||||
|
Suno 는 곡만 붙인다. 프롬프트가 **없는 시설·숫자를 말하지 말라**고 못 박는다
|
||||||
|
(요금·전화번호를 노래에 넣으면 틀렸을 때 고쳐 부를 수가 없다).
|
||||||
|
★ 가사에는 `ground_check` 를 걸지 않는다. "밤이 깊어도 불이 켜져 있다" 에 대응하는 fact 는
|
||||||
|
없다 — 문장 단위로 근거를 맞추면 전부 반려된다. 가사는 사실 진술이 아니라 정서다.
|
||||||
|
|
||||||
|
**★ Suno 주소를 그대로 싣지 않는다**
|
||||||
|
Suno 가 주는 audio_url 은 **만료된다.** payload 에 그 주소를 실으면 발행 직후에는 재생되고
|
||||||
|
몇 주 뒤 조용히 죽는다 — 아무도 안 누르면 죽은 줄도 모르는 종류다. mp3 를 받아 보관하고
|
||||||
|
우리 경로(`/s/<slug>/<song_id>.mp3`)만 발행본에 내보낸다.
|
||||||
|
|
||||||
|
**★ 콜백이 아니라 폴링이다**
|
||||||
|
Suno 는 `callBackUrl` 로 완료를 알려 주는데, 그러려면 Suno 가 우리 백엔드에 닿아야 한다.
|
||||||
|
이 서버는 로컬(:9800)이거나 사내망이라 그런 주소가 없다 — 콜백을 믿게 만들어 두면
|
||||||
|
"요청은 성공했는데 결과가 영영 안 옴" 이 되고, 화면상 아무 일도 안 일어나는 실패다.
|
||||||
|
(API 가 필수로 요구해서 값은 채워 보내되, 그 주소를 듣지 않는다.)
|
||||||
|
|
||||||
|
**경계는 그대로다** — 백엔드는 여전히 발행물 디렉토리를 모른다. payload 와 같은 약속으로
|
||||||
|
`out/songs/<song_id>.mp3` 에 떨구고, 굽는 쪽인 프리렌더가 `out/s/<slug>/` 로 복사한다.
|
||||||
|
프리렌더는 복사하면서 **지난 발행의 mp3 를 치운다** — 발행마다 새 곡이라 안 치우면 1MB 짜리가
|
||||||
|
발행 횟수만큼 쌓이고, Azure 에도 그대로 올라간다.
|
||||||
|
|
||||||
|
**화면** — 헤더의 작은 플레이어(`SongPlayer`). 곡이 없으면 **아무것도 그리지 않는다** —
|
||||||
|
노래는 발행보다 늦게 도착하므로 그 사이 빈 플레이어를 그리면 고장난 버튼이다.
|
||||||
|
자동 재생하지 않고(소리가 갑자기 나는 페이지는 닫힌다), 가사를 함께 싣는다
|
||||||
|
(오디오 안의 말은 크롤러가 못 듣는다).
|
||||||
|
|
||||||
|
**표** — `place_songs`. 검증 상태가 없다(창작물이라 "맞는가" 를 물을 대상이 아니다).
|
||||||
|
상태는 `GENERATING`·`READY`·`FAILED` 셋이고 스냅샷은 READY 만 싣는다. 새 곡이 실패하면
|
||||||
|
직전 곡이 그대로 남는다. → [DATA_MODEL.md](DATA_MODEL.md)
|
||||||
|
|
||||||
|
**검증** — 실제로 발행해 봤다(스테이,머뭄 v15): 가사 '시간이 머무는 고요한 밤'(acoustic
|
||||||
|
ballad, 154자, $0.0014) → 작곡 40초 → 1.98MB mp3 → **그 다음** 스냅샷(노래 1) → 발행 완료.
|
||||||
|
`/s/스테이머뭄-99a887f8` 200, mp3 200 `audio/mpeg`, HTML 에 제목·가사·재생 주소 확인.
|
||||||
|
지난 발행의 곡은 404 로 치워졌다. `tsc --noEmit` · `eslint` · vitest 55건 통과(신규 4건).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 2026-09-10 — 소개문이 생성되고도 영영 안 나가던 것 (승인 단계 제거)
|
## 2026-09-10 — 소개문이 생성되고도 영영 안 나가던 것 (승인 단계 제거)
|
||||||
|
|
||||||
**무슨 일** — 힐튼 가든 인 서울 강남을 만들어 보니 소개가 빈칸이었다. 로그는 `[copy] 소개문 O`,
|
**무슨 일** — 힐튼 가든 인 서울 강남을 만들어 보니 소개가 빈칸이었다. 로그는 `[copy] 소개문 O`,
|
||||||
|
|||||||
@ -167,6 +167,27 @@ CREATE TABLE IF NOT EXISTS public.place_units (
|
|||||||
deleted BOOLEAN NOT NULL DEFAULT FALSE
|
deleted BOOLEAN NOT NULL DEFAULT FALSE
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- 이 숙소의 노래. 발행할 때마다 한 곡 만든다(가사 Gemini → 작곡 Suno).
|
||||||
|
-- ★ 곡은 사실이 아니라 만들어진 창작물이라 fact·사진과 같은 검증 상태를 두지 않는다.
|
||||||
|
-- 대신 완성(READY)된 것만 사이트에 나간다 — 생성 중인 곡을 실으면 재생 버튼이 빈 파일을 가리킨다.
|
||||||
|
CREATE TABLE IF NOT EXISTS public.place_songs (
|
||||||
|
song_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
place_id uuid NOT NULL,
|
||||||
|
title VARCHAR(200) NOT NULL,
|
||||||
|
lyrics TEXT NULL, -- 화면에 같이 싣는다. 손님이 무슨 노래인지 읽을 수 있어야 한다
|
||||||
|
style VARCHAR(200) NULL, -- Suno 에 넘긴 장르·분위기 문자열
|
||||||
|
provider VARCHAR(40) NOT NULL DEFAULT 'suno',
|
||||||
|
provider_task_id VARCHAR(120) NULL, -- Suno taskId. 폴링·재조회의 유일한 열쇠
|
||||||
|
origin_url VARCHAR(1000) NULL, -- ★ Suno 가 준 원본 주소. 만료되므로 이 주소를 사이트에 싣지 않는다
|
||||||
|
file_name VARCHAR(200) NULL, -- 우리가 받아 둔 파일(solution/site/songs/<이것>). 프리렌더가 사이트로 복사한다
|
||||||
|
duration_sec NUMERIC(6,2) NULL,
|
||||||
|
status SMALLINT NOT NULL DEFAULT 1, -- SongStatus: 1=generating 2=ready 3=failed. ★ 2 만 사이트에 나간다
|
||||||
|
last_error TEXT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
deleted BOOLEAN NOT NULL DEFAULT FALSE
|
||||||
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS public.place_photos (
|
CREATE TABLE IF NOT EXISTS public.place_photos (
|
||||||
media_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
media_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
place_id uuid NOT NULL,
|
place_id uuid NOT NULL,
|
||||||
@ -362,6 +383,7 @@ CREATE UNIQUE INDEX IF NOT EXISTS uq_place_links_place_url ON public.place_chann
|
|||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_units_place ON public.place_units (place_id);
|
CREATE INDEX IF NOT EXISTS idx_units_place ON public.place_units (place_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_media_place ON public.place_photos (place_id);
|
CREATE INDEX IF NOT EXISTS idx_media_place ON public.place_photos (place_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_songs_place ON public.place_songs (place_id, created_at DESC);
|
||||||
CREATE INDEX IF NOT EXISTS idx_media_unit ON public.place_photos (unit_id);
|
CREATE INDEX IF NOT EXISTS idx_media_unit ON public.place_photos (unit_id);
|
||||||
|
|
||||||
-- place_facts
|
-- place_facts
|
||||||
|
|||||||
30
postgres-init/migrations/0010_place_songs.sql
Normal file
30
postgres-init/migrations/0010_place_songs.sql
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
-- 0010 · 숙소의 노래 표(place_songs)
|
||||||
|
--
|
||||||
|
-- 발행할 때마다 이 숙소의 노래를 한 곡 만든다 — 가사는 Gemini, 작곡은 Suno.
|
||||||
|
-- 곡은 **사실이 아니라 창작물**이라 fact·사진 같은 검증 상태(FactStatus)를 두지 않는다.
|
||||||
|
-- 대신 상태는 "만들어졌는가" 하나다: 1=generating · 2=ready · 3=failed.
|
||||||
|
-- ★ 사이트에는 2 만 나간다. 생성 중인 곡을 실으면 재생 버튼이 없는 파일을 가리킨다.
|
||||||
|
--
|
||||||
|
-- ★ origin_url 을 그대로 싣지 않는 이유: Suno 가 주는 주소는 만료된다. 받아서 우리 쪽에
|
||||||
|
-- 보관한 파일(file_name)만 사이트에 나간다 — 안 그러면 몇 주 뒤 조용히 재생이 죽는다.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS public.place_songs (
|
||||||
|
song_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
place_id uuid NOT NULL,
|
||||||
|
title VARCHAR(200) NOT NULL,
|
||||||
|
lyrics TEXT NULL,
|
||||||
|
style VARCHAR(200) NULL,
|
||||||
|
provider VARCHAR(40) NOT NULL DEFAULT 'suno',
|
||||||
|
provider_task_id VARCHAR(120) NULL,
|
||||||
|
origin_url VARCHAR(1000) NULL,
|
||||||
|
file_name VARCHAR(200) NULL,
|
||||||
|
duration_sec NUMERIC(6,2) NULL,
|
||||||
|
status SMALLINT NOT NULL DEFAULT 1,
|
||||||
|
last_error TEXT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
deleted BOOLEAN NOT NULL DEFAULT FALSE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 한 업장의 최신 곡을 꺼내는 경로. 발행본은 늘 "가장 최근에 완성된 한 곡"을 싣는다.
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_songs_place ON public.place_songs (place_id, created_at DESC);
|
||||||
@ -14,6 +14,7 @@ from common.enums import (
|
|||||||
FactStatus,
|
FactStatus,
|
||||||
SourceType,
|
SourceType,
|
||||||
MediaStatus,
|
MediaStatus,
|
||||||
|
SongStatus,
|
||||||
SiteStatus,
|
SiteStatus,
|
||||||
BuildStatus,
|
BuildStatus,
|
||||||
JobStatus,
|
JobStatus,
|
||||||
@ -190,6 +191,30 @@ class place_photos(MainTableMixin, MAIN_BASE):
|
|||||||
sort_order = Column(Integer, nullable=False, server_default=text("0"), default=0)
|
sort_order = Column(Integer, nullable=False, server_default=text("0"), default=0)
|
||||||
|
|
||||||
|
|
||||||
|
class place_songs(MainTableMixin, MAIN_BASE):
|
||||||
|
"""이 숙소의 노래. 발행할 때마다 한 곡 만든다 — 가사는 Gemini, 작곡은 Suno.
|
||||||
|
|
||||||
|
★ 검증 상태(FactStatus)가 없다. 노래는 수집한 사실이 아니라 우리가 만든 창작물이라
|
||||||
|
"맞는가" 를 물을 대상이 아니다. 상태는 "만들어졌는가" 하나다(SongStatus).
|
||||||
|
★ origin_url(Suno 가 준 주소)은 **사이트에 싣지 않는다.** 만료되는 주소라 그대로 두면
|
||||||
|
몇 주 뒤 재생만 조용히 죽는다 — 받아서 보관한 file_name 만 발행본으로 나간다."""
|
||||||
|
|
||||||
|
__tablename__ = "place_songs"
|
||||||
|
|
||||||
|
song_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
place_id = Column(UUID(as_uuid=True), nullable=False, index=True)
|
||||||
|
title = Column(String(200), nullable=False)
|
||||||
|
lyrics = Column(Text, nullable=True) # 화면에 함께 싣는다
|
||||||
|
style = Column(String(200), nullable=True) # Suno 에 넘긴 장르·분위기
|
||||||
|
provider = Column(String(40), nullable=False, server_default=text("'suno'"), default="suno")
|
||||||
|
provider_task_id = Column(String(120), nullable=True) # Suno taskId — 폴링의 유일한 열쇠
|
||||||
|
origin_url = Column(String(1000), nullable=True) # ★ 만료되는 주소. 보관용 기록일 뿐이다
|
||||||
|
file_name = Column(String(200), nullable=True) # solution/site/songs/<이것>
|
||||||
|
duration_sec = Column(Numeric(6, 2), nullable=True)
|
||||||
|
status = Column(SmallInteger, nullable=False, server_default=text("1"), default=SongStatus.GENERATING.value)
|
||||||
|
last_error = Column(Text, nullable=True)
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# fact : 사실 / FAQ
|
# fact : 사실 / FAQ
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|||||||
@ -283,6 +283,18 @@ class MediaStatus(CodeEnum):
|
|||||||
REJECTED = 3
|
REJECTED = 3
|
||||||
|
|
||||||
|
|
||||||
|
class SongStatus(CodeEnum):
|
||||||
|
"""place_songs.status 코드값.
|
||||||
|
|
||||||
|
★ fact·사진과 달리 검증 상태가 없다. 노래는 수집한 사실이 아니라 우리가 만든 창작물이라
|
||||||
|
"맞는가" 를 물을 대상이 아니다. 물을 것은 "만들어졌는가" 하나다.
|
||||||
|
★ 사이트에는 READY 만 나간다 — 생성 중인 곡을 실으면 재생 버튼이 없는 파일을 가리킨다."""
|
||||||
|
|
||||||
|
GENERATING = 1 # Suno 가 작곡 중(또는 파일을 아직 못 받았다)
|
||||||
|
READY = 2 # 파일까지 받아 뒀다 — 사이트에 나간다
|
||||||
|
FAILED = 3 # 생성 실패. 발행은 그대로 진행된다(노래만 없다)
|
||||||
|
|
||||||
|
|
||||||
# ★ Vision 결과를 자동 반영해도 되는 신뢰도 하한. 이 아래는 사람 확인 큐(PENDING_REVIEW)로 남긴다.
|
# ★ Vision 결과를 자동 반영해도 되는 신뢰도 하한. 이 아래는 사람 확인 큐(PENDING_REVIEW)로 남긴다.
|
||||||
# "신뢰도 낮은 항목은 자동 반영하지 말고 사람 확인 큐로 보낸다" 를 한 곳에서만 판단한다.
|
# "신뢰도 낮은 항목은 자동 반영하지 말고 사람 확인 큐로 보낸다" 를 한 곳에서만 판단한다.
|
||||||
VISION_AUTO_APPROVE_CONFIDENCE = 0.7
|
VISION_AUTO_APPROVE_CONFIDENCE = 0.7
|
||||||
@ -419,6 +431,7 @@ class JobType(CodeEnum):
|
|||||||
BUILD = 4 # 사이트 정적 빌드 — ★ 개별 재빌드 단위
|
BUILD = 4 # 사이트 정적 빌드 — ★ 개별 재빌드 단위
|
||||||
LOCAL_SYNC = 5 # 지역 정보 갱신 — 행정구역 코드 단위(같은 지역 사이트 50개여도 1회)
|
LOCAL_SYNC = 5 # 지역 정보 갱신 — 행정구역 코드 단위(같은 지역 사이트 50개여도 1회)
|
||||||
AI_CHECK = 6 # AI 검색 노출 점검
|
AI_CHECK = 6 # AI 검색 노출 점검
|
||||||
|
SONG = 7 # 이 숙소의 노래 한 곡 (가사 Gemini → 작곡 Suno). 발행이 이 잡을 건다
|
||||||
|
|
||||||
|
|
||||||
class JobStatus(CodeEnum):
|
class JobStatus(CodeEnum):
|
||||||
|
|||||||
@ -131,6 +131,11 @@ class ExternalApiConfig(BaseSettings):
|
|||||||
# 이 값 미만이면 자동 반영하지 않고 사람 확인 큐(PENDING_REVIEW)에 남긴다.
|
# 이 값 미만이면 자동 반영하지 않고 사람 확인 큐(PENDING_REVIEW)에 남긴다.
|
||||||
vision_confidence_threshold: float = Field(0.7, validation_alias="VISION_CONFIDENCE_THRESHOLD")
|
vision_confidence_threshold: float = Field(0.7, validation_alias="VISION_CONFIDENCE_THRESHOLD")
|
||||||
tour_api_key: str = Field("", validation_alias="TOUR_API_KEY")
|
tour_api_key: str = Field("", validation_alias="TOUR_API_KEY")
|
||||||
|
# 발행할 때 이 숙소의 노래를 한 곡 만든다(services/song_service). 비면 그 단계만 건너뛴다.
|
||||||
|
suno_api_key: str = Field("", validation_alias="SUNO_API_KEY")
|
||||||
|
# ★ 콜백은 쓰지 않고 폴링한다 — 우리 백엔드는 로컬·사내망이라 Suno 가 부를 수 있는 주소가 아니다.
|
||||||
|
# 그래도 API 가 필수로 요구하는 필드라 값을 들고 있는다(services/external/suno.py 주석).
|
||||||
|
suno_callback_url: str = Field("", validation_alias="SUNO_CALLBACK_URL")
|
||||||
|
|
||||||
|
|
||||||
# .env 를 요청마다 다시 읽지 않는다. 새 코드는 Depends(get_*) 로 주입받는다.
|
# .env 를 요청마다 다시 읽지 않는다. 새 코드는 Depends(get_*) 로 주입받는다.
|
||||||
|
|||||||
39
solution/backend/crud/song_crud.py
Normal file
39
solution/backend/crud/song_crud.py
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
from sqlalchemy import and_, select, update
|
||||||
|
|
||||||
|
from common.database.db_session_manager import DB_SESSION_MNG
|
||||||
|
from common.database.model.models import place_songs
|
||||||
|
from common.enums import SongStatus
|
||||||
|
from common.utils.gtime import GTime
|
||||||
|
|
||||||
|
|
||||||
|
class SongCRUD:
|
||||||
|
"""place_songs 접근. 발행본이 읽는 것은 `latest_ready` 하나뿐이다."""
|
||||||
|
|
||||||
|
async def insert(self, db, row):
|
||||||
|
return await DB_SESSION_MNG.insert(db, row)
|
||||||
|
|
||||||
|
async def update(self, db, song_id, data: dict):
|
||||||
|
return await DB_SESSION_MNG.add_with_rowcount(
|
||||||
|
db,
|
||||||
|
update(place_songs)
|
||||||
|
.where(place_songs.song_id == song_id, place_songs.deleted == False) # noqa: E712
|
||||||
|
.values(**data, updated_at=GTime.UTC()),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def latest_ready(self, db, place_id):
|
||||||
|
"""이 업장의 **가장 최근에 완성된** 곡 하나.
|
||||||
|
|
||||||
|
★ READY 만 본다. 발행마다 새 곡을 만들므로 GENERATING 행이 함께 있을 수 있는데,
|
||||||
|
그걸 집으면 아직 없는 파일을 사이트가 가리킨다. 실패(FAILED)도 마찬가지다 —
|
||||||
|
새 곡이 실패하면 사이트는 **직전 곡을 그대로 유지**한다(빈 플레이어보다 낫다)."""
|
||||||
|
return await DB_SESSION_MNG.execute(
|
||||||
|
db,
|
||||||
|
select(place_songs)
|
||||||
|
.where(and_(
|
||||||
|
place_songs.place_id == place_id,
|
||||||
|
place_songs.status == SongStatus.READY.value,
|
||||||
|
place_songs.deleted == False, # noqa: E712
|
||||||
|
))
|
||||||
|
.order_by(place_songs.created_at.desc())
|
||||||
|
.limit(1),
|
||||||
|
)
|
||||||
@ -33,6 +33,9 @@ def _content_type(path: Path) -> str:
|
|||||||
".json": "application/json; charset=utf-8",
|
".json": "application/json; charset=utf-8",
|
||||||
".xml": "application/xml; charset=utf-8",
|
".xml": "application/xml; charset=utf-8",
|
||||||
".txt": "text/plain; charset=utf-8",
|
".txt": "text/plain; charset=utf-8",
|
||||||
|
# 노래. guess_type 도 audio/mpeg 를 주지만 플랫폼마다 갈려서 못 박아 둔다 —
|
||||||
|
# octet-stream 으로 올라가면 브라우저가 재생 대신 내려받기로 처리한다.
|
||||||
|
".mp3": "audio/mpeg",
|
||||||
}
|
}
|
||||||
return overrides.get(path.suffix.lower()) or mimetypes.guess_type(path.name)[0] or "application/octet-stream"
|
return overrides.get(path.suffix.lower()) or mimetypes.guess_type(path.name)[0] or "application/octet-stream"
|
||||||
|
|
||||||
@ -46,6 +49,10 @@ def _cache_control(path: Path) -> str:
|
|||||||
# 60초로 두면 방문자가 수 MB 짜리 폰트를 계속 다시 받는다.
|
# 60초로 두면 방문자가 수 MB 짜리 폰트를 계속 다시 받는다.
|
||||||
if head == "fonts":
|
if head == "fonts":
|
||||||
return "public, max-age=604800"
|
return "public, max-age=604800"
|
||||||
|
# 노래 파일명은 song_id(UUID)다 — 곡이 바뀌면 이름도 바뀌므로 영구 캐시가 안전하다.
|
||||||
|
# 1MB 남짓한 파일을 60초마다 다시 받게 두면 헤더 버튼 하나가 트래픽을 먹는다.
|
||||||
|
if path.suffix.lower() == ".mp3":
|
||||||
|
return "public, max-age=31536000, immutable"
|
||||||
# HTML · robots.txt · sitemap.xml — 발행하면 곧바로 반영되어야 한다.
|
# HTML · robots.txt · sitemap.xml — 발행하면 곧바로 반영되어야 한다.
|
||||||
return "public, max-age=60, must-revalidate"
|
return "public, max-age=60, must-revalidate"
|
||||||
|
|
||||||
|
|||||||
@ -29,7 +29,7 @@ from common.logger import LOG
|
|||||||
from common.utils.gtime import GTime
|
from common.utils.gtime import GTime
|
||||||
from crud.site_crud import SiteCRUD
|
from crud.site_crud import SiteCRUD
|
||||||
from crud.place_crud import PlaceCRUD
|
from crud.place_crud import PlaceCRUD
|
||||||
from services import azure_static, indexnow, publish_gate, render_report, site_payload, site_thumbnail
|
from services import azure_static, indexnow, publish_gate, render_report, site_payload, site_thumbnail, song_service
|
||||||
from services.local_content_service import LocalContentService
|
from services.local_content_service import LocalContentService
|
||||||
from services.site_payload import emit_payload
|
from services.site_payload import emit_payload
|
||||||
from services.snapshot import build_snapshot
|
from services.snapshot import build_snapshot
|
||||||
@ -126,6 +126,23 @@ async def run_build(job: dict) -> dict:
|
|||||||
except Exception as ex: # noqa: BLE001 — 곁들이 정보 실패가 빌드를 죽이면 안 된다
|
except Exception as ex: # noqa: BLE001 — 곁들이 정보 실패가 빌드를 죽이면 안 된다
|
||||||
LOG.w(f"[build] place={place_id} 주변정보 갱신 실패(직전 값 사용): {type(ex).__name__}: {ex}")
|
LOG.w(f"[build] place={place_id} 주변정보 갱신 실패(직전 값 사용): {type(ex).__name__}: {ex}")
|
||||||
|
|
||||||
|
# ★ 발행이면 **노래를 먼저 만들고** 스냅샷을 뜬다 (2026-09-11 결정).
|
||||||
|
# 순서가 뒤집히면(먼저 굽고 나중에 붙이기) 발행 직후의 사이트에는 노래가 없고 몇 분 뒤
|
||||||
|
# 조용히 생긴다 — 사장님이 [사이트 열기] 로 보는 첫 화면에 그 기능이 빠져 있다.
|
||||||
|
# 값은 발행이 30초~3분 늦어지는 것이고(Suno 폴링 상한 5분), 그건 감수한다.
|
||||||
|
# ★ 실패해도 빌드는 계속한다. 주변 정보와 같은 규칙이다 — 곁들이 하나가 사장님 사이트
|
||||||
|
# 발행을 막을 이유가 없다. 노래 없이 나가고, 사유는 아래 로그와 place_songs 에 남는다.
|
||||||
|
# ★ 미리보기 빌드(publish=False)에는 만들지 않는다 — 유료 호출이라 눌러 보는 것만으로
|
||||||
|
# 비용이 나가면 안 된다.
|
||||||
|
song_result: dict | None = None
|
||||||
|
if want_publish:
|
||||||
|
try:
|
||||||
|
song_result = await song_service.ensure_song(place_id, owner_user_id)
|
||||||
|
LOG.i(f"[build] place={place_id} 노래 — {song_result}")
|
||||||
|
except Exception as ex: # noqa: BLE001 — 노래 실패가 발행을 죽이면 안 된다
|
||||||
|
song_result = {"error": f"{type(ex).__name__}: {ex}"}
|
||||||
|
LOG.w(f"[build] place={place_id} 노래 실패(노래 없이 발행): {type(ex).__name__}: {ex}")
|
||||||
|
|
||||||
snapshot = await build_snapshot(place)
|
snapshot = await build_snapshot(place)
|
||||||
|
|
||||||
v_err, version_no = await DB_SESSION_MNG.execute_lambda(
|
v_err, version_no = await DB_SESSION_MNG.execute_lambda(
|
||||||
@ -148,6 +165,9 @@ async def run_build(job: dict) -> dict:
|
|||||||
|
|
||||||
result = {"place_id": place_id, "site_id": str(site.site_id), "version": version_no,
|
result = {"place_id": place_id, "site_id": str(site.site_id), "version": version_no,
|
||||||
"site_version_id": str(version.site_version_id)}
|
"site_version_id": str(version.site_version_id)}
|
||||||
|
# 잡 결과에 남긴다 — "노래가 왜 없나" 를 잡 하나만 열어 보면 알 수 있어야 한다.
|
||||||
|
if song_result is not None:
|
||||||
|
result["song"] = song_result
|
||||||
now = GTime.UTC()
|
now = GTime.UTC()
|
||||||
|
|
||||||
async def _fail(reason: str, gate: publish_gate.GateResult | None = None, extra: dict | None = None):
|
async def _fail(reason: str, gate: publish_gate.GateResult | None = None, extra: dict | None = None):
|
||||||
|
|||||||
@ -194,3 +194,77 @@ async def generate_copy(
|
|||||||
f"tokens in={usage.input_tokens} out={usage.output_tokens} · 약 ${price(model, usage)}"
|
f"tokens in={usage.input_tokens} out={usage.output_tokens} · 약 ${price(model, usage)}"
|
||||||
)
|
)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class GeneratedSong:
|
||||||
|
"""가사 생성 결과. 곡은 여기서 만들지 않는다 — 작곡은 services/external/suno 다."""
|
||||||
|
|
||||||
|
title: str
|
||||||
|
lyrics: str
|
||||||
|
style: str
|
||||||
|
|
||||||
|
|
||||||
|
async def generate_song(
|
||||||
|
place_name: str,
|
||||||
|
category: PlaceCategory,
|
||||||
|
*,
|
||||||
|
region: str,
|
||||||
|
grounding: list[str],
|
||||||
|
intro: str = "",
|
||||||
|
model: str = DEFAULT_TEXT_MODEL,
|
||||||
|
max_retries: int = 2,
|
||||||
|
client: Optional[httpx.AsyncClient] = None,
|
||||||
|
) -> GeneratedSong:
|
||||||
|
"""이 업소의 노래 가사를 쓴다.
|
||||||
|
|
||||||
|
★ `ground_check` 를 걸지 않는다. 가사는 사실 진술이 아니라 정서라 문장 단위로 근거를
|
||||||
|
맞추면 전부 반려된다("밤이 깊어도 불이 켜져 있다" 에 대응하는 fact 는 없다).
|
||||||
|
대신 프롬프트가 **없는 시설·숫자를 말하지 말라**고 못 박는다(services/prompts/song 머리주석).
|
||||||
|
★ 재료가 하나도 없으면 부르지 않는다 — 소개문과 같은 규칙이다. 상호와 지역만으로 쓴 노래는
|
||||||
|
어느 숙소에 붙여도 말이 되는 노래이고, 그건 이 기능이 하려던 일이 아니다.
|
||||||
|
"""
|
||||||
|
if not is_configured():
|
||||||
|
raise GeminiNotConfigured("GEMINI_API_KEY 가 설정되지 않았다")
|
||||||
|
if not grounding and not (intro or "").strip():
|
||||||
|
raise GeminiInvalidOutput("가사를 쓸 재료가 없다 — 확인된 fact 도 소개문도 없다")
|
||||||
|
|
||||||
|
from common.category_schema import get_schema
|
||||||
|
from services.prompts.song import RESPONSE_SCHEMA as SONG_SCHEMA, build_prompt as build_song_prompt
|
||||||
|
|
||||||
|
body = {
|
||||||
|
"contents": [{"role": "user", "parts": [{
|
||||||
|
"text": build_song_prompt(place_name, get_schema(category).label, region, grounding, intro)
|
||||||
|
}]}],
|
||||||
|
"generationConfig": {
|
||||||
|
"responseMimeType": "application/json",
|
||||||
|
"responseSchema": SONG_SCHEMA,
|
||||||
|
# 소개문(0.2)보다 높다 — 노래는 정확해야 하는 글이 아니라 흥얼거릴 글이다.
|
||||||
|
"temperature": 0.9,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
owns_client = client is None
|
||||||
|
client = client or httpx.AsyncClient(timeout=httpx.Timeout(120.0, connect=10.0))
|
||||||
|
try:
|
||||||
|
payload = await call(client, model, body, max_retries)
|
||||||
|
parsed = json.loads(extract_text(payload))
|
||||||
|
except json.JSONDecodeError as ex:
|
||||||
|
raise GeminiInvalidOutput(f"가사 파싱 실패: {ex}") from ex
|
||||||
|
finally:
|
||||||
|
if owns_client:
|
||||||
|
await client.aclose()
|
||||||
|
|
||||||
|
title = (parsed.get("title") or "").strip()
|
||||||
|
lyrics = (parsed.get("lyrics") or "").strip()
|
||||||
|
style = (parsed.get("style") or "").strip()
|
||||||
|
if not lyrics:
|
||||||
|
raise GeminiInvalidOutput("가사가 비어 있다")
|
||||||
|
|
||||||
|
usage = read_usage(payload)
|
||||||
|
LOG.i(
|
||||||
|
f"[gemini-text] '{place_name}' 가사 — '{title}' ({style}) · {len(lyrics)}자 · "
|
||||||
|
f"tokens in={usage.input_tokens} out={usage.output_tokens} · 약 ${price(model, usage)}"
|
||||||
|
)
|
||||||
|
# 제목이 비면 상호를 쓴다 — 빈 제목은 플레이어에서 빈 줄로 보인다.
|
||||||
|
return GeneratedSong(title=title or place_name, lyrics=lyrics, style=style or "acoustic ballad")
|
||||||
|
|||||||
158
solution/backend/services/external/suno.py
vendored
Normal file
158
solution/backend/services/external/suno.py
vendored
Normal file
@ -0,0 +1,158 @@
|
|||||||
|
"""Suno API — 가사를 받아 40초짜리 곡 한 편을 만든다.
|
||||||
|
|
||||||
|
API 문서 https://docs.sunoapi.org
|
||||||
|
가사 services/external/gemini_text.generate_song (여기는 작곡만 한다)
|
||||||
|
쓰는 곳 services/song_service (잡 흐름·저장)
|
||||||
|
|
||||||
|
★ **콜백을 쓰지 않고 폴링한다.**
|
||||||
|
Suno 는 완료 시 `callBackUrl` 로 POST 를 보내 주는데, 그러려면 Suno 쪽에서 우리 백엔드에
|
||||||
|
닿아야 한다. 이 서버는 로컬(:9800)이거나 사내망(킹서버)이라 그런 주소가 없다 —
|
||||||
|
콜백을 믿게 만들어 두면 "요청은 성공했는데 결과가 영영 안 옴" 이 되고, 그건 화면상
|
||||||
|
아무 일도 안 일어나는 종류의 실패다. 그래서 `generate` 로 taskId 를 받고 `record-info` 를
|
||||||
|
직접 물어본다. 잡 워커에서 도는 코드라 몇 분 기다리는 것이 문제가 되지 않는다.
|
||||||
|
(`callBackUrl` 은 API 가 필수로 요구해서 값만 채워 보낸다. 우리는 그 주소를 듣지 않는다.)
|
||||||
|
|
||||||
|
★ **한 요청에 곡이 두 편 온다.** Suno 는 같은 가사로 변주 두 개를 만들어 준다(sunoData 배열).
|
||||||
|
우리는 **첫 번째 한 곡만** 쓴다 — 사장님에게 고르라고 묻는 화면이 없고, 두 곡을 다 실으면
|
||||||
|
손님이 무엇을 듣는지 우리도 모른다.
|
||||||
|
|
||||||
|
★ **오디오 주소는 만료된다.** 여기서 돌려주는 `audio_url` 을 그대로 사이트에 싣지 않는다.
|
||||||
|
받는 쪽(song_service)이 파일을 내려받아 우리 쪽에 보관한다.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from common.logger import LOG
|
||||||
|
from config.server_configs import external_api_config
|
||||||
|
|
||||||
|
BASE_URL = "https://api.sunoapi.org/api/v1"
|
||||||
|
|
||||||
|
# 실측(참고 프로젝트 o2o-castad-backend): 스트림 주소는 30~40초, 내려받을 수 있는 주소는 2~3분.
|
||||||
|
# 우리는 파일을 받아야 하므로 뒤쪽 기준으로 기다린다.
|
||||||
|
POLL_INTERVAL_SEC = 10
|
||||||
|
POLL_TIMEOUT_SEC = 300
|
||||||
|
|
||||||
|
REQUEST_TIMEOUT = httpx.Timeout(60.0, connect=10.0)
|
||||||
|
|
||||||
|
# 40초짜리를 만든다. 헤더의 작은 플레이어에서 듣는 곡이라 길 이유가 없고,
|
||||||
|
# 길수록 생성 시간과 요금이 같이 는다.
|
||||||
|
SONG_SECONDS = 40
|
||||||
|
MODEL = "V5"
|
||||||
|
|
||||||
|
|
||||||
|
class SunoNotConfigured(RuntimeError):
|
||||||
|
"""SUNO_API_KEY 가 없다 — 노래만 건너뛰고 발행은 계속한다."""
|
||||||
|
|
||||||
|
|
||||||
|
class SunoError(RuntimeError):
|
||||||
|
"""호출 실패·거절. 잡의 last_error 로 남는다."""
|
||||||
|
|
||||||
|
|
||||||
|
def is_configured() -> bool:
|
||||||
|
return bool((external_api_config.suno_api_key or "").strip())
|
||||||
|
|
||||||
|
|
||||||
|
def _headers() -> dict:
|
||||||
|
return {
|
||||||
|
"Authorization": f"Bearer {external_api_config.suno_api_key}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def generate(lyrics: str, *, title: str, style: str, client: httpx.AsyncClient) -> str:
|
||||||
|
"""작곡 요청. taskId 를 돌려준다.
|
||||||
|
|
||||||
|
★ `customMode=True` 다 — prompt 를 '주제' 가 아니라 **가사 그대로** 쓰라는 뜻이다.
|
||||||
|
false 로 두면 Suno 가 가사를 자기가 새로 쓴다. 우리는 이 숙소의 사실로 쓴 가사를
|
||||||
|
넘기는 것이므로, 그걸 버리면 이 기능의 의미가 없다.
|
||||||
|
"""
|
||||||
|
if not is_configured():
|
||||||
|
raise SunoNotConfigured("SUNO_API_KEY 미설정")
|
||||||
|
|
||||||
|
body = {
|
||||||
|
"model": MODEL,
|
||||||
|
"customMode": True,
|
||||||
|
"instrumental": False,
|
||||||
|
# 길이는 API 파라미터가 아니라 프롬프트로 지시한다(참고 프로젝트와 같은 방식).
|
||||||
|
"prompt": f"[Song Duration: Around {SONG_SECONDS} seconds]\n{lyrics}",
|
||||||
|
"title": title[:80],
|
||||||
|
"style": style,
|
||||||
|
# 듣지 않는 주소다(머리주석). 비워서 보내면 거절당한다.
|
||||||
|
"callBackUrl": external_api_config.suno_callback_url or "https://example.com/api/suno/callback",
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
res = await client.post(f"{BASE_URL}/generate", headers=_headers(), json=body, timeout=REQUEST_TIMEOUT)
|
||||||
|
except httpx.HTTPError as ex:
|
||||||
|
raise SunoError(f"generate 호출 실패: {type(ex).__name__}: {ex}") from ex
|
||||||
|
|
||||||
|
if res.status_code != 200:
|
||||||
|
raise SunoError(f"generate HTTP {res.status_code}: {res.text[:300]}")
|
||||||
|
|
||||||
|
data = res.json() or {}
|
||||||
|
if data.get("code") != 200:
|
||||||
|
raise SunoError(f"generate 거절: {data.get('msg')}")
|
||||||
|
|
||||||
|
task_id = ((data.get("data") or {}).get("taskId"))
|
||||||
|
if not task_id:
|
||||||
|
raise SunoError(f"generate 응답에 taskId 가 없다: {str(data)[:300]}")
|
||||||
|
return task_id
|
||||||
|
|
||||||
|
|
||||||
|
def _first_clip(payload: dict) -> Optional[dict]:
|
||||||
|
"""완성된 클립 하나. 아직이면 None.
|
||||||
|
|
||||||
|
★ 상태 문자열을 믿기 전에 **주소가 실제로 있는지** 본다. SUCCESS 인데 audioUrl 이
|
||||||
|
아직 비어 오는 응답을 참고 프로젝트가 겪었다(스트림만 먼저 나오는 구간).
|
||||||
|
"""
|
||||||
|
data = (payload or {}).get("data") or {}
|
||||||
|
status = (data.get("status") or "").upper()
|
||||||
|
if status in {"CREATE_TASK_FAILED", "GENERATE_AUDIO_FAILED", "CALLBACK_EXCEPTION", "SENSITIVE_WORD_ERROR"}:
|
||||||
|
raise SunoError(f"작곡 실패: {status} {data.get('errorMessage') or ''}".strip())
|
||||||
|
|
||||||
|
clips = ((data.get("response") or {}).get("sunoData")) or []
|
||||||
|
for clip in clips:
|
||||||
|
if clip.get("audioUrl") or clip.get("sourceAudioUrl"):
|
||||||
|
return clip
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def wait_for_clip(task_id: str, *, client: httpx.AsyncClient) -> dict[str, Any]:
|
||||||
|
"""완성될 때까지 물어본다. 돌려주는 것은 첫 클립 하나.
|
||||||
|
|
||||||
|
★ 상한(POLL_TIMEOUT_SEC)을 둔다. Suno 가 영영 안 끝내는 경우 잡이 그대로 매달리면
|
||||||
|
워커 한 자리를 계속 차지한다 — 노래 하나 때문에 다른 사업장의 수집이 멈춘다.
|
||||||
|
"""
|
||||||
|
waited = 0
|
||||||
|
while waited < POLL_TIMEOUT_SEC:
|
||||||
|
await asyncio.sleep(POLL_INTERVAL_SEC)
|
||||||
|
waited += POLL_INTERVAL_SEC
|
||||||
|
|
||||||
|
try:
|
||||||
|
res = await client.get(
|
||||||
|
f"{BASE_URL}/generate/record-info",
|
||||||
|
headers=_headers(),
|
||||||
|
params={"taskId": task_id},
|
||||||
|
timeout=REQUEST_TIMEOUT,
|
||||||
|
)
|
||||||
|
res.raise_for_status()
|
||||||
|
except httpx.HTTPError as ex:
|
||||||
|
# 폴링 한 번 실패는 실패가 아니다 — 다음 차례에 다시 묻는다.
|
||||||
|
LOG.w(f"[suno] 상태 조회 실패(계속 기다린다) task={task_id}: {type(ex).__name__}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
clip = _first_clip(res.json() or {})
|
||||||
|
if clip:
|
||||||
|
LOG.i(f"[suno] 작곡 완료 task={task_id} ({waited}초)")
|
||||||
|
return clip
|
||||||
|
|
||||||
|
raise SunoError(f"{POLL_TIMEOUT_SEC}초 안에 완성되지 않았다 task={task_id}")
|
||||||
|
|
||||||
|
|
||||||
|
async def download(url: str, *, client: httpx.AsyncClient) -> bytes:
|
||||||
|
"""오디오 파일을 받아 온다. 보관은 부르는 쪽이 한다."""
|
||||||
|
res = await client.get(url, timeout=httpx.Timeout(180.0, connect=10.0), follow_redirects=True)
|
||||||
|
res.raise_for_status()
|
||||||
|
return res.content
|
||||||
62
solution/backend/services/prompts/song.py
Normal file
62
solution/backend/services/prompts/song.py
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
"""이 숙소의 노래 — 가사 프롬프트와 응답 스키마.
|
||||||
|
|
||||||
|
★ 왜 가사를 **우리가** 쓰고 Suno 에는 작곡만 시키나
|
||||||
|
Suno 에 "군산 한옥 숙소 노래" 라고만 던지면 가사를 저쪽이 지어낸다. 그 가사에는 이 숙소에
|
||||||
|
없는 것(수영장·조식·오션뷰)이 섞이고, 우리는 그걸 검증할 방법이 없다 — 사이트의 다른
|
||||||
|
모든 문장은 확인된 fact 로만 쓰는데 노래만 지어낸 말을 싣는 꼴이 된다.
|
||||||
|
그래서 가사는 소개문과 **같은 재료**(확인된 fact + 소개문)로 여기서 쓰고, Suno 는 그 가사에
|
||||||
|
곡을 붙이기만 한다.
|
||||||
|
|
||||||
|
★ 그래도 가사는 사실 진술이 아니다
|
||||||
|
"밤이 깊어도 불이 켜져 있다" 같은 줄은 fact 가 아니라 분위기다. 그래서 `ground_check` 를
|
||||||
|
걸지 않는다 — 대신 프롬프트가 **없는 시설·없는 숫자를 말하지 말라**고 못 박는다.
|
||||||
|
요금·전화번호·주소를 가사에 넣지 않는 것도 같은 이유다(틀리면 예약 클레임이고, 노래는
|
||||||
|
고쳐 부르기도 어렵다).
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Suno 가 받는 style 문자열. 장르를 모델이 고르게 두되 후보를 좁힌다 —
|
||||||
|
# 열어 두면 숙소 사이트에 어울리지 않는 것(하드록·트랩)이 나온다.
|
||||||
|
STYLE_CHOICES = [
|
||||||
|
"acoustic ballad", "city pop", "folk pop", "lo-fi", "bossa nova", "soft rock", "jazz",
|
||||||
|
]
|
||||||
|
|
||||||
|
RESPONSE_SCHEMA = {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"title": {"type": "string", "description": "곡 제목. 상호를 그대로 쓰지 말고 한 구절로."},
|
||||||
|
"lyrics": {"type": "string", "description": "가사. [Verse]/[Chorus] 구조 태그를 포함한다."},
|
||||||
|
"style": {"type": "string", "description": f"장르·분위기. 다음 중 하나에서 시작한다: {', '.join(STYLE_CHOICES)}"},
|
||||||
|
},
|
||||||
|
"required": ["title", "lyrics", "style"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_prompt(place_name: str, category_label: str, region: str, grounding: list[str], intro: str) -> str:
|
||||||
|
"""가사 프롬프트. 재료는 소개문 생성과 같은 것을 받는다."""
|
||||||
|
material = "\n".join(f"- {line}" for line in grounding) or "- (확인된 항목 없음)"
|
||||||
|
intro_block = f"\n[이 숙소 소개문]\n{intro.strip()}\n" if (intro or "").strip() else ""
|
||||||
|
|
||||||
|
return f"""너는 작은 가게의 노래를 쓰는 작사가다. 아래 업소의 노래 가사를 쓴다.
|
||||||
|
|
||||||
|
[업소]
|
||||||
|
- 상호: {place_name}
|
||||||
|
- 업종: {category_label}
|
||||||
|
- 지역: {region or "(모름)"}
|
||||||
|
|
||||||
|
[확인된 항목 — 이 안에서만 말한다]
|
||||||
|
{material}
|
||||||
|
{intro_block}
|
||||||
|
[쓰는 법]
|
||||||
|
- 한국어. 40초 안에 불리는 길이다 — [Verse] 한 덩이 + [Chorus] 한 덩이면 충분하다.
|
||||||
|
- 구조 태그([Verse], [Chorus])를 반드시 넣는다. Suno 가 이 태그로 곡을 나눈다.
|
||||||
|
- **없는 것을 말하지 않는다.** 위 목록에 없는 시설·풍경·서비스를 지어내지 않는다.
|
||||||
|
지역과 계절, 머무는 마음처럼 사실 확인이 필요 없는 정서는 자유롭게 쓴다.
|
||||||
|
- **숫자를 넣지 않는다.** 요금·전화번호·주소·객실 수는 가사에 쓰지 않는다.
|
||||||
|
틀리면 손님이 손해를 보고, 노래는 고쳐 부르기 어렵다.
|
||||||
|
- 광고 문구처럼 쓰지 않는다("최고", "1등", "예약하세요"). 손님이 흥얼거릴 노래다.
|
||||||
|
- 상호는 후렴에 한 번쯤 자연스럽게 넣는다.
|
||||||
|
|
||||||
|
[출력]
|
||||||
|
title(곡 제목) · lyrics(가사) · style(장르·분위기) 를 JSON 으로 준다.
|
||||||
|
style 은 이 업소의 분위기에 맞는 것을 고른다: {', '.join(STYLE_CHOICES)}.
|
||||||
|
"""
|
||||||
@ -955,6 +955,25 @@ def to_site_payload(place, snapshot: dict, site, version, links) -> dict:
|
|||||||
# ★ 없는 것을 지어내지 않는다 — 틀린 경로 안내는 방문자에게 헛걸음을 만든다(모델 주석).
|
# ★ 없는 것을 지어내지 않는다 — 틀린 경로 안내는 방문자에게 헛걸음을 만든다(모델 주석).
|
||||||
# 렌더러는 비면 해당 섹션을 그리지 않는다.
|
# 렌더러는 비면 해당 섹션을 그리지 않는다.
|
||||||
"routes": [],
|
"routes": [],
|
||||||
|
# ★ 이 숙소의 노래. `audioUrl` 은 **우리 쪽 경로**다 — Suno 가 준 주소는 만료되므로
|
||||||
|
# 파일을 받아 두고(song_service) 프리렌더가 사이트 디렉토리로 복사한 것을 가리킨다.
|
||||||
|
# 경로를 여기서 만드는 이유: 발행본의 주소 규칙(basePath + /s/<slug>)을 아는 곳이 여기다.
|
||||||
|
"songs": [
|
||||||
|
{
|
||||||
|
"songId": row["song_id"],
|
||||||
|
"title": row["title"],
|
||||||
|
"lyrics": row.get("lyrics") or None,
|
||||||
|
"style": row.get("style") or None,
|
||||||
|
"durationSec": row.get("duration_sec"),
|
||||||
|
# 프리렌더가 복사해 놓을 자리. 파일명은 그대로 쓴다.
|
||||||
|
# basePath 자체가 이미 `/s/<slug>`(또는 서브패스 마운트라면 그 앞에 접두어)다.
|
||||||
|
"audioUrl": f"{target['basePath']}/{row['file_name']}",
|
||||||
|
# 프리렌더가 원본을 찾을 때 쓰는 이름(솔루션 밖으로는 안 나간다).
|
||||||
|
"fileName": row["file_name"],
|
||||||
|
}
|
||||||
|
for row in (snapshot.get("songs") or [])
|
||||||
|
if (row.get("file_name") or "").strip()
|
||||||
|
],
|
||||||
"narrative": narrative,
|
"narrative": narrative,
|
||||||
"theme": theme,
|
"theme": theme,
|
||||||
}
|
}
|
||||||
|
|||||||
@ -560,6 +560,11 @@ class SiteService:
|
|||||||
if job_id is None:
|
if job_id is None:
|
||||||
res.result.SetResult(ErrorType.COLLECT_ALREADY_RUNNING)
|
res.result.SetResult(ErrorType.COLLECT_ALREADY_RUNNING)
|
||||||
return res
|
return res
|
||||||
|
|
||||||
|
# ★ 노래는 여기서 따로 걸지 않는다. BUILD 잡이 스냅샷을 뜨기 **전에** 직접 만든다
|
||||||
|
# (`build_service.run_build` → `song_service.ensure_song`) — 발행이 노래를 기다린다.
|
||||||
|
# 따로 걸면 먼저 구워지고 노래가 몇 분 뒤 붙는데, 그러면 사장님이 [사이트 열기] 로
|
||||||
|
# 보는 첫 화면에 그 기능이 빠져 있다(2026-09-11 결정).
|
||||||
res.job_id = uuid.UUID(job_id)
|
res.job_id = uuid.UUID(job_id)
|
||||||
res.status = JobStatus.PENDING
|
res.status = JobStatus.PENDING
|
||||||
res.created = created
|
res.created = created
|
||||||
|
|||||||
@ -23,7 +23,7 @@ from sqlalchemy import or_, select
|
|||||||
from common.category_schema import get_schema
|
from common.category_schema import get_schema
|
||||||
from common.database.db_session_manager import DB_SESSION_MNG
|
from common.database.db_session_manager import DB_SESSION_MNG
|
||||||
from common.database.model.models import (
|
from common.database.model.models import (
|
||||||
place_facts, place_faqs, area_contents, place_photos, place_area_refs, place_units,
|
place_facts, place_faqs, area_contents, place_photos, place_area_refs, place_songs, place_units,
|
||||||
site_sections, sites,
|
site_sections, sites,
|
||||||
)
|
)
|
||||||
from common.enums import (
|
from common.enums import (
|
||||||
@ -35,6 +35,7 @@ from common.enums import (
|
|||||||
LocalSource,
|
LocalSource,
|
||||||
MediaStatus,
|
MediaStatus,
|
||||||
PlaceCategory,
|
PlaceCategory,
|
||||||
|
SongStatus,
|
||||||
)
|
)
|
||||||
from common.logger import LOG
|
from common.logger import LOG
|
||||||
from services.external.naver import region_key
|
from services.external.naver import region_key
|
||||||
@ -89,6 +90,16 @@ async def build_snapshot(place) -> dict:
|
|||||||
).order_by(place_photos.sort_order.asc())
|
).order_by(place_photos.sort_order.asc())
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ★ 완성(READY)된 최신 곡 하나. 발행마다 새 곡을 만들므로 생성 중인 행이 함께 있을 수 있는데,
|
||||||
|
# 그걸 실으면 사이트가 아직 없는 파일을 가리킨다. 새 곡이 실패하면 직전 곡이 그대로 남는다.
|
||||||
|
song_rows = await _select(
|
||||||
|
select(place_songs).where(
|
||||||
|
place_songs.place_id == pid,
|
||||||
|
place_songs.deleted == False, # noqa: E712
|
||||||
|
place_songs.status == SongStatus.READY.value,
|
||||||
|
).order_by(place_songs.created_at.desc()).limit(1)
|
||||||
|
)
|
||||||
|
|
||||||
local_rows = await _local_contents(place)
|
local_rows = await _local_contents(place)
|
||||||
|
|
||||||
snapshot = {
|
snapshot = {
|
||||||
@ -161,11 +172,26 @@ async def build_snapshot(place) -> dict:
|
|||||||
# ★ 원문(body)을 거의 그대로 싣는다. 렌더러 타입으로의 변환은 site_payload 가 한다 —
|
# ★ 원문(body)을 거의 그대로 싣는다. 렌더러 타입으로의 변환은 site_payload 가 한다 —
|
||||||
# fact·사진과 같은 분업이다(여기는 '무엇이 나갈 수 있는가', 거기는 '어떤 모양으로 나가는가').
|
# fact·사진과 같은 분업이다(여기는 '무엇이 나갈 수 있는가', 거기는 '어떤 모양으로 나가는가').
|
||||||
"local": local_rows,
|
"local": local_rows,
|
||||||
|
# ★ 이 숙소의 노래. 파일은 DB 가 아니라 `solution/site/songs/<file_name>` 에 있고,
|
||||||
|
# 프리렌더가 그걸 사이트 디렉토리로 복사한다(services/song_service 머리주석).
|
||||||
|
# ★ origin_url(Suno 주소)은 싣지 않는다 — 만료되는 주소라 발행본에 나가면 안 된다.
|
||||||
|
"songs": [
|
||||||
|
{
|
||||||
|
"song_id": str(r.song_id),
|
||||||
|
"title": r.title,
|
||||||
|
"lyrics": r.lyrics,
|
||||||
|
"style": r.style,
|
||||||
|
"file_name": r.file_name,
|
||||||
|
"duration_sec": float(r.duration_sec) if r.duration_sec is not None else None,
|
||||||
|
}
|
||||||
|
for r in song_rows
|
||||||
|
if (r.file_name or "").strip()
|
||||||
|
],
|
||||||
}
|
}
|
||||||
LOG.i(
|
LOG.i(
|
||||||
f"[snapshot] place={pid} fact {len(snapshot['facts'])} · 객실 {len(snapshot['units'])} · "
|
f"[snapshot] place={pid} fact {len(snapshot['facts'])} · 객실 {len(snapshot['units'])} · "
|
||||||
f"FAQ {len(snapshot['faqs'])} · 사진 {len(snapshot['media'])} · "
|
f"FAQ {len(snapshot['faqs'])} · 사진 {len(snapshot['media'])} · "
|
||||||
f"지역 {len(snapshot['local']['contents'])}"
|
f"지역 {len(snapshot['local']['contents'])} · 노래 {len(snapshot['songs'])}"
|
||||||
)
|
)
|
||||||
return snapshot
|
return snapshot
|
||||||
|
|
||||||
|
|||||||
258
solution/backend/services/song_service.py
Normal file
258
solution/backend/services/song_service.py
Normal file
@ -0,0 +1,258 @@
|
|||||||
|
"""이 숙소의 노래 — SONG 잡이 하는 일.
|
||||||
|
|
||||||
|
가사 services/external/gemini_text.generate_song (확인된 fact + 소개문으로 쓴다)
|
||||||
|
작곡 services/external/suno (폴링 · 40초 · 한 곡)
|
||||||
|
여기 재료 모으기 → 가사 → 작곡 → 파일 보관 (발행이 이걸 기다린다)
|
||||||
|
|
||||||
|
★ **발행은 노래를 기다린다** (2026-09-11 결정).
|
||||||
|
BUILD 잡이 스냅샷을 뜨기 **전에** 여기를 부른다(`build_service.run_build`). 그래서 발행된
|
||||||
|
사이트에는 처음부터 노래가 들어 있다 — 사장님이 [사이트 열기] 를 눌러 본 화면과 손님이
|
||||||
|
보는 화면이 같다.
|
||||||
|
값은 발행이 그만큼 늦어지는 것이다(실측 30초~3분, 상한 5분). 먼저 굽고 나중에 붙이는
|
||||||
|
방식도 만들어 봤지만, 그러면 발행 직후의 사이트에는 노래가 없고 몇 분 뒤 조용히 생긴다 —
|
||||||
|
"발행했는데 그 기능이 없다" 를 사장님이 먼저 본다.
|
||||||
|
|
||||||
|
★ 잡 타입(JobType.SONG)은 그대로 둔다.
|
||||||
|
발행과 무관하게 **곡만 다시 만들** 때 쓰는 길이다(운영자가 잡을 직접 넣는다).
|
||||||
|
발행 경로와 같은 함수(`ensure_song`)를 부르므로 둘이 갈라지지 않는다.
|
||||||
|
|
||||||
|
★ 왜 파일을 받아서 보관하나
|
||||||
|
Suno 가 주는 주소는 만료된다. 그 주소를 payload 에 실으면 발행 직후에는 재생되고 몇 주 뒤
|
||||||
|
조용히 죽는다 — 아무도 안 누르면 죽은 줄도 모르는 종류다. 그래서 mp3 를 받아
|
||||||
|
`solution/site/songs/` 에 두고, 프리렌더가 사이트 디렉토리로 복사한다.
|
||||||
|
(백엔드는 여전히 HTML 을 만들지 않는다 — 파일과 payload 를 같은 약속된 자리에 둘 뿐이다.)
|
||||||
|
|
||||||
|
★ Blob Storage 로는 **발행이 올린다** — 여기서 따로 올리지 않는다.
|
||||||
|
파일이 `out/s/<slug>/` 안에 있으므로 `azure_static.publish(slug)` 가 사이트를 통째로
|
||||||
|
올릴 때 함께 올라간다(content-type `audio/mpeg`, 해시 파일명이라 immutable 캐시).
|
||||||
|
지난 곡은 `_remove_stale_site_files` 가 블롭에서도 지운다.
|
||||||
|
→ 업로더를 하나 더 두면 같은 컨테이너에 **두 규칙**이 생긴다(경로·캐시·정리 주체).
|
||||||
|
참고 프로젝트(o2o-castad-backend)는 사이트가 없어 직접 올릴 수밖에 없었지만,
|
||||||
|
여기서는 노래가 사이트의 일부라 사이트와 같은 길로 나가는 것이 맞다.
|
||||||
|
덕분에 주소도 같은 오리진(`/s/<slug>/<song_id>.mp3`)이라 CORS·혼합콘텐츠 문제가 없다.
|
||||||
|
|
||||||
|
★ 실패는 발행을 막지 않는다
|
||||||
|
키가 없거나(SUNO_API_KEY·GEMINI_API_KEY), 재료가 없거나, Suno 가 늦으면(상한 5분)
|
||||||
|
**노래만 없이** 발행된다. 기다리는 것과 막는 것은 다르다 — 음악 API 가 느린 날
|
||||||
|
사장님 사이트가 아예 안 나가는 것은 맞바꿀 수 없는 손해다.
|
||||||
|
사유는 place_songs.last_error 와 빌드 로그에 남는다.
|
||||||
|
"""
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from common.category_schema import CategorySchemaError, get_schema
|
||||||
|
from common.database.db_session_manager import DB_SESSION_MNG
|
||||||
|
from common.database.model.models import place_channels, place_facts, place_songs, places
|
||||||
|
from common.enums import DBWRType, ErrorType, PlaceCategory, SongStatus
|
||||||
|
from common.logger import LOG
|
||||||
|
from common.utils.gtime import GTime
|
||||||
|
from crud.fact_crud import FactCRUD
|
||||||
|
from crud.place_crud import PlaceCRUD
|
||||||
|
from crud.song_crud import SongCRUD
|
||||||
|
from services import place_research, site_payload
|
||||||
|
from services.external import gemini_text, suno
|
||||||
|
from services.llm.gemini import GeminiError, GeminiInvalidOutput, GeminiNotConfigured
|
||||||
|
|
||||||
|
_fact_crud = FactCRUD()
|
||||||
|
_place_crud = PlaceCRUD()
|
||||||
|
_song_crud = SongCRUD()
|
||||||
|
|
||||||
|
# payload 와 나란히 두는 자리. 프리렌더가 이 디렉토리에서 파일을 찾는다.
|
||||||
|
# payload_dir 이 `/app/out/payloads` 면 여기는 `/app/out/songs` 다 — 컴포즈가 둘 다 호스트의
|
||||||
|
# `solution/site/` 아래로 붙인다. 한 디렉토리 약속(ARCHITECTURE 1절)을 노래에도 그대로 쓴다.
|
||||||
|
SONGS_DIRNAME = "songs"
|
||||||
|
|
||||||
|
|
||||||
|
class SongAborted(RuntimeError):
|
||||||
|
"""재시도해도 소용없는 중단 — 잡의 last_error 로 남아 운영자가 본다."""
|
||||||
|
|
||||||
|
|
||||||
|
def songs_dir() -> Path:
|
||||||
|
return site_payload.payload_dir().parent / SONGS_DIRNAME
|
||||||
|
|
||||||
|
|
||||||
|
async def _grounding(place, place_id: str) -> tuple[list[str], str]:
|
||||||
|
"""가사 재료 — 확인된 fact + 수집/조사 원문, 그리고 소개문.
|
||||||
|
|
||||||
|
★ 소개문 생성과 **같은 재료**를 쓴다(copy_service). 노래만 다른 출처를 보면 사이트의
|
||||||
|
글과 노래가 서로 다른 숙소를 말하게 된다.
|
||||||
|
"""
|
||||||
|
schema = get_schema(PlaceCategory(place.category))
|
||||||
|
pid = uuid.UUID(place_id)
|
||||||
|
|
||||||
|
f_err, fact_rows = await DB_SESSION_MNG.execute_lambda(
|
||||||
|
place_facts.DBType(),
|
||||||
|
DBWRType.DB_READ.value,
|
||||||
|
lambda s: _fact_crud.list_facts(s, pid, None, None, True, True),
|
||||||
|
)
|
||||||
|
if f_err != ErrorType.SUCCESS:
|
||||||
|
raise SongAborted(f"fact 조회 실패: {f_err.name}")
|
||||||
|
|
||||||
|
lines: list[str] = []
|
||||||
|
intro = ""
|
||||||
|
for row in (fact_rows or []):
|
||||||
|
value = (row.value or "").strip()
|
||||||
|
if not value:
|
||||||
|
continue
|
||||||
|
if row.key == "intro":
|
||||||
|
intro = value
|
||||||
|
continue
|
||||||
|
if row.key == "meta_description":
|
||||||
|
continue
|
||||||
|
spec = schema.get(row.key)
|
||||||
|
label = spec.label if spec else row.key
|
||||||
|
lines.append(f"{label}: {value}{(' ' + row.unit) if row.unit else ''}")
|
||||||
|
|
||||||
|
# 조사 근거(남이 쓴 글)도 재료다 — 이 숙소의 내력이 거기에만 있는 경우가 많다.
|
||||||
|
l_err, link_rows = await DB_SESSION_MNG.execute_lambda(
|
||||||
|
place_channels.DBType(),
|
||||||
|
DBWRType.DB_READ.value,
|
||||||
|
lambda s: _place_crud.list_links(s, pid, False),
|
||||||
|
)
|
||||||
|
if l_err == ErrorType.SUCCESS:
|
||||||
|
for link in (link_rows or []):
|
||||||
|
raw = link.raw if isinstance(link.raw, dict) else {}
|
||||||
|
if link.confirmed_at is None and raw.get("kind") != place_research.RAW_KIND:
|
||||||
|
continue
|
||||||
|
text = (raw.get("text") or "").strip()
|
||||||
|
if text:
|
||||||
|
lines.append(text[:1500])
|
||||||
|
|
||||||
|
return lines, intro
|
||||||
|
|
||||||
|
|
||||||
|
async def run_song(job: dict) -> dict:
|
||||||
|
"""SONG 잡 핸들러 — 발행과 무관하게 곡만 다시 만들 때 쓴다.
|
||||||
|
|
||||||
|
발행 경로는 이 잡을 거치지 않고 `ensure_song` 을 직접 부른다(build_service)."""
|
||||||
|
payload = job["payload"]
|
||||||
|
return await ensure_song(payload["place_id"], payload["owner_user_id"])
|
||||||
|
|
||||||
|
|
||||||
|
async def ensure_song(place_id: str, owner_user_id: str) -> dict:
|
||||||
|
"""이 업장의 노래를 한 곡 만든다. 돌려주는 dict 가 곧 잡 결과이자 빌드 로그다.
|
||||||
|
|
||||||
|
★ 실패를 예외로 올리지 않는다(사업장을 못 찾는 것 같은 진짜 고장만 예외다).
|
||||||
|
발행이 이 함수를 기다리는데 여기서 예외가 나면 **노래 때문에 발행이 통째로 실패**한다.
|
||||||
|
음악 API 가 느린 날 사장님 사이트가 안 나가는 것은 맞바꿀 수 없는 손해다."""
|
||||||
|
if not suno.is_configured():
|
||||||
|
# 키가 없는 것은 고장이 아니라 설정이다 — 예외로 올려 재시도·DEAD 로 만들지 않는다.
|
||||||
|
LOG.i(f"[song] place={place_id} 건너뜀 — SUNO_API_KEY 미설정")
|
||||||
|
return {"place_id": place_id, "skipped": "SUNO_API_KEY 미설정"}
|
||||||
|
|
||||||
|
err, place = await DB_SESSION_MNG.execute_lambda(
|
||||||
|
places.DBType(),
|
||||||
|
DBWRType.DB_READ.value,
|
||||||
|
lambda s: _place_crud.get_place(s, uuid.UUID(owner_user_id), uuid.UUID(place_id)),
|
||||||
|
)
|
||||||
|
if err != ErrorType.SUCCESS or place is None:
|
||||||
|
raise SongAborted(f"사업장을 찾을 수 없다: {place_id}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
get_schema(PlaceCategory(place.category))
|
||||||
|
except (CategorySchemaError, ValueError) as ex:
|
||||||
|
raise SongAborted(f"지원하지 않는 업종: {place.category}") from ex
|
||||||
|
|
||||||
|
lines, intro = await _grounding(place, place_id)
|
||||||
|
region = site_payload.region_label(place.road_address, place.address) or ""
|
||||||
|
|
||||||
|
# ── 1. 가사 ───────────────────────────────────────────────
|
||||||
|
async with httpx.AsyncClient(timeout=httpx.Timeout(120.0, connect=10.0)) as client:
|
||||||
|
try:
|
||||||
|
song = await gemini_text.generate_song(
|
||||||
|
place.name, PlaceCategory(place.category),
|
||||||
|
region=region, grounding=lines, intro=intro, client=client,
|
||||||
|
)
|
||||||
|
except GeminiNotConfigured:
|
||||||
|
LOG.i(f"[song] place={place_id} 건너뜀 — GEMINI_API_KEY 미설정")
|
||||||
|
return {"place_id": place_id, "skipped": "GEMINI_API_KEY 미설정"}
|
||||||
|
except GeminiInvalidOutput as ex:
|
||||||
|
LOG.i(f"[song] place={place_id} 건너뜀 — {ex}")
|
||||||
|
return {"place_id": place_id, "skipped": str(ex)}
|
||||||
|
except GeminiError as ex:
|
||||||
|
raise SongAborted(f"가사 생성 실패: {ex}") from ex
|
||||||
|
|
||||||
|
# 가사를 먼저 적재한다 — 작곡이 실패해도 "무엇을 만들려 했는가" 가 남아야 한다.
|
||||||
|
row = place_songs(
|
||||||
|
song_id=uuid.uuid4(),
|
||||||
|
place_id=uuid.UUID(place_id),
|
||||||
|
title=song.title[:200],
|
||||||
|
lyrics=song.lyrics,
|
||||||
|
style=song.style[:200],
|
||||||
|
status=SongStatus.GENERATING.value,
|
||||||
|
)
|
||||||
|
add_err = await DB_SESSION_MNG.execute_lambda_run(
|
||||||
|
[place_songs.DBType()], [lambda s: _song_crud.insert(s, row)],
|
||||||
|
)
|
||||||
|
if add_err != ErrorType.SUCCESS:
|
||||||
|
raise SongAborted(f"노래 행 생성 실패: {add_err.name}")
|
||||||
|
|
||||||
|
async def _fail(reason: str) -> dict:
|
||||||
|
await DB_SESSION_MNG.execute_lambda_claim(
|
||||||
|
place_songs.DBType(),
|
||||||
|
lambda s: _song_crud.update(s, row.song_id, {
|
||||||
|
"status": SongStatus.FAILED.value, "last_error": reason[:2000],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
LOG.w(f"[song] place={place_id} 실패: {reason}")
|
||||||
|
return {"place_id": place_id, "song_id": str(row.song_id), "error": reason}
|
||||||
|
|
||||||
|
# ── 2. 작곡 ───────────────────────────────────────────
|
||||||
|
try:
|
||||||
|
task_id = await suno.generate(song.lyrics, title=song.title, style=song.style, client=client)
|
||||||
|
except suno.SunoNotConfigured as ex:
|
||||||
|
return await _fail(str(ex))
|
||||||
|
except suno.SunoError as ex:
|
||||||
|
return await _fail(f"작곡 요청 실패: {ex}")
|
||||||
|
|
||||||
|
await DB_SESSION_MNG.execute_lambda_claim(
|
||||||
|
place_songs.DBType(),
|
||||||
|
lambda s: _song_crud.update(s, row.song_id, {"provider_task_id": task_id}),
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
clip = await suno.wait_for_clip(task_id, client=client)
|
||||||
|
except suno.SunoError as ex:
|
||||||
|
return await _fail(str(ex))
|
||||||
|
|
||||||
|
audio_url = clip.get("audioUrl") or clip.get("sourceAudioUrl") or ""
|
||||||
|
try:
|
||||||
|
content = await suno.download(audio_url, client=client)
|
||||||
|
except httpx.HTTPError as ex:
|
||||||
|
return await _fail(f"오디오 내려받기 실패: {type(ex).__name__}: {ex}")
|
||||||
|
|
||||||
|
# ── 3. 보관 ───────────────────────────────────────────────
|
||||||
|
# 파일명은 song_id 다 — 발행마다 새 곡이 생기므로 이름이 겹치면 옛 곡이 새 곡으로 바뀐다.
|
||||||
|
file_name = f"{row.song_id}.mp3"
|
||||||
|
directory = songs_dir()
|
||||||
|
try:
|
||||||
|
directory.mkdir(parents=True, exist_ok=True)
|
||||||
|
tmp = directory / f".{file_name}.tmp"
|
||||||
|
tmp.write_bytes(content)
|
||||||
|
# payload 와 같은 이유로 rename 이다 — 프리렌더가 반쯤 쓰인 파일을 집으면 안 된다.
|
||||||
|
tmp.replace(directory / file_name)
|
||||||
|
except OSError as ex:
|
||||||
|
return await _fail(f"파일 저장 실패: {ex}")
|
||||||
|
|
||||||
|
duration = clip.get("duration")
|
||||||
|
await DB_SESSION_MNG.execute_lambda_claim(
|
||||||
|
place_songs.DBType(),
|
||||||
|
lambda s: _song_crud.update(s, row.song_id, {
|
||||||
|
"status": SongStatus.READY.value,
|
||||||
|
"file_name": file_name,
|
||||||
|
"origin_url": audio_url[:1000] or None,
|
||||||
|
"duration_sec": round(float(duration), 2) if isinstance(duration, (int, float)) else None,
|
||||||
|
"title": (clip.get("title") or song.title)[:200],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
LOG.i(f"[song] place={place_id} '{song.title}' ({song.style}) 완성 — {len(content)} bytes · {file_name}")
|
||||||
|
|
||||||
|
# ★ 여기서 재빌드를 걸지 않는다. 발행이 이 함수를 **기다리고 있고**, 돌아가면 바로 그
|
||||||
|
# 스냅샷에 이 곡이 실린다. 잡으로 따로 돌 때(운영자 재생성)도 같다 — 다음 발행에 실린다.
|
||||||
|
return {
|
||||||
|
"place_id": place_id, "song_id": str(row.song_id), "title": song.title,
|
||||||
|
"style": song.style, "file": file_name,
|
||||||
|
}
|
||||||
@ -15,6 +15,7 @@
|
|||||||
JobType.COPY ✓ services/copy_service.run_copy — 소개문·FAQ (확보된 fact 만 근거)
|
JobType.COPY ✓ services/copy_service.run_copy — 소개문·FAQ (확보된 fact 만 근거)
|
||||||
JobType.BUILD ✓ services/build_service.run_build — 정적 빌드 + 발행 검수 게이트
|
JobType.BUILD ✓ services/build_service.run_build — 정적 빌드 + 발행 검수 게이트
|
||||||
JobType.LOCAL_SYNC ✓ services/story_service.run_local_sync — 지역 이야기 생성(지역당 1회)
|
JobType.LOCAL_SYNC ✓ services/story_service.run_local_sync — 지역 이야기 생성(지역당 1회)
|
||||||
|
JobType.SONG ✓ services/song_service.run_song — 이 숙소의 노래 한 곡(가사 Gemini → 작곡 Suno)
|
||||||
JobType.AI_CHECK → reports 모듈이 붙을 때
|
JobType.AI_CHECK → reports 모듈이 붙을 때
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@ -80,6 +81,7 @@ def _register_builtin():
|
|||||||
from services.build_service import run_build
|
from services.build_service import run_build
|
||||||
from services.copy_service import run_copy
|
from services.copy_service import run_copy
|
||||||
from services.story_service import run_local_sync
|
from services.story_service import run_local_sync
|
||||||
|
from services.song_service import run_song
|
||||||
from services.vision_service import run_vision
|
from services.vision_service import run_vision
|
||||||
|
|
||||||
if JobType.COLLECT.value not in HANDLERS:
|
if JobType.COLLECT.value not in HANDLERS:
|
||||||
@ -92,6 +94,8 @@ def _register_builtin():
|
|||||||
HANDLERS[JobType.BUILD.value] = run_build
|
HANDLERS[JobType.BUILD.value] = run_build
|
||||||
if JobType.LOCAL_SYNC.value not in HANDLERS:
|
if JobType.LOCAL_SYNC.value not in HANDLERS:
|
||||||
HANDLERS[JobType.LOCAL_SYNC.value] = run_local_sync
|
HANDLERS[JobType.LOCAL_SYNC.value] = run_local_sync
|
||||||
|
if JobType.SONG.value not in HANDLERS:
|
||||||
|
HANDLERS[JobType.SONG.value] = run_song
|
||||||
|
|
||||||
|
|
||||||
_register_builtin()
|
_register_builtin()
|
||||||
|
|||||||
@ -118,5 +118,7 @@ export function sanitizePayloadForPublish(payload: SitePayload): SitePayload {
|
|||||||
links: payload.links.filter((link) => link.confirmed),
|
links: payload.links.filter((link) => link.confirmed),
|
||||||
// 대체 텍스트 없는 이미지는 어차피 렌더하지 않는다 — 목록에서도 뺀다.
|
// 대체 텍스트 없는 이미지는 어차피 렌더하지 않는다 — 목록에서도 뺀다.
|
||||||
media: payload.media.filter((item) => item.alt?.trim()),
|
media: payload.media.filter((item) => item.alt?.trim()),
|
||||||
|
// 재생 주소가 없는 곡은 버튼만 있고 소리가 없다. 목록에서 뺀다.
|
||||||
|
songs: (payload.songs ?? []).filter((song) => song.audioUrl?.trim()),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -41,6 +41,15 @@ export interface SitePayload {
|
|||||||
/** 주요 거점까지의 이동 시간. */
|
/** 주요 거점까지의 이동 시간. */
|
||||||
routes: RouteEntry[];
|
routes: RouteEntry[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이 숙소의 노래. 발행할 때마다 한 곡 만든다(가사 Gemini → 작곡 Suno).
|
||||||
|
*
|
||||||
|
* ★ 발행이 노래를 기다리므로 발행본에는 보통 한 곡이 실려 있다. 그래도 빌 수 있다 —
|
||||||
|
* 키가 없거나(SUNO_API_KEY) 작곡이 실패하면 **노래 없이** 발행한다(발행을 막지는 않는다).
|
||||||
|
* 렌더러는 비면 플레이어를 아예 그리지 않는다.
|
||||||
|
*/
|
||||||
|
songs: SongTrack[];
|
||||||
|
|
||||||
/** LLM 이 쓴 문장(allow_llm=true 필드). 사실이 아니라 문장이라 fact 와 분리한다. */
|
/** LLM 이 쓴 문장(allow_llm=true 필드). 사실이 아니라 문장이라 fact 와 분리한다. */
|
||||||
narrative: Narrative;
|
narrative: Narrative;
|
||||||
|
|
||||||
@ -323,6 +332,25 @@ export interface Narrative {
|
|||||||
summary?: string;
|
summary?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SongTrack {
|
||||||
|
songId: string;
|
||||||
|
title: string;
|
||||||
|
/** 가사. 화면에서 펼쳐 볼 수 있게 함께 싣는다 — 무슨 노래인지 읽히지 않으면 아무도 안 누른다. */
|
||||||
|
lyrics?: string | null;
|
||||||
|
/** 장르·분위기(Suno 에 넘긴 값). */
|
||||||
|
style?: string | null;
|
||||||
|
durationSec?: number | null;
|
||||||
|
/**
|
||||||
|
* 재생 주소. **우리 쪽 경로**다(`/s/<slug>/<파일>`).
|
||||||
|
*
|
||||||
|
* ★ Suno 가 준 주소를 그대로 쓰지 않는다 — 만료되는 주소라, 발행 직후에는 재생되고
|
||||||
|
* 몇 주 뒤 조용히 죽는다. 백엔드가 파일을 받아 두고 프리렌더가 사이트로 복사한다.
|
||||||
|
*/
|
||||||
|
audioUrl: string;
|
||||||
|
/** 프리렌더가 원본을 찾을 때 쓰는 파일명. 렌더러는 쓰지 않는다. */
|
||||||
|
fileName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface SiteTheme {
|
export interface SiteTheme {
|
||||||
templateId: string;
|
templateId: string;
|
||||||
colors: {
|
colors: {
|
||||||
|
|||||||
@ -76,6 +76,8 @@ const SITE_DIR = 's';
|
|||||||
|
|
||||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||||
const SITE_ROOT = resolve(HERE, '..', '..'); // dist/prerender → site/
|
const SITE_ROOT = resolve(HERE, '..', '..'); // dist/prerender → site/
|
||||||
|
// 백엔드가 노래 파일을 떨구는 자리. payload 디렉토리와 나란히 둔다(한 디렉토리 약속).
|
||||||
|
const SONGS_DIR = join(SITE_ROOT, 'songs');
|
||||||
const CLIENT_DIR = join(SITE_ROOT, 'dist', 'client');
|
const CLIENT_DIR = join(SITE_ROOT, 'dist', 'client');
|
||||||
|
|
||||||
interface Args {
|
interface Args {
|
||||||
@ -285,6 +287,53 @@ function assetPlan(payload: SitePayload, outRoot: string, siteDir: string) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이 숙소의 노래 파일을 사이트 디렉토리로 옮겨 놓는다.
|
||||||
|
*
|
||||||
|
* ★ 왜 백엔드가 직접 out/ 에 쓰지 않나
|
||||||
|
* 백엔드는 발행물 디렉토리를 모른다 — payload JSON 을 약속된 자리에 떨구는 것이 경계다
|
||||||
|
* (ARCHITECTURE 1절). 노래도 같은 약속을 쓴다: 백엔드는 `site/songs/<파일>` 에 두고,
|
||||||
|
* 굽는 쪽인 여기가 사이트 안으로 복사한다. 그래야 Azure 발행(`azure_static.publish`)이
|
||||||
|
* 사이트 디렉토리를 통째로 올릴 때 노래도 함께 올라간다.
|
||||||
|
*
|
||||||
|
* ★ 없으면 조용히 넘어간다. 곡은 발행보다 2~3분 늦게 완성되므로 "아직 없음" 이 정상이고,
|
||||||
|
* 그때 payload 에 songs 가 비어 있어 화면도 플레이어를 안 그린다.
|
||||||
|
*/
|
||||||
|
function copySongs(payload: SitePayload, siteDir: string) {
|
||||||
|
const wanted = new Set<string>();
|
||||||
|
|
||||||
|
for (const song of payload.songs ?? []) {
|
||||||
|
const name = song.fileName || song.audioUrl.split('/').pop();
|
||||||
|
if (!name) continue;
|
||||||
|
wanted.add(name);
|
||||||
|
const from = join(SONGS_DIR, name);
|
||||||
|
if (!existsSync(from)) {
|
||||||
|
console.warn(` ! 노래 파일이 없습니다(건너뜀): ${from}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const to = join(siteDir, name);
|
||||||
|
rmSync(to, {force: true});
|
||||||
|
copyFileSync(from, to);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 지난 발행의 곡은 치운다.
|
||||||
|
*
|
||||||
|
* ★ 발행할 때마다 새 곡을 만들고 파일명은 song_id 라, 치우지 않으면 발행 횟수만큼 1MB 짜리
|
||||||
|
* 파일이 사이트 디렉토리에 쌓인다. 그리고 그것들은 Azure 발행 때 **함께 올라간다** —
|
||||||
|
* 아무도 듣지 않는 옛 곡이 계속 쌓이는 종류의 낭비다.
|
||||||
|
* ★ 자산(out/assets)과 달리 보관 기간을 두지 않는다. 번들은 크롤러가 나중에 렌더할 때
|
||||||
|
* 필요하지만(AGENTS.md), 노래는 그 페이지에서 버튼을 눌러야 나는 것이라 옛 HTML 이
|
||||||
|
* 가리킬 일이 없다 — payload 에 실린 곡 하나만 남기면 된다.
|
||||||
|
*/
|
||||||
|
if (!existsSync(siteDir)) return;
|
||||||
|
for (const entry of readdirSync(siteDir, {withFileTypes: true})) {
|
||||||
|
if (!entry.isFile() || !entry.name.endsWith('.mp3')) continue;
|
||||||
|
if (wanted.has(entry.name)) continue;
|
||||||
|
rmSync(join(siteDir, entry.name), {force: true});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function prerenderSite(
|
function prerenderSite(
|
||||||
input: SitePayload,
|
input: SitePayload,
|
||||||
outRoot: string,
|
outRoot: string,
|
||||||
@ -375,6 +424,8 @@ function prerenderSite(
|
|||||||
*/
|
*/
|
||||||
writeFile(siteDir, 'llms.txt', renderLlmsTxt(payload));
|
writeFile(siteDir, 'llms.txt', renderLlmsTxt(payload));
|
||||||
|
|
||||||
|
copySongs(payload, siteDir);
|
||||||
|
|
||||||
// 하이드레이션용 번들. 공용 호스트면 out/ 루트 한 벌을 공유하므로 여기서는 아무것도 안 한다
|
// 하이드레이션용 번들. 공용 호스트면 out/ 루트 한 벌을 공유하므로 여기서는 아무것도 안 한다
|
||||||
// (main 이 사이트를 굽기 전에 한 번 깔아 둔다). 커스텀 도메인일 때만 사이트 안에 복사한다.
|
// (main 이 사이트를 굽기 전에 한 번 깔아 둔다). 커스텀 도메인일 때만 사이트 안에 복사한다.
|
||||||
if (!plan.shared) {
|
if (!plan.shared) {
|
||||||
|
|||||||
4
solution/site/songs/.gitignore
vendored
Normal file
4
solution/site/songs/.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
# 생성된 노래 파일(mp3)은 커밋하지 않는다 — payload 와 같은 규칙이다.
|
||||||
|
# 발행할 때 백엔드가 다시 만들어 떨군다.
|
||||||
|
*
|
||||||
|
!.gitignore
|
||||||
@ -505,6 +505,16 @@ export const MOONLIGHT_STAY_PAYLOAD: SitePayload = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 노래는 비워 둔다.
|
||||||
|
*
|
||||||
|
* ★ 이 픽스처의 목적은 "payload 가 이러이러할 때 화면이 이렇게 나온다" 를 눈으로 보는 것이다.
|
||||||
|
* 노래는 발행 때 Suno 가 만들어 넣는 실제 파일을 가리키므로, 여기에 가짜 주소를 적으면
|
||||||
|
* 개발 서버에서 **재생만 안 되는 버튼**이 생긴다. 비어 있을 때 플레이어가 아예 안 그려지는지
|
||||||
|
* 확인하는 쪽이 이 픽스처가 할 일에 맞다.
|
||||||
|
*/
|
||||||
|
songs: [],
|
||||||
|
|
||||||
narrative: {
|
narrative: {
|
||||||
heroHeadline: '바람과 돌담이 품은 고요',
|
heroHeadline: '바람과 돌담이 품은 고요',
|
||||||
heroSubline: '제주 자연의 질감이 머무는 독채 스테이',
|
heroSubline: '제주 자연의 질감이 머무는 독채 스테이',
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import {Menu, Phone} from 'lucide-react';
|
import {Menu, Phone} from 'lucide-react';
|
||||||
import {useSite} from '@site/lib/site-context';
|
import {useSite} from '@site/lib/site-context';
|
||||||
|
import {SongPlayer} from '@site/sections/SongPlayer';
|
||||||
import {isSectionEnabled, stayBookingView, unitSpec} from '@site/lib/derive';
|
import {isSectionEnabled, stayBookingView, unitSpec} from '@site/lib/derive';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -77,6 +78,9 @@ export function SiteHeader() {
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="flex shrink-0 items-center gap-1.5">
|
<div className="flex shrink-0 items-center gap-1.5">
|
||||||
|
{/* 이 숙소의 노래. 곡이 없으면 아무것도 그리지 않는다(SongPlayer 머리주석). */}
|
||||||
|
<SongPlayer />
|
||||||
|
|
||||||
{payload.place.phone && (
|
{payload.place.phone && (
|
||||||
<a
|
<a
|
||||||
href={`tel:${payload.place.phone}`}
|
href={`tel:${payload.place.phone}`}
|
||||||
|
|||||||
167
solution/site/src/sections/SongPlayer.tsx
Normal file
167
solution/site/src/sections/SongPlayer.tsx
Normal file
@ -0,0 +1,167 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
56
solution/site/src/sections/song-player.test.tsx
Normal file
56
solution/site/src/sections/song-player.test.tsx
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
/**
|
||||||
|
* 노래 플레이어 — 이 검사가 지키는 것.
|
||||||
|
*
|
||||||
|
* 1. 곡이 없으면 **아무것도 그리지 않는다.** 노래는 발행보다 2~3분 늦게 완성되므로
|
||||||
|
* "아직 없음" 이 정상 상태다. 그때 버튼을 그려 두면 손님에게는 고장난 버튼이다.
|
||||||
|
* 2. 곡이 있으면 제목·가사가 **HTML 에 들어간다.** 오디오 안의 말은 크롤러가 못 듣는다 —
|
||||||
|
* 가사가 서버 렌더 결과에 없으면 이 노래는 검색·AI 쪽에 존재하지 않는 것과 같다.
|
||||||
|
* 3. 재생 주소가 **우리 경로**다. Suno 가 준 주소는 만료되므로 발행본에 나가면 안 된다 —
|
||||||
|
* 나가면 발행 직후에는 재생되고 몇 주 뒤 조용히 죽는다.
|
||||||
|
* 4. 재생 주소가 없는 곡은 걸러진다(버튼만 있고 소리가 없는 자리를 만들지 않는다).
|
||||||
|
*/
|
||||||
|
import {describe, expect, it} from 'vitest';
|
||||||
|
|
||||||
|
import {sanitizePayloadForPublish, type SitePayload, type SongTrack} from '@o2o/shared';
|
||||||
|
import {MOONLIGHT_STAY_PAYLOAD} from '@site/fixtures/moonlight-stay';
|
||||||
|
import {render} from '@site/entry-server';
|
||||||
|
|
||||||
|
const SONG: SongTrack = {
|
||||||
|
songId: 'eb5e4efe-47f4-445e-bfa7-613482bb2cfc',
|
||||||
|
title: '시간이 머무는 자리',
|
||||||
|
lyrics: '[Verse]\n군산의 오랜 골목길 따라\n[Chorus]\n시간이 천천히 흐르는 이곳',
|
||||||
|
style: 'acoustic ballad',
|
||||||
|
durationSec: 59.2,
|
||||||
|
audioUrl: '/s/moonlight-stay-jeju/eb5e4efe-47f4-445e-bfa7-613482bb2cfc.mp3',
|
||||||
|
fileName: 'eb5e4efe-47f4-445e-bfa7-613482bb2cfc.mp3',
|
||||||
|
};
|
||||||
|
|
||||||
|
function withSongs(songs: SongTrack[]): SitePayload {
|
||||||
|
return {...MOONLIGHT_STAY_PAYLOAD, songs};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('노래 플레이어', () => {
|
||||||
|
it('곡이 없으면 플레이어를 그리지 않는다', () => {
|
||||||
|
const html = render(withSongs([]));
|
||||||
|
expect(html).not.toContain('노래 목록');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('곡이 있으면 제목과 가사가 HTML 에 들어간다', () => {
|
||||||
|
const html = render(withSongs([SONG]));
|
||||||
|
expect(html).toContain('노래 목록');
|
||||||
|
expect(html).toContain('시간이 머무는 자리');
|
||||||
|
expect(html).toContain('군산의 오랜 골목길 따라');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('★ 재생 주소는 우리 경로다 — Suno 주소(만료됨)가 발행본에 나가면 안 된다', () => {
|
||||||
|
const html = render(withSongs([SONG]));
|
||||||
|
expect(html).toContain('/s/moonlight-stay-jeju/eb5e4efe-47f4-445e-bfa7-613482bb2cfc.mp3');
|
||||||
|
expect(html).not.toContain('sunoapi.org');
|
||||||
|
expect(html).not.toContain('cdn1.suno.ai');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('재생 주소가 없는 곡은 발행 전에 걸러진다', () => {
|
||||||
|
const cleaned = sanitizePayloadForPublish(withSongs([{...SONG, audioUrl: ''}]));
|
||||||
|
expect(cleaned.songs).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user