"""이 숙소의 노래 — 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//` 안에 있으므로 `azure_static.publish(slug)` 가 사이트를 통째로 올릴 때 함께 올라간다(content-type `audio/mpeg`, 해시 파일명이라 immutable 캐시). 지난 곡은 `_remove_stale_site_files` 가 블롭에서도 지운다. → 업로더를 하나 더 두면 같은 컨테이너에 **두 규칙**이 생긴다(경로·캐시·정리 주체). 참고 프로젝트(o2o-castad-backend)는 사이트가 없어 직접 올릴 수밖에 없었지만, 여기서는 노래가 사이트의 일부라 사이트와 같은 길로 나가는 것이 맞다. 덕분에 주소도 같은 오리진(`/s//.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 import provider from services.llm.gemini import GeminiError, GeminiInvalidOutput, GeminiNotConfigured from common.job_errors import PermanentJobError _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(PermanentJobError): """재시도해도 소용없는 중단 — 잡의 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"] # ★ 이 잡은 "다시 만들어 달라" 는 요청이다 — 기존 곡이 있어도 만든다(ensure_song 주석). return await ensure_song(payload["place_id"], payload["owner_user_id"], force=True) async def ensure_song(place_id: str, owner_user_id: str, *, force: bool = False) -> dict: """이 업장의 노래를 한 곡 만든다. 돌려주는 dict 가 곧 잡 결과이자 빌드 로그다. ★ **이미 완성된 곡이 있으면 만들지 않는다**(2026-09-15 대표: "재발행할 때 노래 다시 생성하면 안 되거든"). 예전에는 `build_service` 가 `publish=True` 마다 이 함수를 불렀고 여기에 가드가 없어서, 내용이 하나도 안 바뀐 재발행에도 **Gemini 가사 1회 + Suno 작곡 1회**가 그대로 나갔다. 사장님이 발행을 다섯 번 누르면 유료 호출 다섯 번에 `place_songs` 행이 다섯 개다. 화면은 최신 READY 한 곡만 쓰므로(`SongCRUD.latest_ready`) 나머지는 값을 만들지 않고 돈만 쓴다. ★ 곡을 **일부러 다시 만드는 길은 남긴다** — `force=True`. 그 길은 발행이 아니라 SONG 잡(`run_song`)이고, 사람이 눌러야 돈다. ★ 실패를 예외로 올리지 않는다(사업장을 못 찾는 것 같은 진짜 고장만 예외다). 발행이 이 함수를 기다리는데 여기서 예외가 나면 **노래 때문에 발행이 통째로 실패**한다. 음악 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 미설정"} if not force: err, ready = await DB_SESSION_MNG.execute_lambda( place_songs.DBType(), DBWRType.DB_READ.value, lambda s: _song_crud.latest_ready(s, uuid.UUID(place_id)), ) # `execute()` 는 리스트를 준다(limit 1 이라 0개 아니면 1개다) — None 비교로 보지 않는다. if err == ErrorType.SUCCESS and ready: LOG.i(f"[song] place={place_id} 건너뜀 — 이미 곡이 있다(재발행은 다시 만들지 않는다)") return {"place_id": place_id, "skipped": "이미 곡이 있다"} 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} 건너뜀 — {provider.missing_key()} 미설정") return {"place_id": place_id, "skipped": f"{provider.missing_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, }