feat: 복수 파일 배치 변환 (STT/OCR)

This commit is contained in:
root
2026-05-02 02:14:44 +09:00
parent 4af20f72e0
commit 4fc3da1a2d
6 changed files with 1252 additions and 1339 deletions

View File

@@ -5,6 +5,7 @@ from datetime import datetime
from fastapi import FastAPI, UploadFile, File, HTTPException, Depends, Form, Request
from fastapi.staticfiles import StaticFiles
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,
@@ -28,20 +29,25 @@ HISTORY_MAX = 300
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"}
AUDIO_EXT = {"mp3","mp4","wav","m4a","ogg","flac","aac","wma","webm",
"mkv","avi","mov","ts","mts","m2ts","wmv","flv","rmvb",
"h264","h265","hevc","264","265"}
IMAGE_EXT = {"jpg","jpeg","png","bmp","tiff","tif","webp","gif"}
SUPPORTED_LANGS = {
"ko":"한국어","en":"English","ja":"日本語","zh":"中文(简体)",
"zh-tw":"中文(繁體)","fr":"Français","de":"Deutsch","es":"Español",
"it":"Italiano","pt":"Português","ru":"Русский","ar":"العربية",
"vi":"Tiếng Việt","th":"ไทย","id":"Bahasa Indonesia",
"nl":"Nederlands","pl":"Polski","tr":"Türkçe","sv":"Svenska",
"uk":"Українська","hi":"हिन्दी","bn":"বাংলা",
}
_DEFAULT_SETTINGS = {
"stt_ollama_model": "",
"ocr_ollama_model": "granite3.2-vision:latest",
"cpu_threads": 0,
"stt_timeout": 0,
"ollama_timeout": 600,
# OpenRouter
"openrouter_url": "https://openrouter.ai/api/v1",
"openrouter_api_key": "",
"openrouter_stt_model": "",
"openrouter_ocr_model": "",
"stt_ollama_model":"","ocr_ollama_model":"granite3.2-vision:latest",
"cpu_threads":0,"stt_timeout":0,"ollama_timeout":600,
"openrouter_url":"https://openrouter.ai/api/v1",
"openrouter_api_key":"","openrouter_stt_model":"","openrouter_ocr_model":"",
}
_hist_lock = threading.Lock()
@@ -49,91 +55,93 @@ _hist_lock = threading.Lock()
# ── 설정 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)
for k, v in _DEFAULT_SETTINGS.items(): data.setdefault(k, v)
with open(SETTINGS_FILE,"r",encoding="utf-8") as f: data=json.load(f)
for k,v in _DEFAULT_SETTINGS.items(): data.setdefault(k,v)
return data
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 _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)
# ── 이력 I/O ─────────────────────────────────────────────────
def _load_history() -> list:
def _load_history()->list:
with _hist_lock:
if not HISTORY_FILE.exists(): return []
try:
with open(HISTORY_FILE, "r", encoding="utf-8") as f: return json.load(f)
with open(HISTORY_FILE,"r",encoding="utf-8") as f: return json.load(f)
except: return []
def _write_history(history: list):
HISTORY_FILE.parent.mkdir(parents=True, exist_ok=True)
with open(HISTORY_FILE, "w", encoding="utf-8") as f:
json.dump(history, f, ensure_ascii=False, indent=2)
def _write_history(h:list):
HISTORY_FILE.parent.mkdir(parents=True,exist_ok=True)
with open(HISTORY_FILE,"w",encoding="utf-8") as f: json.dump(h,f,ensure_ascii=False,indent=2)
def append_history(record: dict):
def append_history(record:dict):
with _hist_lock:
try:
history = []
history=[]
if HISTORY_FILE.exists():
with open(HISTORY_FILE, "r", encoding="utf-8") as f: history = json.load(f)
history.insert(0, record)
_write_history(history[:HISTORY_MAX])
with open(HISTORY_FILE,"r",encoding="utf-8") as f: history=json.load(f)
history.insert(0,record); _write_history(history[:HISTORY_MAX])
except: pass
def _update_history_by_task(task_id: str, result: dict, success: bool, error_msg: str = ""):
def _update_history_by_task(task_id:str,result:dict,success:bool,error_msg:str=""):
with _hist_lock:
if not HISTORY_FILE.exists(): return
try:
with open(HISTORY_FILE, "r", encoding="utf-8") as f: history = json.load(f)
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 not success:
h["status"] = "failed"; h["output"] = {"error": error_msg[:300]}; break
h["status"] = "success"
if h["type"] == "stt":
h["output"] = {
"filename": result.get("output_file", ""),
"language": result.get("language", ""),
"duration_s": result.get("duration", 0),
"segments": len(result.get("segments", [])),
"text_preview": result.get("text","")[:200] + ("" if len(result.get("text",""))>200 else ""),
"ollama_used": result.get("ollama_used", False),
"ollama_model": result.get("ollama_model", ""),
"openrouter_used": result.get("openrouter_used", False),
"openrouter_model": result.get("openrouter_model", ""),
if h.get("task_id")!=task_id: continue
if h.get("status")!="processing": break
if not success: h["status"]="failed";h["output"]={"error":error_msg[:300]};break
h["status"]="success"
if h["type"]=="stt":
text=result.get("text","")
h["output"]={
"filename":result.get("output_file",""),
"language":result.get("language",""),
"duration_s":result.get("duration",0),
"segments":len(result.get("segments",[])),
"text_preview":text[:200]+("" if len(text)>200 else ""),
"ollama_used":result.get("ollama_used",False),
"ollama_model":result.get("ollama_model",""),
"openrouter_used":result.get("openrouter_used",False),
"openrouter_model":result.get("openrouter_model",""),
"subtitle_mode":result.get("subtitle_mode",False),
"translated":result.get("translated",False),
"translate_to":result.get("translate_to",""),
"srt_file":result.get("srt_file",""),
"vtt_file":result.get("vtt_file",""),
}
else:
ft = result.get("full_text", "")
h["output"] = {
"txt_file": result.get("txt_file", ""),
"xlsx_file": result.get("xlsx_file", ""),
"line_count": result.get("line_count", 0),
"table_count": len(result.get("tables", [])),
"backend": result.get("backend", ""),
"ollama_model": result.get("ollama_model", ""),
"openrouter_model": result.get("openrouter_model", ""),
"text_preview": ft[:200] + ("" if len(ft)>200 else ""),
ft=result.get("full_text","")
h["output"]={
"txt_file":result.get("txt_file",""),
"xlsx_file":result.get("xlsx_file",""),
"line_count":result.get("line_count",0),
"table_count":len(result.get("tables",[])),
"backend":result.get("backend",""),
"ollama_model":result.get("ollama_model",""),
"openrouter_model":result.get("openrouter_model",""),
"text_preview":ft[:200]+("" if len(ft)>200 else ""),
}
break
_write_history(history)
except: pass
def delete_history_item(history_id: str) -> bool:
def delete_history_item(history_id:str)->bool:
with _hist_lock:
if not HISTORY_FILE.exists(): return False
try:
with open(HISTORY_FILE, "r", encoding="utf-8") as f: history = json.load(f)
new = [h for h in history if h.get("id") != history_id]
if len(new) == len(history): return False
with open(HISTORY_FILE,"r",encoding="utf-8") as f: history=json.load(f)
new=[h for h in history if h.get("id")!=history_id]
if len(new)==len(history): return False
_write_history(new); return True
except: return False
def clear_history():
with _hist_lock:
if HISTORY_FILE.exists(): HISTORY_FILE.write_text("[]", encoding="utf-8")
if HISTORY_FILE.exists(): HISTORY_FILE.write_text("[]",encoding="utf-8")
# ════════════════════════════════════════════════════════════════
@@ -148,177 +156,207 @@ async def on_startup():
# 인증
# ════════════════════════════════════════════════════════════════
@app.post("/api/login")
def login(username: str = Form(...), password: str = Form(...)):
user = authenticate(username, password)
if not user: raise HTTPException(401, "아이디 또는 비밀번호가 올바르지 않습니다")
return {"access_token": create_access_token(username), "token_type": "bearer"}
def login(username:str=Form(...),password:str=Form(...)):
user=authenticate(username,password)
if not user: raise HTTPException(401,"아이디 또는 비밀번호가 올바르지 않습니다")
return {"access_token":create_access_token(username),"token_type":"bearer"}
@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})}
def me(user:dict=Depends(require_auth)):
return {"username":user["username"],"role":user.get("role","user"),
"permissions":user.get("permissions",{"stt":False,"ocr":False})}
@app.get("/api/languages")
def get_languages(user:dict=Depends(require_auth)):
return {"languages":SUPPORTED_LANGS}
# ════════════════════════════════════════════════════════════════
# 시스템 정보
# ════════════════════════════════════════════════════════════════
@app.get("/api/system")
def system_info(user: dict = Depends(require_auth)):
mem = psutil.virtual_memory(); swap = psutil.swap_memory(); s = _load_settings()
def system_info(user:dict=Depends(require_auth)):
mem=psutil.virtual_memory();swap=psutil.swap_memory();s=_load_settings()
return {
"ram_total_gb": round(mem.total / 1024**3, 1),
"ram_used_gb": round(mem.used / 1024**3, 1),
"ram_avail_gb": round(mem.available / 1024**3, 1),
"ram_percent": mem.percent,
"swap_total_gb": round(swap.total / 1024**3, 1),
"swap_used_gb": round(swap.used / 1024**3, 1),
"cpu_logical": psutil.cpu_count(logical=True),
"cpu_physical": psutil.cpu_count(logical=False),
"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),
"ram_total_gb":round(mem.total/1024**3,1),"ram_used_gb":round(mem.used/1024**3,1),
"ram_avail_gb":round(mem.available/1024**3,1),"ram_percent":mem.percent,
"swap_total_gb":round(swap.total/1024**3,1),"swap_used_gb":round(swap.used/1024**3,1),
"cpu_logical":psutil.cpu_count(logical=True),"cpu_physical":psutil.cpu_count(logical=False),
"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),
}
# ════════════════════════════════════════════════════════════════
# STT
# STT 공통 디스패치
# ════════════════════════════════════════════════════════════════
@app.post("/api/transcribe")
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(""),
user: dict = Depends(require_stt),
async def _dispatch_stt(
request, files,
use_ollama, ollama_model,
use_openrouter, openrouter_model,
subtitle_mode, subtitle_format,
force_language,
translate_to, translate_model, translate_via,
user,
):
_check_size(request)
ext = _ext(file.filename)
if ext not in AUDIO_EXT: raise HTTPException(400, f"지원하지 않는 형식: {', '.join(sorted(AUDIO_EXT))}")
file_id = str(uuid.uuid4())
save_path = os.path.join(UPLOAD_DIR, f"{file_id}.{ext}")
await _save(file, save_path)
file_size = os.path.getsize(save_path)
s = _load_settings()
_use_ollama = use_ollama.lower() == "true"
_use_openrouter = use_openrouter.lower() == "true"
_sub_mode = subtitle_mode.lower() == "true"
if _use_ollama and not ollama_model.strip(): ollama_model = s.get("stt_ollama_model","")
if _use_openrouter and not openrouter_model.strip():openrouter_model= s.get("openrouter_stt_model","")
if not translate_model.strip():
translate_model = ollama_model if translate_via=="ollama" else openrouter_model
if _use_ollama and not ollama_model.strip():
ollama_model = s.get("stt_ollama_model", "")
if _use_openrouter and not openrouter_model.strip():
openrouter_model = s.get("openrouter_stt_model", "")
task = transcribe_task.delay(
file_id, save_path,
_use_ollama, ollama_model,
_use_openrouter, openrouter_model,
s.get("openrouter_url", ""), s.get("openrouter_api_key", ""),
)
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),
"stt_timeout": s.get("stt_timeout",0),
"use_ollama": _use_ollama,
"ollama_model": ollama_model if _use_ollama else "",
"use_openrouter": _use_openrouter,
"openrouter_model": openrouter_model if _use_openrouter else "",
},
"output": None,
})
return {"task_id": task.id, "file_id": file_id, "filename": file.filename}
results=[]
for file in files:
_check_size(request)
ext=_ext(file.filename)
if ext not in AUDIO_EXT:
results.append({"error":f"{file.filename}: 지원하지 않는 형식","filename":file.filename})
continue
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,
_use_ollama, ollama_model,
_use_openrouter, openrouter_model,
s.get("openrouter_url",""), s.get("openrouter_api_key",""),
_sub_mode, subtitle_format or "srt",
translate_to or "",
translate_model or "",
translate_via or "ollama",
force_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":force_language or os.getenv("WHISPER_LANGUAGE","auto"),
"compute_type":os.getenv("WHISPER_COMPUTE_TYPE","int8"),
"cpu_threads":s.get("cpu_threads",0),
"subtitle_mode":_sub_mode,
"subtitle_format":subtitle_format,
"translate_to":translate_to,
"translate_model":translate_model,
"use_ollama":_use_ollama,"ollama_model":ollama_model if _use_ollama else "",
"use_openrouter":_use_openrouter,"openrouter_model":openrouter_model if _use_openrouter else "",
},
"output":None,
})
results.append({"task_id":task.id,"file_id":file_id,"filename":file.filename})
return results
# ════════════════════════════════════════════════════════════════
# OCR
# STT — 단일 / 배치
# ════════════════════════════════════════════════════════════════
@app.post("/api/ocr")
async def ocr(
request: Request, file: UploadFile = File(...),
mode: str = Form("text"),
backend: str = Form("paddle"), # paddle | ollama | openrouter
ollama_model: str = Form(""),
openrouter_model: str = Form(""),
custom_prompt: str = Form(""),
user: dict = Depends(require_ocr),
@app.post("/api/transcribe")
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(""),
subtitle_mode:str=Form("false"), subtitle_format:str=Form("srt"),
force_language:str=Form(""),
translate_to:str=Form(""), translate_model:str=Form(""), translate_via:str=Form("ollama"),
user:dict=Depends(require_stt),
):
_check_size(request)
ext = _ext(file.filename)
if ext not in IMAGE_EXT: raise HTTPException(400, f"지원하지 않는 형식: {', '.join(sorted(IMAGE_EXT))}")
if mode not in ("text","structure"): mode = "text"
if backend not in ("paddle","ollama","openrouter"): backend = "paddle"
items=await _dispatch_stt(request,[file],use_ollama,ollama_model,use_openrouter,openrouter_model,
subtitle_mode,subtitle_format,force_language,translate_to,translate_model,translate_via,user)
return items[0]
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","")
file_id = str(uuid.uuid4())
save_path = os.path.join(UPLOAD_DIR, f"{file_id}.{ext}")
await _save(file, save_path)
file_size = os.path.getsize(save_path)
task = ocr_task.delay(
file_id, save_path, mode, backend,
ollama_model, openrouter_model,
s.get("openrouter_url",""), s.get("openrouter_api_key",""),
custom_prompt,
)
append_history({
"id": file_id, "task_id": task.id, "type": "ocr",
"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": {
"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 "",
},
"output": None,
})
return {"task_id": task.id, "file_id": file_id,
"filename": file.filename, "mode": mode, "backend": backend}
@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(""),
subtitle_mode:str=Form("false"), subtitle_format:str=Form("srt"),
force_language:str=Form(""),
translate_to:str=Form(""), translate_model:str=Form(""), translate_via:str=Form("ollama"),
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,
subtitle_mode,subtitle_format,force_language,translate_to,translate_model,translate_via,user)
return {"items":items,"total":len(items)}
# ════════════════════════════════════════════════════════════════
# 상태
# 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","")
results=[]
for file in files:
_check_size(request)
ext=_ext(file.filename)
if ext not in IMAGE_EXT:
results.append({"error":f"{file.filename}: 지원하지 않는 형식","filename":file.filename}); continue
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=ocr_task.delay(file_id,save_path,mode,backend,ollama_model,openrouter_model,
s.get("openrouter_url",""),s.get("openrouter_api_key",""),custom_prompt)
append_history({"id":file_id,"task_id":task.id,"type":"ocr","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":{"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 ""},
"output":None})
results.append({"task_id":task.id,"file_id":file_id,"filename":file.filename})
return results
@app.post("/api/ocr")
async def ocr(request:Request,file:UploadFile=File(...),
mode:str=Form("text"),backend:str=Form("paddle"),
ollama_model:str=Form(""),openrouter_model:str=Form(""),custom_prompt:str=Form(""),
user:dict=Depends(require_ocr)):
items=await _dispatch_ocr(request,[file],mode,backend,ollama_model,openrouter_model,custom_prompt,user)
return items[0]
@app.post("/api/ocr/batch")
async def ocr_batch(request:Request,files:List[UploadFile]=File(...),
mode:str=Form("text"),backend:str=Form("paddle"),
ollama_model:str=Form(""),openrouter_model:str=Form(""),custom_prompt:str=Form(""),
user:dict=Depends(require_ocr)):
if not files: raise HTTPException(400,"파일이 없습니다")
if len(files)>20: raise HTTPException(400,"한 번에 최대 20개까지")
items=await _dispatch_ocr(request,files,mode,backend,ollama_model,openrouter_model,custom_prompt,user)
return {"items":items,"total":len(items)}
# ════════════════════════════════════════════════════════════════
# 상태 / 이력 / 다운로드 / 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),"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)}
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),"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()
if user.get("role") != "admin": history = [h for h in history if h.get("username")==user["username"]]
if type_ in ("stt","ocr"): history = [h for h in history if h.get("type")==type_]
total = len(history); start = (page-1)*per_page
history=_load_history()
if user.get("role")!="admin": history=[h for h in history if h.get("username")==user["username"]]
if type_ in ("stt","ocr"): history=[h for h in history if h.get("type")==type_]
total=len(history);start=(page-1)*per_page
return {"total":total,"page":page,"per_page":per_page,"items":history[start:start+per_page]}
@app.delete("/api/history/{history_id}")
@@ -328,161 +366,87 @@ 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}
# ════════════════════════════════════════════════════════════════
# 다운로드
# ════════════════════════════════════════════════════════════════
@app.get("/api/download/{filename}")
def download(filename:str,user:dict=Depends(require_auth)):
if ".." in filename or "/" in filename: raise HTTPException(400,"잘못된 파일명")
path = os.path.join(OUTPUT_DIR, filename)
path=os.path.join(OUTPUT_DIR,filename)
if not os.path.exists(path): raise HTTPException(404,"파일을 찾을 수 없습니다")
media = ("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
if filename.endswith(".xlsx") else "text/plain")
return FileResponse(path, media_type=media, filename=filename)
if filename.endswith(".xlsx"):
media="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
elif filename.endswith(".srt"): media="text/plain"
elif filename.endswith(".vtt"): media="text/vtt"
else: media="text/plain"
return FileResponse(path,media_type=media,filename=filename)
# ════════════════════════════════════════════════════════════════
# Ollama 모델 목록
# ════════════════════════════════════════════════════════════════
@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)}
except Exception as e: return {"models":[],"connected":False,"error":str(e)}
# ════════════════════════════════════════════════════════════════
# OpenRouter 모델 목록 & 연결 테스트
# ════════════════════════════════════════════════════════════════
@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 키가 설정되지 않았습니다"}
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 키가 설정되지 않았습니다"}
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()
data = resp.json()
# Vision 모델 필터링 (multimodal 지원 모델)
all_models = data.get("data", [])
vision = [m["id"] for m in all_models
if any(k in str(m.get("architecture", {}).get("modality","")).lower()
for k in ["image","vision","multimodal"])
or any(k in m["id"].lower()
for k in ["vision","claude-3","gemini","gpt-4o","llava","pixtral","qwen-vl","intern","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":[], "connected":False, "error":f"HTTP {e.response.status_code}: API 키를 확인하세요"}
except Exception as e:
return {"models":[], "connected":False, "error":str(e)}
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)}
@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),
):
"""API 키 연결 테스트"""
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=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}) — API 키를 확인하세요"}
except Exception as e:
return {"ok": False, "message": f"연결 실패: {str(e)}"}
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()
# API 키는 마스킹해서 반환
result = dict(s)
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
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
@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(""),
user: dict = Depends(require_auth),
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(""),
user:dict=Depends(require_auth),
):
def _int(v, d):
try: return max(0, int(v))
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"}}
current = _load_settings()
# API 키가 비어있으면 기존 값 유지
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"}}
# ════════════════════════════════════════════════════════════════
# 관리자
# ════════════════════════════════════════════════════════════════
@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(...),
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),
):
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",
"allowed_stt_models":_p(allowed_stt_models),"allowed_ocr_models":_p(allowed_ocr_models)}
@@ -491,11 +455,8 @@ def admin_create_user(
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"),
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",
"allowed_stt_models":_p(allowed_stt_models),"allowed_ocr_models":_p(allowed_ocr_models)}
@@ -505,7 +466,7 @@ def admin_update_user(
@app.delete("/api/admin/users/{username}")
def admin_delete_user(username:str,user:dict=Depends(require_admin)):
ok,msg=delete_user(username)
ok,msg=delete_user(username);
if not ok: raise HTTPException(400,msg)
return {"ok":True,"message":msg}
@@ -516,30 +477,29 @@ def cleanup(user:dict=Depends(require_auth)): return {"removed":_cleanup_outputs
# ════════════════════════════════════════════════════════════════
# 유틸
# ════════════════════════════════════════════════════════════════
def _check_size(request):
cl = request.headers.get("content-length")
if cl and int(cl) > MAX_UPLOAD_BYTES:
raise HTTPException(413, f"파일이 너무 큽니다. 최대 {MAX_UPLOAD_BYTES//1024//1024}MB")
def _check_size(request:Request):
cl=request.headers.get("content-length")
if cl and int(cl)>MAX_UPLOAD_BYTES*20: raise HTTPException(413,"파일이 너무 큽니다")
def _cleanup_outputs():
if OUTPUT_KEEP_SECS == 0: return 0
cutoff = time.time() - OUTPUT_KEEP_SECS; removed = 0
if OUTPUT_KEEP_SECS==0: return 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
def _ext(fn): return fn.rsplit(".",1)[-1].lower() if "." in fn else ""
async def _save(file, path):
written = 0
async def _save_upload(file:UploadFile,path:str):
written=0
async with aiofiles.open(path,"wb") as f:
while chunk := await file.read(1024*1024):
written += len(chunk)
if written > MAX_UPLOAD_BYTES:
await f.close(); os.remove(path)
raise HTTPException(413, f"파일이 너무 큽니다. 최대 {MAX_UPLOAD_BYTES//1024//1024}MB")
while chunk:=await file.read(1024*1024):
written+=len(chunk)
if written>MAX_UPLOAD_BYTES:
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")