Files
tasteby/backend/api/routes/videos.py
joungmin 54d21afd52 Add food tag remap feature and show menu tags in restaurant cards
- LLM extraction prompt: foods_mentioned max 10, Korean only, prioritized
- New /remap-foods API endpoint for bulk LLM re-extraction
- Admin UI: "메뉴태그 재생성" button with SSE progress bar
- Backend: attach foods_mentioned to restaurant list API response
- Restaurant cards: display food tags (orange, max 5 visible)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 11:21:05 +09:00

947 lines
37 KiB
Python

"""Video API routes."""
from __future__ import annotations
import asyncio
import json as _json
import logging
import random
import time
from concurrent.futures import ThreadPoolExecutor
from fastapi import APIRouter, Depends, Query
from fastapi.responses import StreamingResponse
from api.deps import get_admin_user
from core.db import conn
from core.pipeline import process_pending
from core import cache
logger = logging.getLogger(__name__)
router = APIRouter()
_executor = ThreadPoolExecutor(max_workers=4)
@router.get("")
def list_videos(
status: str | None = None,
):
conditions = []
params: dict = {}
if status:
conditions.append("v.status = :st")
params["st"] = status
where = ("WHERE " + " AND ".join(conditions)) if conditions else ""
sql = f"""
SELECT v.id, v.video_id, v.title, v.url, v.status,
v.published_at, c.channel_name,
CASE WHEN v.transcript_text IS NOT NULL AND dbms_lob.getlength(v.transcript_text) > 0 THEN 1 ELSE 0 END as has_transcript,
CASE WHEN v.llm_raw_response IS NOT NULL AND dbms_lob.getlength(v.llm_raw_response) > 0 THEN 1 ELSE 0 END as has_llm,
(SELECT COUNT(*) FROM video_restaurants vr WHERE vr.video_id = v.id) as restaurant_count,
(SELECT COUNT(*) FROM video_restaurants vr JOIN restaurants r ON r.id = vr.restaurant_id WHERE vr.video_id = v.id AND r.google_place_id IS NOT NULL) as matched_count
FROM videos v
JOIN channels c ON c.id = v.channel_id
{where}
ORDER BY v.published_at DESC NULLS LAST
"""
with conn() as c:
cur = c.cursor()
cur.execute(sql, params)
cols = [d[0].lower() for d in cur.description]
rows = cur.fetchall()
results = []
for row in rows:
d = dict(zip(cols, row))
if d.get("published_at"):
d["published_at"] = d["published_at"].isoformat()
d["has_transcript"] = bool(d.get("has_transcript"))
d["has_llm"] = bool(d.get("has_llm"))
d["restaurant_count"] = d.get("restaurant_count", 0)
d["matched_count"] = d.get("matched_count", 0)
results.append(d)
return results
def _get_unprocessed_videos() -> list[dict]:
"""Get videos that have transcripts but no LLM extraction."""
sql = """
SELECT v.id, v.video_id, v.title, v.url, v.transcript_text
FROM videos v
WHERE v.transcript_text IS NOT NULL
AND dbms_lob.getlength(v.transcript_text) > 0
AND (v.llm_raw_response IS NULL OR dbms_lob.getlength(v.llm_raw_response) = 0)
AND v.status != 'skip'
ORDER BY v.published_at DESC
"""
with conn() as c:
cur = c.cursor()
cur.execute(sql)
rows = cur.fetchall()
result = []
for r in rows:
transcript = r[4]
if hasattr(transcript, "read"):
transcript = transcript.read()
result.append({
"id": r[0], "video_id": r[1], "title": r[2], "url": r[3],
"transcript": transcript,
})
return result
@router.get("/bulk-extract/pending")
def bulk_extract_pending_count():
"""Get count of videos pending LLM extraction."""
videos = _get_unprocessed_videos()
return {"count": len(videos), "videos": [{"id": v["id"], "title": v["title"]} for v in videos]}
@router.post("/bulk-extract")
def bulk_extract(_admin: dict = Depends(get_admin_user)):
"""Process all unextracted videos with random delays. Streams SSE progress."""
from core.pipeline import process_video_extract
videos = _get_unprocessed_videos()
def generate():
total = len(videos)
total_restaurants = 0
yield f"data: {_json.dumps({'type': 'start', 'total': total})}\n\n"
for i, v in enumerate(videos):
# Random delay (3-8 seconds) between requests to avoid bot detection
if i > 0:
delay = random.uniform(3.0, 8.0)
yield f"data: {_json.dumps({'type': 'wait', 'index': i, 'delay': round(delay, 1)})}\n\n"
time.sleep(delay)
yield f"data: {_json.dumps({'type': 'processing', 'index': i, 'title': v['title']})}\n\n"
try:
count = process_video_extract(
{"id": v["id"], "video_id": v["video_id"], "title": v["title"], "url": v["url"]},
v["transcript"],
)
total_restaurants += count
yield f"data: {_json.dumps({'type': 'done', 'index': i, 'title': v['title'], 'restaurants': count})}\n\n"
except Exception as e:
logger.error("Bulk extract error for %s: %s", v["video_id"], e)
yield f"data: {_json.dumps({'type': 'error', 'index': i, 'title': v['title'], 'message': str(e)})}\n\n"
if total_restaurants > 0:
cache.flush()
yield f"data: {_json.dumps({'type': 'complete', 'total': total, 'total_restaurants': total_restaurants})}\n\n"
return StreamingResponse(generate(), media_type="text/event-stream")
def _get_no_transcript_videos() -> list[dict]:
"""Get videos that have no transcript yet."""
sql = """
SELECT v.id, v.video_id, v.title
FROM videos v
WHERE (v.transcript_text IS NULL OR dbms_lob.getlength(v.transcript_text) = 0)
AND v.status != 'skip'
ORDER BY v.published_at DESC
"""
with conn() as c:
cur = c.cursor()
cur.execute(sql)
return [{"id": r[0], "video_id": r[1], "title": r[2]} for r in cur.fetchall()]
@router.get("/bulk-transcript/pending")
def bulk_transcript_pending_count():
"""Get count of videos without transcripts."""
videos = _get_no_transcript_videos()
return {"count": len(videos), "videos": [{"id": v["id"], "title": v["title"]} for v in videos]}
@router.post("/bulk-transcript")
def bulk_transcript(_admin: dict = Depends(get_admin_user)):
"""Fetch transcripts for all videos missing them. Streams SSE progress."""
from core.youtube import get_transcript
videos = _get_no_transcript_videos()
def generate():
total = len(videos)
success = 0
yield f"data: {_json.dumps({'type': 'start', 'total': total})}\n\n"
for i, v in enumerate(videos):
# Random delay (5-15 seconds) to avoid bot detection — longer than LLM since this hits YouTube
if i > 0:
delay = random.uniform(5.0, 15.0)
yield f"data: {_json.dumps({'type': 'wait', 'index': i, 'delay': round(delay, 1)})}\n\n"
time.sleep(delay)
yield f"data: {_json.dumps({'type': 'processing', 'index': i, 'title': v['title']})}\n\n"
try:
transcript, source = get_transcript(v["video_id"])
if transcript:
with conn() as c:
cur = c.cursor()
cur.execute(
"UPDATE videos SET transcript_text = :txt WHERE id = :vid",
{"txt": transcript, "vid": v["id"]},
)
success += 1
yield f"data: {_json.dumps({'type': 'done', 'index': i, 'title': v['title'], 'source': source, 'length': len(transcript)})}\n\n"
else:
yield f"data: {_json.dumps({'type': 'error', 'index': i, 'title': v['title'], 'message': 'No transcript available'})}\n\n"
except Exception as e:
logger.error("Bulk transcript error for %s: %s", v["video_id"], e)
yield f"data: {_json.dumps({'type': 'error', 'index': i, 'title': v['title'], 'message': str(e)})}\n\n"
if success > 0:
cache.flush()
yield f"data: {_json.dumps({'type': 'complete', 'total': total, 'success': success})}\n\n"
return StreamingResponse(generate(), media_type="text/event-stream")
@router.post("/remap-cuisine")
def remap_cuisine(_admin: dict = Depends(get_admin_user)):
"""Remap all restaurant cuisine_type using LLM. Streams SSE progress."""
from core.cuisine import build_remap_prompt, CUISINE_TYPES, VALID_PREFIXES
from core.extractor import _llm, _parse_json
from core.db import conn as db_conn
BATCH = 20 # restaurants per LLM call (smaller for better accuracy)
def _apply_batch(batch: list[dict], valid_set: set[str]) -> tuple[int, list[dict]]:
"""Run LLM on a batch. Returns (updated_count, missed_items)."""
prompt = build_remap_prompt(batch)
raw = _llm(prompt, max_tokens=4096)
result = _parse_json(raw)
if not isinstance(result, list):
result = []
result_map = {}
for item in result:
rid = item.get("id")
new_type = item.get("cuisine_type")
if rid and new_type:
result_map[rid] = new_type
updated = 0
missed = []
for r in batch:
rid = r["id"]
new_type = result_map.get(rid)
if not new_type:
missed.append(r)
continue
# Accept if exact match or valid prefix
if new_type not in valid_set and not new_type.startswith(VALID_PREFIXES):
missed.append(r)
continue
with db_conn() as c:
c.cursor().execute(
"UPDATE restaurants SET cuisine_type = :ct WHERE id = :id",
{"ct": new_type, "id": rid},
)
updated += 1
return updated, missed
def generate():
sql = """
SELECT r.id, r.name, r.cuisine_type,
(SELECT LISTAGG(vr.foods_mentioned, '|') WITHIN GROUP (ORDER BY vr.id)
FROM video_restaurants vr WHERE vr.restaurant_id = r.id) AS foods
FROM restaurants r
WHERE EXISTS (SELECT 1 FROM video_restaurants vr2 WHERE vr2.restaurant_id = r.id)
ORDER BY r.name
"""
with db_conn() as c:
cur = c.cursor()
cur.execute(sql)
rows = []
for row in cur.fetchall():
foods_raw = row[3].read() if hasattr(row[3], "read") else row[3]
rows.append({"id": row[0], "name": row[1], "cuisine_type": row[2], "foods_mentioned": foods_raw})
total = len(rows)
yield f"data: {_json.dumps({'type': 'start', 'total': total})}\n\n"
valid_set = set(CUISINE_TYPES)
updated = 0
all_missed: list[dict] = []
# Pass 1: process all in batches
for i in range(0, total, BATCH):
batch = rows[i : i + BATCH]
yield f"data: {_json.dumps({'type': 'processing', 'current': min(i + BATCH, total), 'total': total, 'pass': 1})}\n\n"
try:
cnt, missed = _apply_batch(batch, valid_set)
updated += cnt
all_missed.extend(missed)
yield f"data: {_json.dumps({'type': 'batch_done', 'current': min(i + BATCH, total), 'total': total, 'updated': updated, 'missed': len(all_missed)})}\n\n"
except Exception as e:
logger.error("Remap batch error at %d: %s", i, e, exc_info=True)
all_missed.extend(batch)
yield f"data: {_json.dumps({'type': 'error', 'message': str(e), 'current': i})}\n\n"
# Pass 2: retry missed items (smaller batches for accuracy)
if all_missed:
yield f"data: {_json.dumps({'type': 'retry', 'missed': len(all_missed)})}\n\n"
RETRY_BATCH = 10
for i in range(0, len(all_missed), RETRY_BATCH):
batch = all_missed[i : i + RETRY_BATCH]
try:
cnt, _ = _apply_batch(batch, valid_set)
updated += cnt
yield f"data: {_json.dumps({'type': 'batch_done', 'current': min(i + RETRY_BATCH, len(all_missed)), 'total': len(all_missed), 'updated': updated, 'pass': 2})}\n\n"
except Exception as e:
logger.error("Remap retry error at %d: %s", i, e, exc_info=True)
cache.flush()
yield f"data: {_json.dumps({'type': 'complete', 'total': total, 'updated': updated})}\n\n"
return StreamingResponse(generate(), media_type="text/event-stream")
@router.post("/remap-foods")
def remap_foods(_admin: dict = Depends(get_admin_user)):
"""Re-extract foods_mentioned for all video_restaurants using LLM. SSE progress."""
from core.extractor import _llm, _parse_json
BATCH = 15
_FOODS_PROMPT = """\
아래 식당들의 대표 메뉴 태그를 다시 만들어주세요.
규칙:
- 반드시 한글로 작성
- 각 식당당 최대 10개의 대표 메뉴/음식 태그
- 우선순위: 시그니처 메뉴 > 자주 언급된 메뉴 > 일반 메뉴
- 너무 일반적인 태그(밥, 반찬 등)는 제외
- 모든 식당에 대해 빠짐없이 결과 반환 (총 {count}개)
- JSON 배열만 반환, 설명 없음
- 형식: [{{"id": "식당ID", "foods": ["메뉴1", "메뉴2", ...]}}]
식당 목록:
{restaurants}
JSON 배열:"""
def _apply_batch(batch: list[dict]) -> tuple[int, list[dict]]:
items = [{"id": b["id"], "name": b["name"], "current_foods": b["foods"], "cuisine_type": b.get("cuisine_type")} for b in batch]
prompt = _FOODS_PROMPT.format(
restaurants=_json.dumps(items, ensure_ascii=False),
count=len(items),
)
raw = _llm(prompt, max_tokens=4096)
results = _parse_json(raw)
if not isinstance(results, list):
return 0, batch
result_map: dict[str, list[str]] = {}
for item in results:
if isinstance(item, dict) and "id" in item and "foods" in item:
foods = item["foods"]
if isinstance(foods, list):
# Ensure Korean, max 10
foods = [str(f) for f in foods[:10]]
result_map[item["id"]] = foods
updated = 0
missed = []
for b in batch:
bid = b["id"]
new_foods = result_map.get(bid)
if new_foods is None:
missed.append(b)
continue
with db_conn() as c:
c.cursor().execute(
"UPDATE video_restaurants SET foods_mentioned = :foods WHERE id = :id",
{"foods": _json.dumps(new_foods, ensure_ascii=False), "id": bid},
)
updated += 1
return updated, missed
def generate():
# Fetch all video_restaurants with context
sql = """
SELECT vr.id, r.name, r.cuisine_type,
vr.foods_mentioned, v.title
FROM video_restaurants vr
JOIN restaurants r ON r.id = vr.restaurant_id
JOIN videos v ON v.id = vr.video_id
ORDER BY r.name
"""
with db_conn() as c:
cur = c.cursor()
cur.execute(sql)
rows = []
for row in cur.fetchall():
foods_raw = row[3].read() if hasattr(row[3], "read") else (row[3] or "[]")
try:
foods = _json.loads(foods_raw) if isinstance(foods_raw, str) else foods_raw
except Exception:
foods = []
rows.append({
"id": row[0],
"name": row[1],
"cuisine_type": row[2],
"foods": foods if isinstance(foods, list) else [],
"video_title": row[4],
})
total = len(rows)
yield f"data: {_json.dumps({'type': 'start', 'total': total})}\n\n"
updated = 0
all_missed: list[dict] = []
for i in range(0, total, BATCH):
batch = rows[i : i + BATCH]
yield f"data: {_json.dumps({'type': 'processing', 'current': min(i + BATCH, total), 'total': total})}\n\n"
try:
cnt, missed = _apply_batch(batch)
updated += cnt
all_missed.extend(missed)
yield f"data: {_json.dumps({'type': 'batch_done', 'current': min(i + BATCH, total), 'total': total, 'updated': updated})}\n\n"
except Exception as e:
logger.error("Remap foods error at %d: %s", i, e, exc_info=True)
all_missed.extend(batch)
yield f"data: {_json.dumps({'type': 'error', 'message': str(e), 'current': i})}\n\n"
# Retry missed
if all_missed:
yield f"data: {_json.dumps({'type': 'retry', 'missed': len(all_missed)})}\n\n"
for i in range(0, len(all_missed), 10):
batch = all_missed[i : i + 10]
try:
cnt, _ = _apply_batch(batch)
updated += cnt
except Exception as e:
logger.error("Remap foods retry error: %s", e)
cache.flush()
yield f"data: {_json.dumps({'type': 'complete', 'total': total, 'updated': updated})}\n\n"
return StreamingResponse(generate(), media_type="text/event-stream")
@router.post("/rebuild-vectors")
def rebuild_vectors(_admin: dict = Depends(get_admin_user)):
"""Rebuild all restaurant vector embeddings. Streams SSE progress."""
from core import vector
def generate():
yield f"data: {_json.dumps({'type': 'start'})}\n\n"
try:
for progress in vector.rebuild_all_vectors():
yield f"data: {_json.dumps({'type': progress.get('status', 'progress'), **progress})}\n\n"
cache.flush()
except Exception as e:
logger.error("Rebuild vectors error: %s", e, exc_info=True)
yield f"data: {_json.dumps({'type': 'error', 'message': str(e)})}\n\n"
return StreamingResponse(generate(), media_type="text/event-stream")
@router.get("/extract/prompt")
def get_extract_prompt():
"""Get the current LLM extraction prompt template."""
from core.extractor import _EXTRACT_PROMPT
return {"prompt": _EXTRACT_PROMPT}
def _do_process(limit: int):
result = process_pending(limit)
if result > 0:
cache.flush()
return {"restaurants_extracted": result}
@router.post("/process")
async def trigger_processing(limit: int = Query(5, le=20), _admin: dict = Depends(get_admin_user)):
"""Manually trigger processing of pending videos (non-blocking)."""
loop = asyncio.get_event_loop()
return await loop.run_in_executor(_executor, _do_process, limit)
@router.get("/{video_db_id}")
def get_video_detail(video_db_id: str):
"""Get video detail including transcript and extracted restaurants."""
from fastapi import HTTPException
import json
with conn() as c:
cur = c.cursor()
cur.execute("""
SELECT v.id, v.video_id, v.title, v.url, v.status,
v.published_at, v.transcript_text,
c.channel_name
FROM videos v
JOIN channels c ON c.id = v.channel_id
WHERE v.id = :vid
""", {"vid": video_db_id})
row = cur.fetchone()
if not row:
raise HTTPException(404, "Video not found")
transcript = row[6]
if hasattr(transcript, "read"):
transcript = transcript.read()
# Get extracted restaurants for this video
cur.execute("""
SELECT r.id, r.name, r.address, r.cuisine_type, r.price_range, r.region,
vr.foods_mentioned, vr.evaluation, vr.guests,
r.google_place_id, r.latitude, r.longitude
FROM video_restaurants vr
JOIN restaurants r ON r.id = vr.restaurant_id
WHERE vr.video_id = :vid
""", {"vid": video_db_id})
restaurants = []
for rr in cur.fetchall():
foods_raw = rr[6].read() if hasattr(rr[6], "read") else rr[6]
eval_raw = rr[7].read() if hasattr(rr[7], "read") else rr[7]
guests_raw = rr[8].read() if hasattr(rr[8], "read") else rr[8]
def parse_json(val, default):
if val is None:
return default
if isinstance(val, (list, dict)):
return val
try:
return json.loads(val)
except (json.JSONDecodeError, ValueError):
return default
restaurants.append({
"restaurant_id": rr[0],
"name": rr[1],
"address": rr[2],
"cuisine_type": rr[3],
"price_range": rr[4],
"region": rr[5],
"foods_mentioned": parse_json(foods_raw, []),
"evaluation": parse_json(eval_raw, {}),
"guests": parse_json(guests_raw, []),
"google_place_id": rr[9],
"has_location": rr[10] is not None and rr[11] is not None,
})
return {
"id": row[0],
"video_id": row[1],
"title": row[2],
"url": row[3],
"status": row[4],
"published_at": row[5].isoformat() if row[5] else None,
"transcript": transcript,
"channel_name": row[7],
"restaurants": restaurants,
}
def _do_fetch_transcript(video_db_id: str, mode: str):
from core.youtube import get_transcript
with conn() as c:
cur = c.cursor()
cur.execute("SELECT video_id FROM videos WHERE id = :vid", {"vid": video_db_id})
row = cur.fetchone()
if not row:
return {"error": 404, "detail": "Video not found"}
video_id = row[0]
transcript, source = get_transcript(video_id, mode=mode)
if not transcript:
return {"error": 422, "detail": "Transcript unavailable for this video"}
with conn() as c:
cur = c.cursor()
cur.execute(
"UPDATE videos SET transcript_text = :txt WHERE id = :vid",
{"txt": transcript, "vid": video_db_id},
)
cache.flush()
return {"ok": True, "length": len(transcript), "source": source}
@router.post("/{video_db_id}/fetch-transcript")
async def fetch_transcript(video_db_id: str, mode: str = Query("auto"), _admin: dict = Depends(get_admin_user)):
"""Fetch and save transcript for a video (non-blocking)."""
from fastapi import HTTPException
if mode not in ("auto", "manual", "generated"):
raise HTTPException(400, "mode must be auto, manual, or generated")
loop = asyncio.get_event_loop()
result = await loop.run_in_executor(_executor, _do_fetch_transcript, video_db_id, mode)
if "error" in result:
raise HTTPException(result["error"], result["detail"])
return result
def _do_extract(video_db_id: str, custom_prompt: str | None):
from core.pipeline import process_video_extract
with conn() as c:
cur = c.cursor()
cur.execute(
"SELECT id, video_id, title, url, transcript_text FROM videos WHERE id = :vid",
{"vid": video_db_id},
)
row = cur.fetchone()
if not row:
return {"error": 404, "detail": "Video not found"}
transcript = row[4]
if hasattr(transcript, "read"):
transcript = transcript.read()
if not transcript:
return {"error": 422, "detail": "No transcript available for this video"}
count = process_video_extract(
{"id": row[0], "video_id": row[1], "title": row[2], "url": row[3]},
transcript,
custom_prompt=custom_prompt,
)
cache.flush()
return {"ok": True, "restaurants_extracted": count}
@router.post("/{video_db_id}/extract")
async def extract_restaurants_from_video(video_db_id: str, body: dict = None, _admin: dict = Depends(get_admin_user)):
"""Run LLM extraction on an existing transcript (non-blocking)."""
from fastapi import HTTPException
custom_prompt = body.get("prompt") if body else None
loop = asyncio.get_event_loop()
result = await loop.run_in_executor(_executor, _do_extract, video_db_id, custom_prompt)
if "error" in result:
raise HTTPException(result["error"], result["detail"])
return result
@router.post("/{video_db_id}/skip")
def skip_video(video_db_id: str, _admin: dict = Depends(get_admin_user)):
"""Mark a video as skipped."""
from fastapi import HTTPException
with conn() as c:
cur = c.cursor()
cur.execute(
"UPDATE videos SET status = 'skip' WHERE id = :vid",
{"vid": video_db_id},
)
if cur.rowcount == 0:
raise HTTPException(404, "Video not found")
cache.flush()
return {"ok": True}
@router.delete("/{video_db_id}")
def delete_video(video_db_id: str, _admin: dict = Depends(get_admin_user)):
"""Delete a video and its related data."""
from core.db import conn as get_conn
with get_conn() as c:
cur = c.cursor()
# Delete vector embeddings for restaurants only linked to this video
cur.execute("""
DELETE FROM restaurant_vectors
WHERE restaurant_id IN (
SELECT vr.restaurant_id FROM video_restaurants vr
WHERE vr.video_id = :vid
AND NOT EXISTS (
SELECT 1 FROM video_restaurants vr2
WHERE vr2.restaurant_id = vr.restaurant_id
AND vr2.video_id != :vid
)
)
""", {"vid": video_db_id})
# Delete reviews for restaurants only linked to this video
cur.execute("""
DELETE FROM user_reviews
WHERE restaurant_id IN (
SELECT vr.restaurant_id FROM video_restaurants vr
WHERE vr.video_id = :vid
AND NOT EXISTS (
SELECT 1 FROM video_restaurants vr2
WHERE vr2.restaurant_id = vr.restaurant_id
AND vr2.video_id != :vid
)
)
""", {"vid": video_db_id})
# Delete restaurants only linked to this video
cur.execute("""
DELETE FROM restaurants
WHERE id IN (
SELECT vr.restaurant_id FROM video_restaurants vr
WHERE vr.video_id = :vid
AND NOT EXISTS (
SELECT 1 FROM video_restaurants vr2
WHERE vr2.restaurant_id = vr.restaurant_id
AND vr2.video_id != :vid
)
)
""", {"vid": video_db_id})
# Delete video-restaurant links
cur.execute("DELETE FROM video_restaurants WHERE video_id = :vid", {"vid": video_db_id})
# Delete the video
cur.execute("DELETE FROM videos WHERE id = :vid", {"vid": video_db_id})
if cur.rowcount == 0:
from fastapi import HTTPException
raise HTTPException(404, "Video not found")
cache.flush()
return {"ok": True}
@router.put("/{video_db_id}")
def update_video(video_db_id: str, body: dict, _admin: dict = Depends(get_admin_user)):
"""Update video title."""
from fastapi import HTTPException
title = body.get("title")
if not title:
raise HTTPException(400, "title is required")
with conn() as c:
cur = c.cursor()
cur.execute(
"UPDATE videos SET title = :title WHERE id = :vid",
{"title": title, "vid": video_db_id},
)
if cur.rowcount == 0:
raise HTTPException(404, "Video not found")
cache.flush()
return {"ok": True}
@router.delete("/{video_db_id}/restaurants/{restaurant_id}")
def delete_video_restaurant(video_db_id: str, restaurant_id: str, _admin: dict = Depends(get_admin_user)):
"""Delete a video-restaurant mapping. Also cleans up orphaned restaurant."""
from fastapi import HTTPException
with conn() as c:
cur = c.cursor()
cur.execute(
"DELETE FROM video_restaurants WHERE video_id = :vid AND restaurant_id = :rid",
{"vid": video_db_id, "rid": restaurant_id},
)
if cur.rowcount == 0:
raise HTTPException(404, "Mapping not found")
# Clean up orphaned restaurant (no other video links)
cur.execute("""
DELETE FROM restaurant_vectors WHERE restaurant_id = :rid
AND NOT EXISTS (SELECT 1 FROM video_restaurants WHERE restaurant_id = :rid)
""", {"rid": restaurant_id})
cur.execute("""
DELETE FROM user_reviews WHERE restaurant_id = :rid
AND NOT EXISTS (SELECT 1 FROM video_restaurants WHERE restaurant_id = :rid)
""", {"rid": restaurant_id})
cur.execute("""
DELETE FROM restaurants WHERE id = :rid
AND NOT EXISTS (SELECT 1 FROM video_restaurants WHERE restaurant_id = :rid)
""", {"rid": restaurant_id})
cache.flush()
return {"ok": True}
@router.post("/{video_db_id}/restaurants/manual")
def add_manual_restaurant(video_db_id: str, body: dict, _admin: dict = Depends(get_admin_user)):
"""Manually add a restaurant and link it to a video."""
from fastapi import HTTPException
from core import restaurant as rest_mod
from core.geocoding import geocode_restaurant
name = body.get("name", "").strip()
if not name:
raise HTTPException(400, "Restaurant name is required")
address = body.get("address", "").strip() or None
region = body.get("region", "").strip() or None
cuisine_type = body.get("cuisine_type", "").strip() or None
price_range = body.get("price_range", "").strip() or None
foods = body.get("foods_mentioned", [])
evaluation = body.get("evaluation", "").strip() or None
guests = body.get("guests", [])
# Geocode to get lat/lng and Google data
geo = geocode_restaurant(name, address or region or "")
if not geo:
raise HTTPException(400, f"'{name}' 위치를 찾을 수 없습니다. 주소를 입력해주세요.")
rid = rest_mod.upsert(
name=name,
address=geo.get("formatted_address") or address,
region=region,
latitude=geo["latitude"],
longitude=geo["longitude"],
cuisine_type=cuisine_type,
price_range=price_range,
google_place_id=geo.get("google_place_id"),
phone=geo.get("phone"),
website=geo.get("website"),
business_status=geo.get("business_status"),
rating=geo.get("rating"),
rating_count=geo.get("rating_count"),
)
link_id = rest_mod.link_video_restaurant(
video_db_id=video_db_id,
restaurant_id=rid,
foods=foods if isinstance(foods, list) else [],
evaluation=evaluation,
guests=guests if isinstance(guests, list) else [],
)
cache.flush()
return {"ok": True, "restaurant_id": rid, "link_id": link_id}
@router.put("/{video_db_id}/restaurants/{restaurant_id}")
def update_video_restaurant(video_db_id: str, restaurant_id: str, body: dict, _admin: dict = Depends(get_admin_user)):
"""Update restaurant info linked to a video.
If name changed, re-geocode and remap to a new restaurant record.
"""
from fastapi import HTTPException
import json as _json
# Check if name changed — need to remap
new_name = body.get("name", "").strip() if "name" in body else None
name_changed = False
active_rid = restaurant_id
if new_name:
with conn() as c:
cur = c.cursor()
cur.execute("SELECT name FROM restaurants WHERE id = :rid", {"rid": restaurant_id})
row = cur.fetchone()
old_name = row[0] if row else ""
if old_name != new_name:
name_changed = True
from core import restaurant as rest_mod
from core.geocoding import geocode_restaurant
address = (body.get("address") or "").strip() or (body.get("region") or "").strip() or ""
geo = geocode_restaurant(new_name, address)
if not geo:
# Geocode failed — just rename in place without remapping
with conn() as c:
cur = c.cursor()
cur.execute("UPDATE restaurants SET name = :name, updated_at = SYSTIMESTAMP WHERE id = :rid",
{"name": new_name, "rid": restaurant_id})
else:
new_rid = rest_mod.upsert(
name=new_name,
address=geo.get("formatted_address") or body.get("address"),
region=body.get("region"),
latitude=geo["latitude"],
longitude=geo["longitude"],
cuisine_type=body.get("cuisine_type"),
price_range=body.get("price_range"),
google_place_id=geo.get("google_place_id"),
phone=geo.get("phone"),
website=geo.get("website"),
business_status=geo.get("business_status"),
rating=geo.get("rating"),
rating_count=geo.get("rating_count"),
)
# Read existing mapping data, delete old, create new
with conn() as c:
cur = c.cursor()
cur.execute(
"SELECT foods_mentioned, evaluation, guests FROM video_restaurants WHERE video_id = :vid AND restaurant_id = :rid",
{"vid": video_db_id, "rid": restaurant_id},
)
old_vr = cur.fetchone()
cur.execute(
"DELETE FROM video_restaurants WHERE video_id = :vid AND restaurant_id = :rid",
{"vid": video_db_id, "rid": restaurant_id},
)
def _parse(val, default):
if val is None:
return default
if hasattr(val, "read"):
val = val.read()
if isinstance(val, (list, dict)):
return val
try:
return _json.loads(val)
except Exception:
return default
old_foods = _parse(old_vr[0], []) if old_vr else []
old_eval = _parse(old_vr[1], {}) if old_vr else {}
old_guests = _parse(old_vr[2], []) if old_vr else []
foods = body.get("foods_mentioned", old_foods)
evaluation = body.get("evaluation", old_eval)
guests = body.get("guests", old_guests)
eval_text = evaluation.get("text", "") if isinstance(evaluation, dict) else str(evaluation or "")
rest_mod.link_video_restaurant(
video_db_id=video_db_id,
restaurant_id=new_rid,
foods=foods if isinstance(foods, list) else [],
evaluation=eval_text or None,
guests=guests if isinstance(guests, list) else [],
)
active_rid = new_rid
# 기존 식당이 다른 영상 매핑이 없으면 고아 → 삭제
if new_rid != restaurant_id:
with conn() as c:
cur = c.cursor()
cur.execute(
"SELECT COUNT(*) FROM video_restaurants WHERE restaurant_id = :rid",
{"rid": restaurant_id},
)
remaining = cur.fetchone()[0]
if remaining == 0:
cur.execute("DELETE FROM restaurant_vectors WHERE restaurant_id = :rid", {"rid": restaurant_id})
cur.execute("DELETE FROM user_reviews WHERE restaurant_id = :rid", {"rid": restaurant_id})
cur.execute("DELETE FROM user_favorites WHERE restaurant_id = :rid", {"rid": restaurant_id})
cur.execute("DELETE FROM restaurants WHERE id = :rid", {"rid": restaurant_id})
# Update remaining fields in place (skip name if already remapped)
if not name_changed:
with conn() as c:
cur = c.cursor()
r_sets = []
r_params: dict = {"rid": active_rid}
for field in ("address", "region", "cuisine_type", "price_range"):
if field in body:
r_sets.append(f"{field} = :{field}")
r_params[field] = body[field]
if r_sets:
r_sets.append("updated_at = SYSTIMESTAMP")
sql = f"UPDATE restaurants SET {', '.join(r_sets)} WHERE id = :rid"
cur.execute(sql, r_params)
vr_params: dict = {"vid": video_db_id, "rid": active_rid}
vr_sets = []
for field in ("foods_mentioned", "evaluation", "guests"):
if field in body:
vr_sets.append(f"{field} = :{field}")
val = body[field]
vr_params[field] = _json.dumps(val, ensure_ascii=False) if isinstance(val, (list, dict)) else val
if vr_sets:
sql = f"UPDATE video_restaurants SET {', '.join(vr_sets)} WHERE video_id = :vid AND restaurant_id = :rid"
cur.execute(sql, vr_params)
cache.flush()
result: dict = {"ok": True}
if name_changed:
result["remapped"] = active_rid != restaurant_id
if active_rid != restaurant_id:
result["new_restaurant_id"] = active_rid
return result