feat(music-lab): video_pipelines 4 컬럼 추가 + compile_jobs JOIN
- _add_column_if_missing 헬퍼 추가 (idempotent ALTER TABLE) - video_pipelines에 compile_job_id, visual_style, background_mode, background_keyword 컬럼 추가 - track_id를 nullable로 변경 (compile_job_id 입력 모드 지원) - create_pipeline에 compile_job_id XOR track_id 검증 추가 - get_pipeline / list_pipelines에 compile_jobs LEFT JOIN — compile_title 노출 Task 1 of 17: Essential Mix pipeline DB migration Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,85 @@ def _conn() -> sqlite3.Connection:
|
||||
return conn
|
||||
|
||||
|
||||
def _add_column_if_missing(cursor, table: str, column: str, ddl: str) -> None:
|
||||
"""SQLite-safe ALTER TABLE ADD COLUMN — idempotent.
|
||||
|
||||
SQLite의 ALTER TABLE은 컬럼 존재 시 에러. PRAGMA로 미리 확인.
|
||||
"""
|
||||
cursor.execute(f"PRAGMA table_info({table})")
|
||||
existing = {row[1] for row in cursor.fetchall()}
|
||||
if column not in existing:
|
||||
cursor.execute(f"ALTER TABLE {table} ADD COLUMN {column} {ddl}")
|
||||
|
||||
|
||||
def _is_column_not_null(cursor, table: str, column: str) -> bool:
|
||||
"""PRAGMA table_info row format: (cid, name, type, notnull, dflt_value, pk)."""
|
||||
cursor.execute(f"PRAGMA table_info({table})")
|
||||
for row in cursor.fetchall():
|
||||
if row[1] == column:
|
||||
return row[3] == 1
|
||||
return False
|
||||
|
||||
|
||||
def _relax_video_pipelines_track_id_nullable(cursor) -> None:
|
||||
"""track_id NOT NULL → NULL (compile_job_id 만 있는 pipeline 지원).
|
||||
|
||||
SQLite는 ALTER COLUMN을 지원하지 않아 표준 패턴 — 새 테이블 생성 → 데이터 복사 → 교체.
|
||||
Idempotent: 이미 NULL이면 no-op.
|
||||
"""
|
||||
if not _is_column_not_null(cursor, "video_pipelines", "track_id"):
|
||||
return # already nullable
|
||||
|
||||
# 새 컬럼 4개도 함께 포함된 최종 스키마로 새 테이블 생성
|
||||
cursor.execute("""
|
||||
CREATE TABLE video_pipelines_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
track_id INTEGER,
|
||||
state TEXT NOT NULL DEFAULT 'created',
|
||||
state_started_at TEXT NOT NULL,
|
||||
cover_url TEXT,
|
||||
video_url TEXT,
|
||||
thumbnail_url TEXT,
|
||||
metadata_json TEXT,
|
||||
review_json TEXT,
|
||||
youtube_video_id TEXT,
|
||||
feedback_count_per_step TEXT NOT NULL DEFAULT '{}',
|
||||
last_telegram_msg_ids TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
cancelled_at TEXT,
|
||||
failed_reason TEXT,
|
||||
compile_job_id INTEGER,
|
||||
visual_style TEXT NOT NULL DEFAULT 'essential',
|
||||
background_mode TEXT NOT NULL DEFAULT 'static',
|
||||
background_keyword TEXT
|
||||
)
|
||||
""")
|
||||
|
||||
# 기존 컬럼 모두 명시적으로 SELECT (새 컬럼은 default로 채워짐)
|
||||
cursor.execute("""
|
||||
INSERT INTO video_pipelines_new
|
||||
(id, track_id, state, state_started_at, cover_url, video_url,
|
||||
thumbnail_url, metadata_json, review_json, youtube_video_id,
|
||||
feedback_count_per_step, last_telegram_msg_ids,
|
||||
created_at, updated_at, cancelled_at, failed_reason,
|
||||
compile_job_id, visual_style, background_mode, background_keyword)
|
||||
SELECT
|
||||
id, track_id, state, state_started_at, cover_url, video_url,
|
||||
thumbnail_url, metadata_json, review_json, youtube_video_id,
|
||||
feedback_count_per_step, last_telegram_msg_ids,
|
||||
created_at, updated_at, cancelled_at, failed_reason,
|
||||
COALESCE(compile_job_id, NULL),
|
||||
COALESCE(visual_style, 'essential'),
|
||||
COALESCE(background_mode, 'static'),
|
||||
COALESCE(background_keyword, NULL)
|
||||
FROM video_pipelines
|
||||
""")
|
||||
|
||||
cursor.execute("DROP TABLE video_pipelines")
|
||||
cursor.execute("ALTER TABLE video_pipelines_new RENAME TO video_pipelines")
|
||||
|
||||
|
||||
def init_db() -> None:
|
||||
with _conn() as conn:
|
||||
conn.execute("""
|
||||
@@ -186,10 +265,11 @@ def init_db() -> None:
|
||||
""")
|
||||
|
||||
# ── YouTube pipeline 테이블 (5개) ─────────────────────────────────
|
||||
# track_id는 nullable: compile_job_id로 입력하는 essential mix 모드 지원
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS video_pipelines (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
track_id INTEGER NOT NULL,
|
||||
track_id INTEGER,
|
||||
state TEXT NOT NULL DEFAULT 'created',
|
||||
state_started_at TEXT NOT NULL,
|
||||
cover_url TEXT,
|
||||
@@ -206,6 +286,13 @@ def init_db() -> None:
|
||||
failed_reason TEXT
|
||||
)
|
||||
""")
|
||||
# Migration for essential mix pipeline (task 2026-05-09)
|
||||
cur = conn.cursor()
|
||||
_add_column_if_missing(cur, "video_pipelines", "compile_job_id", "INTEGER")
|
||||
_add_column_if_missing(cur, "video_pipelines", "visual_style", "TEXT NOT NULL DEFAULT 'essential'")
|
||||
_add_column_if_missing(cur, "video_pipelines", "background_mode", "TEXT NOT NULL DEFAULT 'static'")
|
||||
_add_column_if_missing(cur, "video_pipelines", "background_keyword", "TEXT")
|
||||
_relax_video_pipelines_track_id_nullable(cur)
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS pipeline_jobs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
@@ -946,22 +1033,34 @@ def _parse_pipeline_row(row: sqlite3.Row) -> Dict[str, Any]:
|
||||
return d
|
||||
|
||||
|
||||
def create_pipeline(track_id: int) -> int:
|
||||
def create_pipeline(track_id: Optional[int] = None, *,
|
||||
compile_job_id: Optional[int] = None,
|
||||
visual_style: str = "essential",
|
||||
background_mode: str = "static",
|
||||
background_keyword: Optional[str] = None) -> int:
|
||||
"""track_id XOR compile_job_id 검증."""
|
||||
if (track_id is None) == (compile_job_id is None):
|
||||
raise ValueError("track_id와 compile_job_id 중 정확히 하나만 지정")
|
||||
with _conn() as conn:
|
||||
cur = conn.cursor()
|
||||
now = _now()
|
||||
cur = conn.execute("""
|
||||
INSERT INTO video_pipelines (track_id, state, state_started_at, created_at, updated_at)
|
||||
VALUES (?, 'created', ?, ?, ?)
|
||||
""", (track_id, now, now, now))
|
||||
cur.execute("""
|
||||
INSERT INTO video_pipelines
|
||||
(track_id, compile_job_id, visual_style, background_mode, background_keyword,
|
||||
state, state_started_at, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, 'created', ?, ?, ?)
|
||||
""", (track_id, compile_job_id, visual_style, background_mode,
|
||||
background_keyword, now, now, now))
|
||||
return cur.lastrowid
|
||||
|
||||
|
||||
def get_pipeline(pid: int) -> Optional[Dict[str, Any]]:
|
||||
with _conn() as conn:
|
||||
row = conn.execute("""
|
||||
SELECT vp.*, ml.title AS track_title
|
||||
SELECT vp.*, ml.title AS track_title, cj.title AS compile_title
|
||||
FROM video_pipelines vp
|
||||
LEFT JOIN music_library ml ON ml.id = vp.track_id
|
||||
LEFT JOIN compile_jobs cj ON cj.id = vp.compile_job_id
|
||||
WHERE vp.id = ?
|
||||
""", (pid,)).fetchone()
|
||||
if not row:
|
||||
@@ -991,9 +1090,10 @@ def update_pipeline_state(pid: int, state: str, **fields) -> None:
|
||||
|
||||
def list_pipelines(active_only: bool = False) -> List[Dict[str, Any]]:
|
||||
sql = """
|
||||
SELECT vp.*, ml.title AS track_title
|
||||
SELECT vp.*, ml.title AS track_title, cj.title AS compile_title
|
||||
FROM video_pipelines vp
|
||||
LEFT JOIN music_library ml ON ml.id = vp.track_id
|
||||
LEFT JOIN compile_jobs cj ON cj.id = vp.compile_job_id
|
||||
"""
|
||||
if active_only:
|
||||
sql += " WHERE vp.state NOT IN ('published','cancelled','failed','awaiting_manual')"
|
||||
|
||||
Reference in New Issue
Block a user