feat: VoiceScript STT+OCR 자막기능 수정

This commit is contained in:
root
2026-05-04 08:12:59 +09:00
parent b3805c2b0b
commit c3cb7a6e8f
4 changed files with 1371 additions and 886 deletions

View File

@@ -8,7 +8,7 @@ from fastapi.responses import FileResponse
from typing import List
from auth import (authenticate, create_access_token, init_users,
require_auth, require_admin, require_stt, require_ocr,
require_auth, require_admin, require_stt, require_ocr, require_subtitle,
list_users, create_user, update_user, delete_user)
from tasks import celery_app, transcribe_task, subtitle_pipeline_task
from ocr_tasks import ocr_task
@@ -24,26 +24,26 @@ OUTPUT_KEEP_SECS = int(os.getenv("OUTPUT_KEEP_HOURS", "48")) * 3600
DATA_DIR = Path(UPLOAD_DIR).parent
SETTINGS_FILE = DATA_DIR / "settings.json"
HISTORY_FILE = DATA_DIR / "history.json"
HISTORY_MAX = 300
HISTORY_MAX = 500
os.makedirs(UPLOAD_DIR, exist_ok=True)
os.makedirs(OUTPUT_DIR, exist_ok=True)
AUDIO_EXT = {"mp3","mp4","wav","m4a","ogg","flac","aac","wma","webm",
"mkv","avi","mov","ts","mts","m2ts","wmv","flv","h264","h265","hevc","264","265"}
"mkv","avi","mov","ts","mts","m2ts","wmv","flv","h264","h265","hevc","264","265","m4v"}
IMAGE_EXT = {"jpg","jpeg","png","bmp","tiff","tif","webp","gif"}
VIDEO_EXT = {"mp4","mkv","avi","mov","webm","ts","mts","m2ts","wmv","flv","h264","h265","hevc","264","265","m4v","3gp","rm","rmvb"}
_DEFAULT_SETTINGS = {
"stt_ollama_model":"","ocr_ollama_model":"granite3.2-vision:latest",
"cpu_threads":0,"stt_timeout":0,"ollama_timeout":600,
"cpu_threads":0,"stt_timeout":0,"ollama_timeout":600,"subtitle_timeout":600,
"openrouter_url":"https://openrouter.ai/api/v1",
"openrouter_api_key":"","openrouter_stt_model":"","openrouter_ocr_model":"",
"groq_api_key":"","openai_api_key":"","default_stt_engine":"local",
}
_hist_lock = threading.Lock()
# ── 설정 I/O ─────────────────────────────────────────────────
# ── 설정 I/O ─────────────────────────────────────────────────
def _load_settings() -> dict:
if not SETTINGS_FILE.exists(): return dict(_DEFAULT_SETTINGS)
with open(SETTINGS_FILE,"r",encoding="utf-8") as f: data=json.load(f)
@@ -54,8 +54,15 @@ def _save_settings(data:dict):
SETTINGS_FILE.parent.mkdir(parents=True,exist_ok=True)
with open(SETTINGS_FILE,"w",encoding="utf-8") as f: json.dump(data,f,ensure_ascii=False,indent=2)
def _mask(key:str)->str:
if not key: return ""
return key[:6]+"..."+(key[-4:] if len(key)>10 else "")
# ── 이력 I/O ─────────────────────────────────────────────────
def _keep(new_val:str, field:str, current:dict)->str:
return new_val.strip() if new_val.strip() else current.get(field,"")
# ── 이력 I/O ──────────────────────────────────────────────────
def _load_history()->list:
with _hist_lock:
if not HISTORY_FILE.exists(): return []
@@ -83,10 +90,10 @@ def _update_history_by_task(task_id:str, result:dict, success:bool, error_msg:st
with open(HISTORY_FILE,"r",encoding="utf-8") as f: history=json.load(f)
for h in history:
if h.get("task_id")!=task_id: continue
if h.get("status")!="processing": break
if h.get("status") not in ("processing","cancelled"): break
h["status"]="failed" if not success else "success"
if not success:
h["output"]={"error":error_msg[:300]}
h["output"]={"error":error_msg[:500]}
elif h["type"]=="stt":
text=result.get("text","")
h["output"]={
@@ -99,14 +106,17 @@ def _update_history_by_task(task_id:str, result:dict, success:bool, error_msg:st
"ollama_model":result.get("ollama_model",""),
"openrouter_used":result.get("openrouter_used",False),
"openrouter_model":result.get("openrouter_model",""),
"stt_engine":result.get("stt_engine","local"),
}
elif h["type"]=="subtitle":
h["output"]={
"detected_language":result.get("detected_language",""),
"duration_s":result.get("duration",0),
"segment_count":result.get("segment_count",0),
"stt_engine":result.get("stt_engine","local"),
"translated":result.get("translated",False),
"translate_to":result.get("translate_to",""),
"refine_model":result.get("refine_model",""),
"srt_orig":result.get("srt_orig",""),
"vtt_orig":result.get("vtt_orig",""),
"srt_trans":result.get("srt_trans",""),
@@ -163,7 +173,7 @@ def login(username:str=Form(...),password:str=Form(...)):
@app.get("/api/me")
def me(user:dict=Depends(require_auth)):
return {"username":user["username"],"role":user.get("role","user"),
"permissions":user.get("permissions",{"stt":False,"ocr":False})}
"permissions":user.get("permissions",{"stt":False,"ocr":False,"subtitle":False})}
# ════════════════════════════════════════════════════════════════
@@ -180,17 +190,72 @@ def system_info(user:dict=Depends(require_auth)):
"cpu_percent":psutil.cpu_percent(interval=0.3),
"cpu_threads_setting":s.get("cpu_threads",0),
"stt_timeout":s.get("stt_timeout",0),"ollama_timeout":s.get("ollama_timeout",600),
"subtitle_timeout":s.get("subtitle_timeout",600),
}
@app.get("/api/stt-engines")
def stt_engines(user:dict=Depends(require_auth)):
s=_load_settings()
return {
"local":{"available":True},
"groq":{"available":True,"key_set":bool(s.get("groq_api_key",""))},
"openai":{"available":True,"key_set":bool(s.get("openai_api_key",""))},
"default":s.get("default_stt_engine","local"),
}
# ════════════════════════════════════════════════════════════════
# STT 단일 / 배치
# 작업 상태 / 취소
# ════════════════════════════════════════════════════════════════
async def _dispatch_stt(request,files,use_ollama,ollama_model,use_openrouter,openrouter_model,user):
@app.get("/api/status/{task_id}")
def get_status(task_id:str, user:dict=Depends(require_auth)):
r=celery_app.AsyncResult(task_id)
if r.state=="PENDING": return {"state":"pending","progress":0,"message":"대기 중..."}
if r.state=="PROGRESS":
m=r.info or {}
return {"state":"progress","progress":m.get("progress",0),
"step":m.get("step",0),"step_msg":m.get("step_msg",""),
"message":m.get("message","처리 중...")}
if r.state=="SUCCESS":
_update_history_by_task(task_id,r.result or {},True)
return {"state":"success","progress":100,**(r.result or {})}
if r.state=="FAILURE":
_update_history_by_task(task_id,{},False,str(r.info))
return {"state":"failure","progress":0,"message":str(r.info)}
if r.state=="REVOKED":
return {"state":"cancelled","progress":0,"message":"작업이 취소되었습니다"}
return {"state":r.state.lower(),"progress":0}
@app.post("/api/cancel/{task_id}")
def cancel_task(task_id:str, user:dict=Depends(require_auth)):
"""작업 취소 (Celery revoke)"""
try:
celery_app.control.revoke(task_id, terminate=True, signal="SIGTERM")
# 이력에 취소 표시
with _hist_lock:
if HISTORY_FILE.exists():
with open(HISTORY_FILE,"r",encoding="utf-8") as f: history=json.load(f)
for h in history:
if h.get("task_id")==task_id and h.get("status")=="processing":
h["status"]="cancelled"
h["output"]={"error":"사용자가 취소했습니다"}
break
_write_history(history)
return {"ok":True,"message":"취소 요청 전송됨"}
except Exception as e:
return {"ok":False,"message":str(e)}
# ════════════════════════════════════════════════════════════════
# STT
# ════════════════════════════════════════════════════════════════
async def _dispatch_stt(request,files,use_ollama,ollama_model,use_openrouter,openrouter_model,
stt_engine,stt_language,user):
s=_load_settings()
_uo=use_ollama.lower()=="true"; _uor=use_openrouter.lower()=="true"
if _uo and not ollama_model.strip(): ollama_model=s.get("stt_ollama_model","")
if _uor and not openrouter_model.strip():openrouter_model=s.get("openrouter_stt_model","")
if not stt_engine: stt_engine=s.get("default_stt_engine","local")
results=[]
for file in files:
_check_size(request)
@@ -200,13 +265,18 @@ async def _dispatch_stt(request,files,use_ollama,ollama_model,use_openrouter,ope
file_id=str(uuid.uuid4())
save_path=os.path.join(UPLOAD_DIR,f"{file_id}.{ext}")
await _save_upload(file,save_path); file_size=os.path.getsize(save_path)
task=transcribe_task.delay(file_id,save_path,_uo,ollama_model,_uor,openrouter_model,
s.get("openrouter_url",""),s.get("openrouter_api_key",""))
task=transcribe_task.delay(
file_id,save_path,_uo,ollama_model,_uor,openrouter_model,
s.get("openrouter_url",""),s.get("openrouter_api_key",""),
stt_engine,s.get("groq_api_key",""),s.get("openai_api_key",""),stt_language or "",
)
append_history({"id":file_id,"task_id":task.id,"type":"stt","status":"processing",
"timestamp":datetime.now().strftime("%Y-%m-%d %H:%M:%S"),"username":user["username"],
"input":{"filename":file.filename,"size_bytes":file_size,"format":ext.upper()},
"settings":{"model":os.getenv("WHISPER_MODEL","medium"),"language":os.getenv("WHISPER_LANGUAGE","ko"),
"compute_type":os.getenv("WHISPER_COMPUTE_TYPE","int8"),"cpu_threads":s.get("cpu_threads",0),
"settings":{"model":os.getenv("WHISPER_MODEL","medium"),
"language":stt_language or os.getenv("WHISPER_LANGUAGE","ko"),
"compute_type":os.getenv("WHISPER_COMPUTE_TYPE","int8"),
"cpu_threads":s.get("cpu_threads",0),"stt_engine":stt_engine,
"use_ollama":_uo,"ollama_model":ollama_model if _uo else "",
"use_openrouter":_uor,"openrouter_model":openrouter_model if _uor else ""},
"output":None})
@@ -217,87 +287,79 @@ async def _dispatch_stt(request,files,use_ollama,ollama_model,use_openrouter,ope
async def transcribe(request:Request,file:UploadFile=File(...),
use_ollama:str=Form("false"),ollama_model:str=Form(""),
use_openrouter:str=Form("false"),openrouter_model:str=Form(""),
stt_engine:str=Form(""),stt_language:str=Form(""),
user:dict=Depends(require_stt)):
items=await _dispatch_stt(request,[file],use_ollama,ollama_model,use_openrouter,openrouter_model,user)
items=await _dispatch_stt(request,[file],use_ollama,ollama_model,use_openrouter,openrouter_model,stt_engine,stt_language,user)
return items[0]
@app.post("/api/transcribe/batch")
async def transcribe_batch(request:Request,files:List[UploadFile]=File(...),
use_ollama:str=Form("false"),ollama_model:str=Form(""),
use_openrouter:str=Form("false"),openrouter_model:str=Form(""),
stt_engine:str=Form(""),stt_language:str=Form(""),
user:dict=Depends(require_stt)):
if not files: raise HTTPException(400,"파일이 없습니다")
if len(files)>20: raise HTTPException(400,"최대 20개까지")
items=await _dispatch_stt(request,files,use_ollama,ollama_model,use_openrouter,openrouter_model,user)
items=await _dispatch_stt(request,files,use_ollama,ollama_model,use_openrouter,openrouter_model,stt_engine,stt_language,user)
return {"items":items,"total":len(items)}
# ════════════════════════════════════════════════════════════════
# 자막 파이프라인 (영상 → SRT/VTT)
# 자막
# ════════════════════════════════════════════════════════════════
@app.post("/api/subtitle")
async def create_subtitle(
request: Request,
file: UploadFile = File(...),
src_language: str = Form(""), # 원어 (빈칸=자동)
subtitle_fmt: str = Form("srt"), # srt | vtt | both
translate_to: str = Form(""), # 번역 대상 언어 (빈칸=번역 안 함)
trans_model: str = Form(""), # 번역 모델
trans_via: str = Form("ollama"), # ollama | openrouter
user: dict = Depends(require_stt),
request:Request, file:UploadFile=File(...),
src_language:str=Form(""),subtitle_fmt:str=Form("srt"),
stt_engine:str=Form("local"),
refine_model:str=Form(""),refine_via:str=Form("ollama"),
translate_to:str=Form(""),trans_model:str=Form(""),trans_via:str=Form("ollama"),
user:dict=Depends(require_subtitle),
):
_check_size(request)
ext = _ext(file.filename)
# 영상 + 오디오 모두 허용 (오디오만 있어도 자막 생성 가능)
if ext not in AUDIO_EXT:
raise HTTPException(400, f"지원하지 않는 형식입니다. 영상/오디오 파일을 업로드하세요.")
if subtitle_fmt not in ("srt","vtt","both"): subtitle_fmt = "srt"
s = _load_settings()
# 번역 모델 미지정 시 설정에서 가져옴
ext=_ext(file.filename)
if ext not in AUDIO_EXT: raise HTTPException(400,"지원하지 않는 형식입니다")
if subtitle_fmt not in ("srt","vtt","both"): subtitle_fmt="srt"
s=_load_settings()
if not stt_engine: stt_engine=s.get("default_stt_engine","local")
if not refine_model.strip():
refine_model=(s.get("openrouter_stt_model","") if refine_via=="openrouter"
else s.get("stt_ollama_model",""))
if not trans_model.strip():
trans_model = (s.get("openrouter_stt_model","") if trans_via=="openrouter"
else s.get("stt_ollama_model",""))
file_id = str(uuid.uuid4())
save_path = os.path.join(UPLOAD_DIR, f"{file_id}.{ext}")
await _save_upload(file, save_path)
file_size = os.path.getsize(save_path)
task = subtitle_pipeline_task.delay(
file_id, save_path,
src_language, subtitle_fmt,
translate_to, trans_model, trans_via,
s.get("openrouter_url",""), s.get("openrouter_api_key",""),
trans_model=(s.get("openrouter_stt_model","") if trans_via=="openrouter"
else s.get("stt_ollama_model",""))
file_id=str(uuid.uuid4())
save_path=os.path.join(UPLOAD_DIR,f"{file_id}.{ext}")
await _save_upload(file,save_path)
file_size=os.path.getsize(save_path)
subtitle_timeout=int(s.get("subtitle_timeout",600))
task=subtitle_pipeline_task.delay(
file_id,save_path,src_language,subtitle_fmt,
stt_engine,s.get("groq_api_key",""),s.get("openai_api_key",""),
refine_model,refine_via,translate_to,trans_model,trans_via,
s.get("openrouter_url",""),s.get("openrouter_api_key",""),
subtitle_timeout,
)
append_history({
"id": file_id, "task_id": task.id, "type": "subtitle",
"status": "processing",
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"username": user["username"],
"input": {"filename": file.filename, "size_bytes": file_size, "format": ext.upper()},
"settings": {
"src_language": src_language or "auto",
"subtitle_fmt": subtitle_fmt,
"translate_to": translate_to,
"trans_model": trans_model,
"trans_via": trans_via,
},
"output": None,
})
return {"task_id": task.id, "file_id": file_id, "filename": file.filename}
append_history({"id":file_id,"task_id":task.id,"type":"subtitle","status":"processing",
"timestamp":datetime.now().strftime("%Y-%m-%d %H:%M:%S"),"username":user["username"],
"input":{"filename":file.filename,"size_bytes":file_size,"format":ext.upper()},
"settings":{"src_language":src_language or "auto","subtitle_fmt":subtitle_fmt,
"stt_engine":stt_engine,"refine_model":refine_model,"refine_via":refine_via,
"translate_to":translate_to,"trans_model":trans_model,"trans_via":trans_via,
"subtitle_timeout":subtitle_timeout},
"output":None})
return {"task_id":task.id,"file_id":file_id,"filename":file.filename}
# ════════════════════════════════════════════════════════════════
# OCR 단일 / 배치
# OCR
# ════════════════════════════════════════════════════════════════
async def _dispatch_ocr(request,files,mode,backend,ollama_model,openrouter_model,custom_prompt,user):
if mode not in ("text","structure"): mode="text"
if backend not in ("paddle","ollama","openrouter"): backend="paddle"
s=_load_settings()
if backend=="ollama" and not ollama_model.strip(): ollama_model=s.get("ocr_ollama_model","granite3.2-vision:latest")
if backend=="openrouter" and not openrouter_model.strip():openrouter_model=s.get("openrouter_ocr_model","")
if backend=="ollama" and not ollama_model.strip(): ollama_model=s.get("ocr_ollama_model","granite3.2-vision:latest")
if backend=="openrouter" and not openrouter_model.strip():openrouter_model=s.get("openrouter_ocr_model","")
results=[]
for file in files:
_check_size(request)
@@ -315,7 +377,7 @@ async def _dispatch_ocr(request,files,mode,backend,ollama_model,openrouter_model
"settings":{"backend":backend,"mode":mode,"ocr_lang":os.getenv("OCR_LANG","korean"),
"ollama_model":ollama_model if backend=="ollama" else "",
"openrouter_model":openrouter_model if backend=="openrouter" else "",
"ollama_timeout":s.get("ollama_timeout",600),"custom_prompt":custom_prompt[:200] if custom_prompt else ""},
"custom_prompt":custom_prompt[:200] if custom_prompt else ""},
"output":None})
results.append({"task_id":task.id,"file_id":file_id,"filename":file.filename})
return results
@@ -340,17 +402,8 @@ async def ocr_batch(request:Request,files:List[UploadFile]=File(...),
# ════════════════════════════════════════════════════════════════
# 상태 / 이력 / 다운로드 / Ollama / OpenRouter / 설정 / 관리자
# 이력
# ════════════════════════════════════════════════════════════════
@app.get("/api/status/{task_id}")
def get_status(task_id:str,user:dict=Depends(require_auth)):
r=celery_app.AsyncResult(task_id)
if r.state=="PENDING": return {"state":"pending","progress":0,"message":"대기 중..."}
if r.state=="PROGRESS": m=r.info or {};return {"state":"progress","progress":m.get("progress",0),"step":m.get("step",0),"step_msg":m.get("step_msg",""),"message":m.get("message","처리 중...")}
if r.state=="SUCCESS": _update_history_by_task(task_id,r.result or {},True);return {"state":"success","progress":100,**(r.result or {})}
if r.state=="FAILURE": _update_history_by_task(task_id,{},False,str(r.info));return {"state":"failure","progress":0,"message":str(r.info)}
return {"state":r.state.lower(),"progress":0}
@app.get("/api/history")
def get_history(page:int=1,per_page:int=15,type_:str="",user:dict=Depends(require_auth)):
history=_load_history()
@@ -366,8 +419,12 @@ def delete_history(history_id:str,user:dict=Depends(require_auth)):
@app.delete("/api/history")
def clear_all_history(user:dict=Depends(require_admin)):
clear_history();return {"ok":True}
clear_history(); return {"ok":True}
# ════════════════════════════════════════════════════════════════
# 다운로드 / Ollama / OpenRouter / 설정 / 관리자
# ════════════════════════════════════════════════════════════════
@app.get("/api/download/{filename}")
def download(filename:str,user:dict=Depends(require_auth)):
if ".." in filename or "/" in filename: raise HTTPException(400,"잘못된 파일명")
@@ -382,79 +439,102 @@ def download(filename:str,user:dict=Depends(require_auth)):
@app.get("/api/ollama/models")
def ollama_models(user:dict=Depends(require_auth)):
try:
resp=httpx.get(f"{OLLAMA_URL}/api/tags",timeout=8.0);resp.raise_for_status()
resp=httpx.get(f"{OLLAMA_URL}/api/tags",timeout=8.0); resp.raise_for_status()
return {"models":[m["name"] for m in resp.json().get("models",[])], "connected":True}
except Exception as e: return {"models":[],"connected":False,"error":str(e)}
@app.get("/api/openrouter/models")
def openrouter_models(user:dict=Depends(require_auth)):
s=_load_settings();api_key=s.get("openrouter_api_key","");base_url=s.get("openrouter_url","https://openrouter.ai/api/v1").rstrip("/")
if not api_key: return {"models":[],"connected":False,"error":"API 키가 설정되지 않았습니다"}
s=_load_settings(); api_key=s.get("openrouter_api_key","")
base_url=s.get("openrouter_url","https://openrouter.ai/api/v1").rstrip("/")
if not api_key: return {"models":[],"vision_models":[],"text_models":[],"connected":False,"error":"API 키가 설정되지 않았습니다"}
try:
resp=httpx.get(f"{base_url}/models",headers={"Authorization":f"Bearer {api_key}","HTTP-Referer":"https://voicescript.local"},timeout=12.0)
resp=httpx.get(f"{base_url}/models",
headers={"Authorization":f"Bearer {api_key}","HTTP-Referer":"https://voicescript.local"},timeout=12.0)
resp.raise_for_status()
all_models=resp.json().get("data",[])
vision=[m["id"] for m in all_models if any(k in m["id"].lower() for k in ["vision","claude-3","gemini","gpt-4o","llava","pixtral","qwen-vl","deepseek-vl"])]
return {"models":[m["id"] for m in all_models],"vision_models":vision,"connected":True,"total":len(all_models)}
except httpx.HTTPStatusError as e: return {"models":[],"connected":False,"error":f"HTTP {e.response.status_code}"}
except Exception as e: return {"models":[],"connected":False,"error":str(e)}
vision=[m["id"] for m in all_models if any(k in m["id"].lower()
for k in ["vision","claude-3","gemini","gpt-4o","llava","pixtral","qwen-vl","deepseek-vl"])]
text=[m["id"] for m in all_models if m["id"] not in vision]
return {"models":[m["id"] for m in all_models],"vision_models":vision,"text_models":text,
"connected":True,"total":len(all_models)}
except httpx.HTTPStatusError as e: return {"models":[],"vision_models":[],"text_models":[],"connected":False,"error":f"HTTP {e.response.status_code}"}
except Exception as e: return {"models":[],"vision_models":[],"text_models":[],"connected":False,"error":str(e)}
@app.post("/api/openrouter/test")
def openrouter_test(api_key:str=Form(...),base_url:str=Form("https://openrouter.ai/api/v1"),user:dict=Depends(require_auth)):
try:
resp=httpx.get(f"{base_url.rstrip('/')}/models",headers={"Authorization":f"Bearer {api_key}","HTTP-Referer":"https://voicescript.local"},timeout=10.0)
resp.raise_for_status();count=len(resp.json().get("data",[]))
resp=httpx.get(f"{base_url.rstrip('/')}/models",
headers={"Authorization":f"Bearer {api_key}","HTTP-Referer":"https://voicescript.local"},timeout=10.0)
resp.raise_for_status(); count=len(resp.json().get("data",[]))
return {"ok":True,"message":f"연결 성공 — {count}개 모델 사용 가능"}
except httpx.HTTPStatusError as e: return {"ok":False,"message":f"인증 실패 (HTTP {e.response.status_code})"}
except Exception as e: return {"ok":False,"message":f"연결 실패: {str(e)}"}
@app.get("/api/settings")
def get_settings(user:dict=Depends(require_auth)):
s=_load_settings();result=dict(s)
if result.get("openrouter_api_key"):
key=result["openrouter_api_key"]
result["openrouter_api_key_masked"]=key[:8]+"..."+key[-4:] if len(key)>12 else "****"
else: result["openrouter_api_key_masked"]=""
result["openrouter_api_key"]="";return result
s=_load_settings(); result=dict(s)
for field in ("openrouter_api_key","groq_api_key","openai_api_key"):
result[field+"_masked"]=_mask(result.get(field,""))
result[field]=""
return result
@app.post("/api/settings")
def save_settings_endpoint(
stt_ollama_model:str=Form(""),ocr_ollama_model:str=Form(""),
cpu_threads:str=Form("0"),stt_timeout:str=Form("0"),ollama_timeout:str=Form("600"),
openrouter_url:str=Form("https://openrouter.ai/api/v1"),openrouter_api_key:str=Form(""),
openrouter_stt_model:str=Form(""),openrouter_ocr_model:str=Form(""),
cpu_threads:str=Form("0"),stt_timeout:str=Form("0"),
ollama_timeout:str=Form("600"),subtitle_timeout:str=Form("600"),
openrouter_url:str=Form("https://openrouter.ai/api/v1"),
openrouter_api_key:str=Form(""),openrouter_stt_model:str=Form(""),openrouter_ocr_model:str=Form(""),
groq_api_key:str=Form(""),openai_api_key:str=Form(""),
default_stt_engine:str=Form("local"),
user:dict=Depends(require_auth),
):
def _int(v,d):
try: return max(0,int(v))
except: return d
current=_load_settings()
final_key=openrouter_api_key.strip() if openrouter_api_key.strip() else current.get("openrouter_api_key","")
data={"stt_ollama_model":stt_ollama_model,"ocr_ollama_model":ocr_ollama_model,
"cpu_threads":_int(cpu_threads,0),"stt_timeout":_int(stt_timeout,0),"ollama_timeout":_int(ollama_timeout,600),
"openrouter_url":openrouter_url.strip() or "https://openrouter.ai/api/v1",
"openrouter_api_key":final_key,"openrouter_stt_model":openrouter_stt_model,"openrouter_ocr_model":openrouter_ocr_model}
_save_settings(data);return {"ok":True,"settings":{k:v for k,v in data.items() if k!="openrouter_api_key"}}
data={
"stt_ollama_model":stt_ollama_model,"ocr_ollama_model":ocr_ollama_model,
"cpu_threads":_int(cpu_threads,0),"stt_timeout":_int(stt_timeout,0),
"ollama_timeout":_int(ollama_timeout,600),"subtitle_timeout":_int(subtitle_timeout,600),
"openrouter_url":openrouter_url.strip() or "https://openrouter.ai/api/v1",
"openrouter_api_key":_keep(openrouter_api_key,"openrouter_api_key",current),
"openrouter_stt_model":openrouter_stt_model,"openrouter_ocr_model":openrouter_ocr_model,
"groq_api_key":_keep(groq_api_key,"groq_api_key",current),
"openai_api_key":_keep(openai_api_key,"openai_api_key",current),
"default_stt_engine":default_stt_engine or "local",
}
_save_settings(data)
result={k:v for k,v in data.items() if not k.endswith("_api_key")}
for f in ("openrouter_api_key","groq_api_key","openai_api_key"):
result[f+"_masked"]=_mask(data.get(f,""))
return {"ok":True,"settings":result}
@app.get("/api/admin/users")
def admin_list_users(user:dict=Depends(require_admin)): return {"users":list_users()}
@app.post("/api/admin/users")
def admin_create_user(username:str=Form(...),password:str=Form(...),
perm_stt:str=Form("false"),perm_ocr:str=Form("false"),
allowed_stt_models:str=Form(""),allowed_ocr_models:str=Form(""),user:dict=Depends(require_admin)):
perm_stt:str=Form("false"),perm_ocr:str=Form("false"),perm_subtitle:str=Form("false"),
allowed_stt_models:str=Form(""),allowed_ocr_models:str=Form(""),
user:dict=Depends(require_admin)):
def _p(s): return [m.strip() for m in s.split(",") if m.strip()]
perms={"stt":perm_stt.lower()=="true","ocr":perm_ocr.lower()=="true",
"subtitle":perm_subtitle.lower()=="true",
"allowed_stt_models":_p(allowed_stt_models),"allowed_ocr_models":_p(allowed_ocr_models)}
ok,msg=create_user(username,password,perms)
if not ok: raise HTTPException(400,msg)
return {"ok":True,"message":msg}
@app.put("/api/admin/users/{username}")
def admin_update_user(username:str,perm_stt:str=Form("false"),perm_ocr:str=Form("false"),
password:str=Form(""),allowed_stt_models:str=Form(""),allowed_ocr_models:str=Form(""),user:dict=Depends(require_admin)):
def admin_update_user(username:str,
perm_stt:str=Form("false"),perm_ocr:str=Form("false"),perm_subtitle:str=Form("false"),
password:str=Form(""),allowed_stt_models:str=Form(""),allowed_ocr_models:str=Form(""),
user:dict=Depends(require_admin)):
def _p(s): return [m.strip() for m in s.split(",") if m.strip()]
perms={"stt":perm_stt.lower()=="true","ocr":perm_ocr.lower()=="true",
"subtitle":perm_subtitle.lower()=="true",
"allowed_stt_models":_p(allowed_stt_models),"allowed_ocr_models":_p(allowed_ocr_models)}
ok,msg=update_user(username,perms,password or None)
if not ok: raise HTTPException(400,msg)
@@ -469,6 +549,17 @@ def admin_delete_user(username:str,user:dict=Depends(require_admin)):
@app.post("/api/cleanup")
def cleanup(user:dict=Depends(require_auth)): return {"removed":_cleanup_outputs()}
@app.get("/")
async def index():
import pathlib
path=pathlib.Path("static/index.html")
resp=FileResponse(path,media_type="text/html")
resp.headers["Cache-Control"]="no-cache, no-store, must-revalidate"
resp.headers["Pragma"]="no-cache"; resp.headers["Expires"]="0"
return resp
app.mount("/",StaticFiles(directory="static",html=True),name="static")
# ════════════════════════════════════════════════════════════════
# 유틸
@@ -479,10 +570,10 @@ def _check_size(request:Request):
def _cleanup_outputs():
if OUTPUT_KEEP_SECS==0: return 0
cutoff=time.time()-OUTPUT_KEEP_SECS;removed=0
cutoff=time.time()-OUTPUT_KEEP_SECS; removed=0
for f in glob.glob(os.path.join(OUTPUT_DIR,"*")):
try:
if os.path.getmtime(f)<cutoff: os.remove(f);removed+=1
if os.path.getmtime(f)<cutoff: os.remove(f); removed+=1
except: pass
return removed
@@ -494,8 +585,6 @@ async def _save_upload(file:UploadFile,path:str):
while chunk:=await file.read(1024*1024):
written+=len(chunk)
if written>MAX_UPLOAD_BYTES:
await f.close();os.remove(path)
await f.close(); os.remove(path)
raise HTTPException(413,f"파일이 너무 큽니다. 최대 {MAX_UPLOAD_BYTES//1024//1024}MB")
await f.write(chunk)
app.mount("/", StaticFiles(directory="static", html=True), name="static")