552 lines
22 KiB
Python
552 lines
22 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""썰박스 생성 잡 매니저 — subprocess 감독 + 진행 단계 DB 영속화.
|
|
|
|
원본(o2o-ssulbox/app/jobs.py)에서 이식하되 4가지를 바꿨다:
|
|
|
|
1. **SSE 제거** — castad 는 폴링을 쓴다. `_subscribers`/`_emit` 을 통째로 걷어냈다.
|
|
2. **step 을 DB 에 영속화** — 원본은 인메모리 `_jobs` 에만 뒀지만, 폴링이 진행률을
|
|
돌려주려면 DB 에 있어야 한다. 나중에 워커를 늘려도 폴링은 그대로 동작한다.
|
|
3. **세션 팩토리를 BackgroundSessionLocal 로** — 요청용 풀(20+20)을 장시간 잡이
|
|
잠식하지 않게 한다. castad `video_task.py` 와 같은 관행.
|
|
4. **좀비 방지** — 원본은 `p.wait()` 에 타임아웃이 없어 엔진이 걸리면 스레드가
|
|
영구 블록되고 `_running` 이 안 줄어 큐가 멎었다. **감시견 타이머**로 데드라인에
|
|
프로세스를 죽인다. `wait(timeout=)` 만 걸면 안 된다 — stdout 읽기 루프가
|
|
EOF 까지 블록하므로 엔진이 조용히 매달리면 wait() 에 도달조차 못 한다
|
|
(2026-07-29 `zzz/_ssul_timeout_verify.py` 로 확인한 실제 결함).
|
|
|
|
⚠️ **단일 워커 전제** — `_jobs`/`_running` 이 프로세스 로컬이고, `sweep_orphans` 가
|
|
"기동 시 비터미널 잡은 전부 고아"라는 불변식에 의존한다. `--workers` 를 늘리면
|
|
워커 B 가 기동하며 워커 A 의 정상 잡을 환불·error 처리한다.
|
|
"""
|
|
|
|
import asyncio
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import threading
|
|
import time
|
|
from collections import deque
|
|
from pathlib import Path
|
|
from typing import Any, Optional
|
|
|
|
from app.database.session import BackgroundSessionLocal
|
|
from app.ssulbox.constants import (
|
|
DONE_RE,
|
|
JOB_DIR_RE,
|
|
SCENARIO_ENGINE,
|
|
STEP_NAMES,
|
|
STEP_RE,
|
|
STORE_RE,
|
|
gemini_key,
|
|
)
|
|
from app.ssulbox.services import place_service, task_service
|
|
from app.utils.address_parser import extract_region_from_address
|
|
from app.utils.logger import get_logger
|
|
from config import ssulbox_settings
|
|
|
|
logger = get_logger("ssulbox")
|
|
|
|
# ── 프로세스 로컬 상태 ────────────────────────────────────────
|
|
#: 진행 중인 잡의 로그·타이밍 (권위 아님 — 권위는 DB status/step)
|
|
_jobs: dict[int, dict[str, Any]] = {}
|
|
_queue: deque[int] = deque()
|
|
_lock = threading.Lock()
|
|
_running = 0
|
|
_shutting_down = False
|
|
#: 앱 이벤트 루프. 워커 스레드가 DB 작업을 위임할 대상
|
|
_loop: Optional[asyncio.AbstractEventLoop] = None
|
|
|
|
|
|
def create_job(
|
|
content_id: int,
|
|
scenario: str,
|
|
input_text: str,
|
|
scenes: int,
|
|
seconds: int,
|
|
*,
|
|
store_name: str | None = None,
|
|
road_address: str | None = None,
|
|
address: str | None = None,
|
|
) -> None:
|
|
"""잡을 큐에 넣는다.
|
|
|
|
**반드시 앱 이벤트 루프(요청 핸들러)에서 호출해야 한다** — 여기서 루프를 캡처해
|
|
워커 스레드가 DB 작업을 위임할 때 쓴다.
|
|
"""
|
|
global _loop
|
|
_loop = asyncio.get_running_loop()
|
|
_jobs[content_id] = {
|
|
"id": content_id,
|
|
"scenario": scenario,
|
|
"input": input_text,
|
|
"scenes": scenes,
|
|
"seconds": seconds,
|
|
"status": "queued",
|
|
"step": 0,
|
|
"log": [],
|
|
"output": None,
|
|
"job_dir": None, # generator 가 stdout 으로 알려준다. 실패 정리 대상
|
|
"store_name": None, # 검색을 안 거친 경우 생성 로그에서 주워온다
|
|
# 자동완성으로 고른 값. place URL 해석(_resolve_input_url)에만 쓴다 —
|
|
# DB 저장은 create_task 가 이미 했으므로 여기서는 힌트일 뿐이다.
|
|
"store_name_hint": store_name,
|
|
"road_address_hint": road_address,
|
|
"address_hint": address,
|
|
"error": None,
|
|
"timings": {},
|
|
}
|
|
with _lock:
|
|
_queue.append(content_id)
|
|
_pump()
|
|
|
|
|
|
def get_job(content_id: int) -> Optional[dict]:
|
|
"""인메모리 진행 정보. 로그 확인용이며 권위는 DB 다."""
|
|
return _jobs.get(content_id)
|
|
|
|
|
|
def shutdown() -> None:
|
|
"""신규 큐잉을 막는다. lifespan shutdown 에서 dispose_engine 전에 호출."""
|
|
global _shutting_down
|
|
_shutting_down = True
|
|
with _lock:
|
|
dropped = len(_queue)
|
|
_queue.clear()
|
|
if dropped:
|
|
logger.info(f"[job_manager] 종료 — 대기 중이던 {dropped}건은 다음 기동 스윕이 처리")
|
|
|
|
|
|
def _run_db(coro, timeout: Optional[int] = None):
|
|
"""워커 스레드에서 앱 루프에 DB 코루틴을 위임하고 완료까지 대기한다.
|
|
|
|
asyncmy 커넥션 풀은 생성된 이벤트 루프에 바인딩되므로 스레드에서
|
|
`asyncio.run` 을 쓰면 풀이 깨진다. 반드시 앱 루프에 위임해야 한다.
|
|
"""
|
|
if _loop is None or _loop.is_closed():
|
|
# 셧다운 중이면 루프가 코루틴을 실행하지 않아 무한정 매달린다.
|
|
coro.close()
|
|
raise RuntimeError("event loop unavailable (shutting down)")
|
|
fut = asyncio.run_coroutine_threadsafe(coro, _loop)
|
|
return fut.result(timeout=timeout or ssulbox_settings.SSULBOX_DB_DELEGATE_TIMEOUT)
|
|
|
|
|
|
async def _mark_running(content_id: int) -> None:
|
|
async with BackgroundSessionLocal() as session:
|
|
await task_service.mark_running(session, content_id)
|
|
await session.commit()
|
|
|
|
|
|
async def _update_step(content_id: int, step: int) -> None:
|
|
async with BackgroundSessionLocal() as session:
|
|
await task_service.update_step(session, content_id, step)
|
|
await session.commit()
|
|
|
|
|
|
async def _get_place_info(
|
|
content_id: int,
|
|
) -> tuple[Optional[str], Optional[str], Optional[str], Optional[str]]:
|
|
"""현재 저장된 (store_name, region, detail_region_info, official_site_url)."""
|
|
async with BackgroundSessionLocal() as session:
|
|
return await task_service.get_place_info(session, content_id)
|
|
|
|
|
|
async def _save_place(
|
|
content_id: int,
|
|
store_name: str,
|
|
region: str,
|
|
detail: str,
|
|
official_site_url: Optional[str] = None,
|
|
) -> None:
|
|
async with BackgroundSessionLocal() as session:
|
|
await task_service.set_place_info(
|
|
session,
|
|
content_id,
|
|
store_name=store_name,
|
|
region=region,
|
|
detail_region_info=detail,
|
|
official_site_url=official_site_url,
|
|
)
|
|
await session.commit()
|
|
|
|
|
|
async def _finalize(
|
|
content_id: int, mp4: Path, store_name: Optional[str] = None
|
|
) -> None:
|
|
cleanup: Optional[Path] = None
|
|
async with BackgroundSessionLocal() as session:
|
|
try:
|
|
# store_name 은 생성 시점에 비어 있을 때만 반영된다(finalize_task 가 판단).
|
|
_, cleanup = await task_service.finalize_task(
|
|
session, content_id, mp4, store_name=store_name
|
|
)
|
|
await session.commit()
|
|
except Exception:
|
|
await session.rollback()
|
|
raise
|
|
# 되돌릴 수 없는 삭제는 커밋이 성공한 뒤에만
|
|
task_service.cleanup_job_dir(cleanup)
|
|
|
|
|
|
async def _try_generate_sns_metadata(content_id: int) -> None:
|
|
"""제목/설명/해시태그 생성 실패가 완료 처리에 영향을 주지 않도록 격리합니다."""
|
|
from app.social.services.seo_service import seo_service
|
|
|
|
try:
|
|
async with BackgroundSessionLocal() as session:
|
|
await seo_service.generate_and_save_for_ssul(content_id, session)
|
|
await session.commit()
|
|
except Exception as e:
|
|
logger.warning(
|
|
f"[ssul {content_id}] SNS 메타데이터 생성 실패: {e}",
|
|
exc_info=True,
|
|
)
|
|
|
|
|
|
async def _fail(content_id: int, error: str) -> None:
|
|
async with BackgroundSessionLocal() as session:
|
|
try:
|
|
await task_service.fail_task(session, content_id, error)
|
|
await session.commit()
|
|
except Exception:
|
|
await session.rollback()
|
|
raise
|
|
|
|
|
|
def _pump() -> None:
|
|
"""동시 실행 한도 안에서 대기 중인 잡을 시작한다."""
|
|
global _running
|
|
if _shutting_down:
|
|
return
|
|
with _lock:
|
|
while _queue and _running < ssulbox_settings.SSULBOX_MAX_CONCURRENT_JOBS:
|
|
content_id = _queue.popleft()
|
|
_running += 1
|
|
threading.Thread(target=_run, args=(content_id,), daemon=True).start()
|
|
|
|
|
|
def _build_command(job: dict) -> tuple[list[str], Path]:
|
|
"""generator 실행 커맨드와 작업 디렉터리."""
|
|
engine = SCENARIO_ENGINE[job["scenario"]]
|
|
engine_dir = ssulbox_settings.generator_path / engine
|
|
main_py = engine_dir / "main.py"
|
|
if not main_py.exists():
|
|
raise FileNotFoundError(f"generator not found: {main_py}")
|
|
|
|
cmd = [
|
|
sys.executable,
|
|
"-u",
|
|
str(main_py),
|
|
job["input"],
|
|
"--scenes",
|
|
str(job["scenes"]),
|
|
"--seconds",
|
|
str(job["seconds"]),
|
|
]
|
|
return cmd, engine_dir
|
|
|
|
|
|
def _is_place_url(text: str) -> bool:
|
|
"""네이버 지도 링크로 보이는가 — 프론트 `isNaverUrl` 과 같은 판정."""
|
|
t = (text or "").strip().lower()
|
|
return t.startswith("http") and (
|
|
"naver.me" in t or "map.naver" in t or "place.naver" in t
|
|
)
|
|
|
|
|
|
def _collect_place_info(content_id: int, job: dict) -> None:
|
|
"""비어 있는 업장명·지역·공식 링크를 크롤링으로 채운다(있는 값은 유지).
|
|
|
|
**빠진 값이 있을 때만 크롤링한다.** 검색으로 고른 경우 create 시점에 업장명·지역이
|
|
채워져 있으나 공식 링크는 늘 비어 있으므로, 그 경로에서도 이 함수가 크롤링한다
|
|
(Playwright 1회, 최대 120초). 링크를 못 얻어도 place URL 폴백은 남는다.
|
|
|
|
수집 실패는 삼킨다. 이 정보가 없어도 생성은 place_url 만으로 진행된다.
|
|
"""
|
|
place_url = job.get("input", "")
|
|
if not _is_place_url(place_url):
|
|
return # 업장명 해석 실패 — 링크로 쓸 값이 없다
|
|
try:
|
|
store_name, region, detail, site_url = _run_db(_get_place_info(content_id))
|
|
if store_name and region and detail and site_url:
|
|
return # 채울 것이 없다
|
|
|
|
# 크롤링이 실패해도 오버레이가 뜨도록 place URL 을 먼저 폴백으로 저장한다.
|
|
# castad 가 `official_site_url or 크롤링 소스 URL` 로 폴백하는 것과 같은 규칙.
|
|
if not site_url:
|
|
_run_db(_save_place(content_id, "", "", "", place_url))
|
|
|
|
detail = _run_db(
|
|
place_service.fetch_place_detail(place_url),
|
|
# Playwright 기동 + 상세 파싱까지 DB 위임 기본 타임아웃(60s)보다 길 수 있다
|
|
timeout=120,
|
|
)
|
|
if not detail:
|
|
return
|
|
|
|
title = detail.get("title") or ""
|
|
road = detail.get("roadAddress") or ""
|
|
jibun = detail.get("address") or ""
|
|
homepage = detail.get("homepage") or ""
|
|
# castad `/home/crawl` 과 동일하게 **도로명·지번을 모두** 넘긴다.
|
|
# 도로명에서 시/군 추출이 실패하면 지번으로 재시도한다(한쪽만 넘기면 놓친다).
|
|
new_region = extract_region_from_address(road or None, jibun or None)
|
|
new_detail = road or jibun # 도로명 우선, 없으면 지번
|
|
if title:
|
|
job["store_name"] = title
|
|
if title or new_region or new_detail or homepage:
|
|
# homepage 가 있으면 위에서 넣어 둔 place URL 폴백을 진짜 홈페이지로 승급시킨다.
|
|
_run_db(_save_place(content_id, title, new_region, new_detail, homepage))
|
|
except Exception as e:
|
|
logger.warning(
|
|
f"[ssul {content_id}] 업장 정보 수집 실패(생성은 계속): "
|
|
f"{type(e).__name__}: {e}"
|
|
)
|
|
|
|
|
|
def _resolve_input_url(job: dict) -> None:
|
|
"""업장명 입력을 네이버 지도 place URL 로 바꾼다(자동완성 선택 경로).
|
|
|
|
generator 는 place 페이지를 크롤링하므로 URL 이 있어야 정확한 가게를 잡는다.
|
|
해석은 castad `NvMapPwScraper` 경로를 재사용한다(ADO2 자동완성과 동일).
|
|
|
|
실패하면 원래 입력(업장명)을 그대로 둔다 — generator 가 업장명만으로도
|
|
네이버 브리핑을 시도한다. 정확도는 떨어지지만 생성을 막지는 않는다.
|
|
"""
|
|
raw = (job.get("input") or "").strip()
|
|
store = (job.get("store_name_hint") or "").strip()
|
|
if not store or _is_place_url(raw):
|
|
return
|
|
try:
|
|
url = _run_db(
|
|
place_service.resolve_place_url(
|
|
store,
|
|
address=job.get("address_hint") or "",
|
|
road_address=job.get("road_address_hint") or "",
|
|
),
|
|
# Playwright 기동 + 최대 3단 재시도라 DB 위임 기본 타임아웃보다 길다
|
|
timeout=120,
|
|
)
|
|
if url:
|
|
job["input"] = url
|
|
logger.info(f"[_resolve_input_url] {store!r} → {url}")
|
|
else:
|
|
logger.warning(f"[_resolve_input_url] 해석 실패, 업장명으로 진행 - {store!r}")
|
|
except Exception as e:
|
|
logger.warning(
|
|
f"[_resolve_input_url] 실패(업장명으로 진행): {type(e).__name__}: {e}"
|
|
)
|
|
|
|
|
|
def _run(content_id: int) -> None:
|
|
"""워커 스레드 본체. subprocess 를 감독하며 stdout 마커로 진행을 파싱한다."""
|
|
global _running
|
|
job = _jobs[content_id]
|
|
proc: Optional[subprocess.Popen] = None
|
|
|
|
try:
|
|
_run_db(_mark_running(content_id))
|
|
job["status"] = "running"
|
|
|
|
# 업장명만 들어온 경우(자동완성 선택) place URL 로 바꾼다.
|
|
# **요청 핸들러가 아니라 여기서 하는 이유**: 해석에 10초 이상 걸려
|
|
# `/ssul/create` 응답이 그만큼 막히고 사용자가 폼에 묶인다.
|
|
# 워커로 옮기면 요청은 즉시 끝나고 진행 화면이 바로 뜬다.
|
|
_resolve_input_url(job)
|
|
|
|
# 업장명·주소를 **항상** 확보한다. 검색으로 고르지 않고 place URL 을
|
|
# 붙여넣은 경우 create 시점에 아무것도 없으므로 여기서 크롤링한다.
|
|
# 생성 **전에** 하는 이유: 목록에 이름 없는 카드가 뜨는 구간을 없앤다.
|
|
# 실패해도 생성은 계속한다(목록 표시·필터용 부가 정보다).
|
|
_collect_place_info(content_id, job)
|
|
|
|
# ⚠️ 커맨드는 위 두 단계가 끝난 **뒤에** 만든다 — job["input"] 이 갱신되므로
|
|
# 먼저 만들면 해석 전 값(업장명)이 그대로 엔진에 넘어간다.
|
|
cmd, engine_dir = _build_command(job)
|
|
|
|
env = dict(os.environ)
|
|
env["PYTHONIOENCODING"] = "utf-8"
|
|
key = gemini_key()
|
|
if key:
|
|
env["GEMINI_API_KEY"] = key
|
|
|
|
proc = subprocess.Popen(
|
|
cmd,
|
|
cwd=str(engine_dir),
|
|
env=env,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.STDOUT,
|
|
text=True,
|
|
encoding="utf-8",
|
|
errors="replace",
|
|
bufsize=1,
|
|
)
|
|
|
|
started = step_started = time.monotonic()
|
|
timeout_s = ssulbox_settings.SSULBOX_JOB_TIMEOUT_SECONDS
|
|
|
|
# ⚠️ 벽시계 감시견이 필요한 이유 — `proc.wait(timeout=)` 만으로는 못 막는다.
|
|
# 아래 `for raw in proc.stdout` 은 EOF 까지 블록한다. 엔진이 출력 없이
|
|
# 매달리면(응답 없는 API 호출 등) EOF 가 오지 않아 워커 스레드가 영구
|
|
# 정지하고 `_running` 이 안 줄어 **큐 전체가 멎는다**. wait() 의 타임아웃은
|
|
# stdout 이 닫힌 뒤에야 평가되므로 정작 그 상황에는 도달하지 못한다.
|
|
# 데드라인에 프로세스를 죽여야 stdout 이 닫히고 루프가 풀린다.
|
|
timed_out = threading.Event()
|
|
|
|
def _on_deadline() -> None:
|
|
timed_out.set()
|
|
logger.error(f"[ssul {content_id}] TIMEOUT {timeout_s}s — 프로세스 종료")
|
|
_kill(proc)
|
|
|
|
watchdog = threading.Timer(timeout_s, _on_deadline)
|
|
watchdog.daemon = True
|
|
watchdog.start()
|
|
|
|
try:
|
|
for raw in proc.stdout: # type: ignore[union-attr]
|
|
line = raw.rstrip()
|
|
if not line:
|
|
continue
|
|
logger.info(f"[ssul {content_id}] {line}")
|
|
job["log"].append(line)
|
|
if len(job["log"]) > 300:
|
|
del job["log"][:-300]
|
|
|
|
m = STEP_RE.search(line)
|
|
if m:
|
|
n = int(m.group(1))
|
|
if n != job["step"]:
|
|
now = time.monotonic()
|
|
prev = job["step"]
|
|
label = "준비·크롤링" if prev == 0 else STEP_NAMES[prev - 1]
|
|
job["timings"][label] = round(now - step_started, 1)
|
|
step_started = now
|
|
job["step"] = n
|
|
# 폴링이 읽을 수 있도록 DB 에 반영한다
|
|
try:
|
|
_run_db(_update_step(content_id, n))
|
|
except Exception as e:
|
|
logger.warning(f"[ssul {content_id}] step 저장 실패: {e}")
|
|
|
|
d = DONE_RE.search(line)
|
|
if d:
|
|
job["output"] = d.group(1).strip()
|
|
|
|
# 실패 시 지울 대상. 완료 마커가 없어도 알 수 있는 유일한 경로다.
|
|
jd = JOB_DIR_RE.search(line)
|
|
if jd:
|
|
job["job_dir"] = jd.group(1).strip()
|
|
|
|
# place URL 을 붙여넣어 검색을 안 거친 경우의 업장명 확보 경로.
|
|
# '?' 는 엔진이 이름을 못 얻었을 때 찍는 placeholder 라 버린다.
|
|
sm = STORE_RE.search(line)
|
|
if sm:
|
|
name = sm.group(1).strip()
|
|
if name and name != "?":
|
|
job["store_name"] = name
|
|
|
|
# stdout 이 닫혔으니 종료는 임박했다. 짧은 여유만 준다.
|
|
code = proc.wait(timeout=30)
|
|
finally:
|
|
watchdog.cancel()
|
|
|
|
if timed_out.is_set():
|
|
# 감시견이 죽인 것이지 정상 종료가 아니다. 아래 except 로 넘긴다.
|
|
raise subprocess.TimeoutExpired(cmd, timeout_s)
|
|
|
|
summary = " · ".join(f"{k} {v}s" for k, v in job["timings"].items()) or "(마커 없음)"
|
|
logger.info(
|
|
f"[ssul {content_id}] 단계별 {summary} · 총 {time.monotonic() - started:.1f}s"
|
|
)
|
|
|
|
if code != 0:
|
|
raise RuntimeError(f"생성 프로세스 종료코드 {code}")
|
|
if not job["output"]:
|
|
raise RuntimeError("완성 마커를 찾지 못했습니다")
|
|
|
|
mp4 = Path(job["output"])
|
|
if not mp4.is_absolute():
|
|
mp4 = (engine_dir / mp4).resolve()
|
|
|
|
# finalize 는 Blob 업로드·포스터를 포함해 오래 걸리므로 위임 타임아웃을 넉넉히
|
|
_run_db(
|
|
_finalize(content_id, mp4, job.get("store_name")), timeout=600
|
|
)
|
|
# ADO2 video_task 와 같이 완료 커밋 뒤에 SEO를 돌린다. 실패해도 영상은 유지.
|
|
try:
|
|
_run_db(_try_generate_sns_metadata(content_id), timeout=240)
|
|
except Exception as e:
|
|
logger.warning(
|
|
f"[ssul {content_id}] SNS 메타데이터 위임 실패: {type(e).__name__}: {e}"
|
|
)
|
|
job["status"] = "done"
|
|
job["step"] = 4
|
|
|
|
except subprocess.TimeoutExpired as e:
|
|
# 원본은 타임아웃이 없어 엔진이 걸리면 큐가 영구 정지했다.
|
|
# 두 경로로 들어온다: 감시견 데드라인, 또는 stdout 이 닫혔는데도
|
|
# 프로세스가 안 끝나는 경우(위 `wait(timeout=30)`).
|
|
job["status"] = "error"
|
|
job["error"] = f"생성 시간 초과 ({e.timeout}s)"
|
|
logger.error(f"[ssul {content_id}] TIMEOUT — 프로세스 종료")
|
|
_kill(proc)
|
|
_safe_fail(content_id, job["error"])
|
|
|
|
except Exception as e:
|
|
job["status"] = "error"
|
|
job["error"] = f"{type(e).__name__}: {e}"
|
|
logger.error(f"[ssul {content_id}] FAILED - {job['error']}", exc_info=True)
|
|
_kill(proc)
|
|
_safe_fail(content_id, job["error"])
|
|
|
|
finally:
|
|
with _lock:
|
|
_running -= 1
|
|
_pump()
|
|
|
|
|
|
def _kill(proc: Optional[subprocess.Popen]) -> None:
|
|
if proc is None or proc.poll() is not None:
|
|
return
|
|
try:
|
|
proc.kill()
|
|
except Exception as e:
|
|
logger.warning(f"[job_manager] 프로세스 종료 실패: {e}")
|
|
|
|
|
|
def _safe_fail(content_id: int, error: str) -> None:
|
|
"""환불·상태 갱신. 이것마저 실패하면 다음 기동의 고아 스윕이 재처리한다."""
|
|
try:
|
|
_run_db(_fail(content_id, error))
|
|
except Exception as e:
|
|
logger.error(
|
|
f"[ssul {content_id}] 실패 처리 실패(스윕이 재처리): {type(e).__name__}: {e}"
|
|
)
|
|
finally:
|
|
# DB 처리 성패와 무관하게 디스크는 정리한다. 남겨봐야 쓸 곳이 없다.
|
|
_cleanup_failed_dir(content_id)
|
|
|
|
|
|
def _cleanup_failed_dir(content_id: int) -> None:
|
|
"""실패한 잡의 중간 산출물 삭제.
|
|
|
|
castad `video_task.py` 는 `finally` 로 성공·실패 양쪽을 정리한다. 썰박스도
|
|
맞춘다. 실패 시엔 generator 자신의 자산 정리(`main.py` 의 `out_mp4.exists()`
|
|
조건)마저 건너뛰므로 **오히려 실패가 가장 많이 남긴다** — 이미지·음성 전체.
|
|
"""
|
|
raw = (_jobs.get(content_id) or {}).get("job_dir")
|
|
if not raw:
|
|
# 폴더 마커 전에 죽었다면 만들어진 것도 없다
|
|
return
|
|
try:
|
|
job_dir = Path(raw).resolve()
|
|
root = ssulbox_settings.output_path.resolve()
|
|
# stdout 에서 읽어온 경로다. output 밖은 무슨 일이 있어도 지우지 않는다.
|
|
# `job_dir.parent != root` 는 `output/<엔진>` 통째 삭제를 막는다
|
|
# (정상 경로는 항상 `output/<엔진>/<작업>` 이라 2단계 아래다).
|
|
if root not in job_dir.parents or job_dir.parent == root:
|
|
logger.warning(f"[ssul {content_id}] output 밖 경로라 정리 생략: {job_dir}")
|
|
return
|
|
task_service.cleanup_job_dir(job_dir)
|
|
except Exception as e:
|
|
logger.warning(f"[ssul {content_id}] 실패 정리 실패: {type(e).__name__}: {e}")
|