main.py 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850
  1. import hashlib
  2. import json
  3. import os
  4. import secrets
  5. import shutil
  6. import subprocess
  7. import tempfile
  8. import threading
  9. import uuid
  10. from datetime import datetime, timedelta
  11. from typing import Literal, Optional
  12. import dotenv
  13. from fastapi import BackgroundTasks, Depends, FastAPI, File, Form, Header, HTTPException, UploadFile
  14. from fastapi.responses import PlainTextResponse, Response, FileResponse
  15. from fastapi.staticfiles import StaticFiles
  16. from pydantic import BaseModel
  17. from core.combine import combine
  18. from core.diarize import diarize
  19. from core.formats import to_srt, to_txt
  20. from core.transcribe import transcribe
  21. dotenv.load_dotenv()
  22. app = FastAPI(title="Transcriptor API", description="Audio/Video transcription and speaker diarization")
  23. # ---------------------------------------------------------------------------
  24. # API key store (direct REST access)
  25. # ---------------------------------------------------------------------------
  26. KEYS_FILE = os.path.join(os.path.dirname(__file__), "api_keys.json")
  27. def _load_keys() -> dict:
  28. if os.path.exists(KEYS_FILE):
  29. with open(KEYS_FILE) as f:
  30. return json.load(f)
  31. return {}
  32. def _save_keys(keys: dict):
  33. with open(KEYS_FILE, "w") as f:
  34. json.dump(keys, f, indent=2)
  35. # ---------------------------------------------------------------------------
  36. # User store (GUI accounts with passwords)
  37. # ---------------------------------------------------------------------------
  38. USERS_FILE = os.path.join(os.path.dirname(__file__), "users.json")
  39. _users_lock = threading.Lock()
  40. def _load_users() -> dict:
  41. if os.path.exists(USERS_FILE):
  42. with open(USERS_FILE) as f:
  43. return json.load(f)
  44. return {}
  45. def _save_users(users: dict):
  46. with _users_lock:
  47. with open(USERS_FILE, "w", encoding="utf-8") as f:
  48. json.dump(users, f, indent=2, ensure_ascii=False)
  49. def _hash_password(password: str, salt: Optional[str] = None) -> tuple[str, str]:
  50. if salt is None:
  51. salt = secrets.token_hex(16)
  52. key = hashlib.pbkdf2_hmac("sha256", password.encode(), salt.encode(), 200_000)
  53. return key.hex(), salt
  54. def _verify_password(password: str, stored_hash: str, salt: str) -> bool:
  55. computed, _ = _hash_password(password, salt)
  56. return secrets.compare_digest(computed, stored_hash)
  57. # ---------------------------------------------------------------------------
  58. # Session store (in-memory, 8-hour TTL)
  59. # ---------------------------------------------------------------------------
  60. _sessions: dict = {}
  61. _sessions_lock = threading.Lock()
  62. SESSION_TTL_HOURS = 8
  63. def _create_session(user: dict) -> str:
  64. token = secrets.token_hex(32)
  65. expires_at = (datetime.now() + timedelta(hours=SESSION_TTL_HOURS)).isoformat()
  66. with _sessions_lock:
  67. _sessions[token] = {
  68. "user_id": user["id"],
  69. "email": user["email"],
  70. "name": user["name"],
  71. "role": user["role"],
  72. "expires_at": expires_at,
  73. }
  74. return token
  75. def _get_session(token: str) -> Optional[dict]:
  76. with _sessions_lock:
  77. session = _sessions.get(token)
  78. if not session:
  79. return None
  80. if datetime.now() > datetime.fromisoformat(session["expires_at"]):
  81. with _sessions_lock:
  82. _sessions.pop(token, None)
  83. return None
  84. return session
  85. # ---------------------------------------------------------------------------
  86. # Auth dependencies
  87. # ---------------------------------------------------------------------------
  88. def verify_api_key(x_api_key: str = Header(..., description="Your API key")) -> dict:
  89. keys = _load_keys()
  90. if x_api_key not in keys:
  91. raise HTTPException(status_code=401, detail="Invalid or missing API key")
  92. return keys[x_api_key]
  93. def verify_session(x_session_token: Optional[str] = Header(None)) -> dict:
  94. if not x_session_token:
  95. raise HTTPException(status_code=401, detail="Not authenticated")
  96. session = _get_session(x_session_token)
  97. if not session:
  98. raise HTTPException(status_code=401, detail="Invalid or expired session")
  99. return session
  100. def require_admin(session: dict = Depends(verify_session)) -> dict:
  101. if session.get("role") != "admin":
  102. raise HTTPException(status_code=403, detail="Admin access required")
  103. return session
  104. def verify_any_auth(
  105. x_api_key: Optional[str] = Header(None),
  106. x_session_token: Optional[str] = Header(None),
  107. ) -> dict:
  108. if x_session_token:
  109. session = _get_session(x_session_token)
  110. if session:
  111. return session
  112. if x_api_key:
  113. keys = _load_keys()
  114. if x_api_key in keys:
  115. info = keys[x_api_key]
  116. return {"user_id": None, "email": info["email"], "name": info.get("name", ""), "role": "user"}
  117. raise HTTPException(status_code=401, detail="Authentication required")
  118. # ---------------------------------------------------------------------------
  119. # Persistent job store
  120. # ---------------------------------------------------------------------------
  121. _jobs: dict = {}
  122. _jobs_lock = threading.Lock()
  123. JOBS_DIR = os.path.join(tempfile.gettempdir(), "transcriptor_jobs")
  124. PERSIST_DIR = os.path.join(os.path.dirname(__file__), "jobs")
  125. os.makedirs(JOBS_DIR, exist_ok=True)
  126. os.makedirs(PERSIST_DIR, exist_ok=True)
  127. def _persist_job(job: dict):
  128. path = os.path.join(PERSIST_DIR, f"{job['job_id']}.json")
  129. with open(path, "w", encoding="utf-8") as f:
  130. json.dump(job, f, ensure_ascii=False, indent=2)
  131. def _load_persisted_jobs():
  132. for fname in os.listdir(PERSIST_DIR):
  133. if not fname.endswith(".json"):
  134. continue
  135. try:
  136. with open(os.path.join(PERSIST_DIR, fname), encoding="utf-8") as f:
  137. job = json.load(f)
  138. _jobs[job["job_id"]] = job
  139. except Exception:
  140. pass
  141. def _update_job(job_id: str, **kwargs):
  142. with _jobs_lock:
  143. _jobs[job_id].update(kwargs)
  144. job = dict(_jobs[job_id])
  145. if job.get("status") in ("completed", "failed"):
  146. _persist_job(job)
  147. _load_persisted_jobs()
  148. # ---------------------------------------------------------------------------
  149. # Pipeline helpers
  150. # ---------------------------------------------------------------------------
  151. def _video_to_audio(src: str, dest: str):
  152. subprocess.run(
  153. ["ffmpeg", "-i", src, "-vn", "-acodec", "pcm_s16le", "-ar", "16000", "-y", dest],
  154. stdout=subprocess.DEVNULL,
  155. stderr=subprocess.DEVNULL,
  156. check=True,
  157. )
  158. def _run_pipeline(
  159. job_id: str,
  160. file_path: str,
  161. model: str,
  162. device: str,
  163. language: str,
  164. do_srt: bool,
  165. do_txt: bool,
  166. do_srt_nh: bool,
  167. do_txt_nh: bool,
  168. initial_prompt: str = "",
  169. ):
  170. try:
  171. audio_path = file_path
  172. if not file_path.lower().endswith(".wav"):
  173. wav_path = file_path.rsplit(".", 1)[0] + "_converted.wav"
  174. _update_job(job_id, status="converting")
  175. _video_to_audio(file_path, wav_path)
  176. audio_path = wav_path
  177. _update_job(job_id, status="transcribing")
  178. segments, elapsed = transcribe(audio_path, model, device, language, initial_prompt=initial_prompt)
  179. if not segments:
  180. _update_job(job_id, status="failed", error="Transcription returned no segments")
  181. return
  182. results: dict = {}
  183. raw_segments_json = [
  184. {"start": s.start, "end": s.end, "text": s.text}
  185. for s in segments
  186. ]
  187. if do_txt_nh:
  188. results["txt_nh"] = to_txt(segments, with_speaker=False)
  189. if do_srt_nh:
  190. results["srt_nh"] = to_srt(segments, with_speaker=False)
  191. if do_srt or do_txt:
  192. _update_job(job_id, status="diarizing")
  193. diarization = diarize(audio_path)
  194. if diarization is None:
  195. _update_job(job_id, status="failed", error="Diarization failed")
  196. return
  197. _update_job(job_id, status="combining")
  198. final_segments = combine(segments, diarization)
  199. if do_srt:
  200. results["srt"] = to_srt(final_segments, with_speaker=True)
  201. if do_txt:
  202. results["txt"] = to_txt(final_segments, with_speaker=True)
  203. raw_segments_json = [
  204. {"start": s["start"], "end": s["end"], "text": s["text"], "speaker": s["speaker"]}
  205. for s in final_segments
  206. ]
  207. _update_job(
  208. job_id,
  209. status="completed",
  210. transcription_time=round(elapsed, 2),
  211. segments=raw_segments_json,
  212. results=results,
  213. )
  214. except Exception as exc:
  215. _update_job(job_id, status="failed", error=str(exc))
  216. finally:
  217. job_dir = os.path.join(JOBS_DIR, job_id)
  218. if os.path.exists(job_dir):
  219. shutil.rmtree(job_dir, ignore_errors=True)
  220. # ---------------------------------------------------------------------------
  221. # Skill / usage guide
  222. # ---------------------------------------------------------------------------
  223. _SKILL_MD = """# Transcriptor — Usage Guide
  224. ## What is this?
  225. Transcriptor is an audio and video transcription API with speaker diarization.
  226. It converts speech to text (Whisper) and identifies who is speaking at each moment (PyAnnote).
  227. ---
  228. ## Authentication
  229. ### Web Interface
  230. Log in with your **email** and **password**. Your session is saved in the browser.
  231. On first login, you will be prompted to set a new password.
  232. ### REST API
  233. Every request requires an `X-API-Key` header.
  234. API keys are managed by an administrator via the Admin Panel.
  235. ---
  236. ## Web Interface
  237. Open the app at `http://<host>:8010/` and follow these steps:
  238. ### 1 — Upload
  239. - Drag and drop any audio or video file onto the upload zone, or click to browse.
  240. - Supported formats: MP3, MP4, WAV, OGG, M4A, WebM, MKV, and more.
  241. - Files that are not WAV are automatically converted before processing.
  242. ### 2 — Settings
  243. | Setting | Options | Default |
  244. |---------|---------|---------|
  245. | Language | es, en, pt, fr, de, it, ja, zh, auto | es |
  246. | Model | large-v3, large-v2, medium, small, base | large-v3 |
  247. **Output formats** (select one or more):
  248. | Format | Description |
  249. |--------|-------------|
  250. | `.txt` | Plain text transcript with speaker labels |
  251. | `.srt` | Subtitle file with timestamps and speaker labels |
  252. | `.txt (no spk)` | Plain text without speaker labels |
  253. | `.srt (no spk)` | Subtitle file without speaker labels |
  254. > Formats with speaker labels trigger diarization (slower).
  255. > No-speaker formats skip diarization and finish faster.
  256. **Initial Prompt (optional)**
  257. Expand the "Initial Prompt" field to provide Whisper with context before transcription begins.
  258. Use it for proper nouns, acronyms, technical vocabulary, or speaker names that Whisper might
  259. otherwise misspell. The prompt is not included in the output — it only guides the model.
  260. Example: `"Participants: Dr. Ramírez and Lic. Ortega. Topic: quarterly budget review."`
  261. ### 3 — Processing
  262. After submitting, the job moves through these stages:
  263. ```
  264. pending → converting → transcribing → diarizing → combining → completed
  265. ```
  266. - **converting**: video/audio is normalised to 16 kHz WAV
  267. - **transcribing**: Whisper extracts word-level timestamps
  268. - **diarizing**: PyAnnote identifies each speaker turn
  269. - **combining**: words are aligned to speaker turns and merged
  270. ### 4 — Results
  271. Once completed, download buttons appear for each requested format.
  272. Click any button to download the file instantly.
  273. You can also preview the first segments with timestamps and speaker labels directly on the page.
  274. ---
  275. ## Jobs History
  276. The history table at the bottom of the page shows **only your own jobs**.
  277. - Click **↻ Refresh** to reload from the server.
  278. - Click any **completed** row to restore its results view.
  279. - Use the format buttons in each row to re-download files from previous jobs.
  280. Jobs are persisted on disk and survive server restarts.
  281. ---
  282. ## Admin Panel
  283. The Admin Panel is accessible to admin users via the **Admin Panel** button in the top navigation.
  284. ### Users tab
  285. - View all registered users with their roles, password status, and API key assignment.
  286. - **Create User**: set email, name, default password, and role.
  287. - **Reset Password**: assign a new default password (user is forced to change on next login).
  288. - **Generate / Revoke API Key**: manage REST API access per user.
  289. - **Delete User**: permanently remove an account.
  290. ### History tab
  291. - Global view of **all jobs across all accounts**, with the submitting user shown per row.
  292. - Columns: File, User, Status, Language, Model, Time, Created.
  293. - Click **↻ Refresh** to reload.
  294. ### Metrics tab
  295. - Overview of total jobs processed.
  296. - Breakdown by status, model, language, and user.
  297. - Average transcription time.
  298. ---
  299. ## REST API
  300. All endpoints require `X-API-Key` header.
  301. ### Verify credentials
  302. ```
  303. GET /auth/verify
  304. ```
  305. ### Submit a transcription job
  306. ```
  307. POST /transcribe
  308. Content-Type: multipart/form-data
  309. file — audio or video file (required)
  310. language — language code, default: es
  311. model — whisper model, default: large-v3
  312. device — cuda or cpu, default: cuda
  313. txt — true/false, default: false
  314. srt — true/false, default: false
  315. txt_nh — true/false, default: false
  316. srt_nh — true/false, default: false
  317. initial_prompt — text hint passed to Whisper before transcription, default: (empty)
  318. ```
  319. Response: `{"job_id": "...", "status": "pending"}`
  320. ### Poll job status
  321. ```
  322. GET /jobs/{job_id}
  323. ```
  324. Returns the full job object including segments and results when completed.
  325. ### List your jobs
  326. ```
  327. GET /jobs
  328. ```
  329. Returns jobs submitted by the authenticated account only (no file content).
  330. ### Download a result file
  331. ```
  332. GET /jobs/{job_id}/download/{fmt}
  333. ```
  334. `fmt` is one of: `txt`, `srt`, `txt_nh`, `srt_nh`
  335. ### Delete a job
  336. ```
  337. DELETE /jobs/{job_id}
  338. ```
  339. Removes the job from memory and from disk.
  340. ### Admin — list all jobs (admin only)
  341. ```
  342. GET /admin/jobs
  343. ```
  344. Returns all jobs across all accounts, sorted by creation date descending.
  345. Each entry includes `submitted_by` (email of the submitting account).
  346. ### This guide
  347. ```
  348. GET /skill
  349. ```
  350. Returns this markdown document.
  351. ---
  352. ## Tips
  353. - Use `large-v3` for best accuracy. Use `small` or `base` for faster results on short clips.
  354. - Set `language` explicitly — auto-detection adds latency.
  355. - If you only need a transcript without identifying speakers, use `txt_nh` or `srt_nh`; it skips the diarization step and finishes much faster.
  356. - Use `initial_prompt` when you know the topic, speaker names, or domain vocabulary upfront — it measurably reduces hallucinations and misspellings on proper nouns.
  357. - The segments preview on the results page shows the first 5 segments. The full content is in the downloaded file.
  358. """
  359. @app.get("/skill", response_class=PlainTextResponse)
  360. def skill_guide():
  361. return PlainTextResponse(content=_SKILL_MD, media_type="text/markdown")
  362. # ---------------------------------------------------------------------------
  363. # Pydantic request models
  364. # ---------------------------------------------------------------------------
  365. class LoginRequest(BaseModel):
  366. email: str
  367. password: str
  368. class ChangePasswordRequest(BaseModel):
  369. new_password: str
  370. current_password: Optional[str] = None
  371. class CreateUserRequest(BaseModel):
  372. email: str
  373. name: str
  374. password: str
  375. role: str = "user"
  376. class ResetPasswordRequest(BaseModel):
  377. new_password: str
  378. # ---------------------------------------------------------------------------
  379. # Auth endpoints
  380. # ---------------------------------------------------------------------------
  381. @app.get("/auth/verify")
  382. def auth_verify(
  383. x_session_token: Optional[str] = Header(None),
  384. x_api_key: Optional[str] = Header(None),
  385. ):
  386. if x_session_token:
  387. session = _get_session(x_session_token)
  388. if session:
  389. users = _load_users()
  390. user = users.get(session["user_id"])
  391. is_default = user.get("is_default_password", False) if user else False
  392. return {
  393. "email": session["email"],
  394. "name": session["name"],
  395. "role": session["role"],
  396. "is_default_password": is_default,
  397. }
  398. if x_api_key:
  399. keys = _load_keys()
  400. if x_api_key in keys:
  401. info = keys[x_api_key]
  402. return {"email": info["email"], "name": info.get("name", ""), "role": "user", "is_default_password": False}
  403. raise HTTPException(status_code=401, detail="Invalid credentials")
  404. @app.post("/auth/login")
  405. def auth_login(req: LoginRequest):
  406. users = _load_users()
  407. user = next((u for u in users.values() if u["email"] == req.email), None)
  408. if not user or not _verify_password(req.password, user["password_hash"], user["password_salt"]):
  409. raise HTTPException(status_code=401, detail="Invalid email or password")
  410. token = _create_session(user)
  411. return {
  412. "session_token": token,
  413. "user": {"email": user["email"], "name": user["name"], "role": user["role"]},
  414. "is_default_password": user.get("is_default_password", False),
  415. }
  416. @app.post("/auth/logout")
  417. def auth_logout(x_session_token: Optional[str] = Header(None)):
  418. if x_session_token:
  419. with _sessions_lock:
  420. _sessions.pop(x_session_token, None)
  421. return {"message": "Logged out"}
  422. @app.post("/auth/change-password")
  423. def auth_change_password(req: ChangePasswordRequest, session: dict = Depends(verify_session)):
  424. users = _load_users()
  425. user = users.get(session["user_id"])
  426. if not user:
  427. raise HTTPException(status_code=404, detail="User not found")
  428. if not user.get("is_default_password"):
  429. if not req.current_password:
  430. raise HTTPException(status_code=400, detail="current_password required")
  431. if not _verify_password(req.current_password, user["password_hash"], user["password_salt"]):
  432. raise HTTPException(status_code=401, detail="Wrong current password")
  433. if len(req.new_password) < 8:
  434. raise HTTPException(status_code=400, detail="Password must be at least 8 characters")
  435. pw_hash, pw_salt = _hash_password(req.new_password)
  436. user["password_hash"] = pw_hash
  437. user["password_salt"] = pw_salt
  438. user["is_default_password"] = False
  439. _save_users(users)
  440. return {"message": "Password changed"}
  441. # ---------------------------------------------------------------------------
  442. # Admin endpoints
  443. # ---------------------------------------------------------------------------
  444. @app.get("/admin/users")
  445. def admin_list_users(_admin: dict = Depends(require_admin)):
  446. users = _load_users()
  447. keys = _load_keys()
  448. email_to_key = {v["email"]: k for k, v in keys.items()}
  449. return [
  450. {
  451. "id": u["id"],
  452. "email": u["email"],
  453. "name": u["name"],
  454. "role": u["role"],
  455. "is_default_password": u.get("is_default_password", False),
  456. "created_at": u.get("created_at", ""),
  457. "has_api_key": u["email"] in email_to_key,
  458. }
  459. for u in users.values()
  460. ]
  461. @app.post("/admin/users", status_code=201)
  462. def admin_create_user(req: CreateUserRequest, _admin: dict = Depends(require_admin)):
  463. if req.role not in ("user", "admin"):
  464. raise HTTPException(status_code=400, detail="role must be 'user' or 'admin'")
  465. users = _load_users()
  466. if any(u["email"] == req.email for u in users.values()):
  467. raise HTTPException(status_code=409, detail="Email already exists")
  468. user_id = str(uuid.uuid4())
  469. pw_hash, pw_salt = _hash_password(req.password)
  470. users[user_id] = {
  471. "id": user_id,
  472. "email": req.email,
  473. "name": req.name,
  474. "role": req.role,
  475. "password_hash": pw_hash,
  476. "password_salt": pw_salt,
  477. "is_default_password": True,
  478. "created_at": datetime.now().isoformat(),
  479. }
  480. _save_users(users)
  481. return {"id": user_id, "email": req.email, "name": req.name, "role": req.role}
  482. @app.delete("/admin/users/{user_id}", status_code=204)
  483. def admin_delete_user(user_id: str, session: dict = Depends(require_admin)):
  484. users = _load_users()
  485. if user_id not in users:
  486. raise HTTPException(status_code=404, detail="User not found")
  487. if users[user_id]["email"] == session["email"]:
  488. raise HTTPException(status_code=400, detail="Cannot delete your own account")
  489. del users[user_id]
  490. _save_users(users)
  491. return Response(status_code=204)
  492. @app.patch("/admin/users/{user_id}/reset-password")
  493. def admin_reset_password(user_id: str, req: ResetPasswordRequest, _admin: dict = Depends(require_admin)):
  494. users = _load_users()
  495. if user_id not in users:
  496. raise HTTPException(status_code=404, detail="User not found")
  497. pw_hash, pw_salt = _hash_password(req.new_password)
  498. users[user_id]["password_hash"] = pw_hash
  499. users[user_id]["password_salt"] = pw_salt
  500. users[user_id]["is_default_password"] = True
  501. _save_users(users)
  502. return {"message": "Password reset"}
  503. @app.post("/admin/users/{user_id}/api-key")
  504. def admin_generate_api_key(user_id: str, _admin: dict = Depends(require_admin)):
  505. users = _load_users()
  506. if user_id not in users:
  507. raise HTTPException(status_code=404, detail="User not found")
  508. user = users[user_id]
  509. keys = _load_keys()
  510. for k in [k for k, v in keys.items() if v["email"] == user["email"]]:
  511. del keys[k]
  512. new_key = "tk_" + secrets.token_hex(24)
  513. keys[new_key] = {"email": user["email"], "name": user["name"], "created_at": datetime.now().isoformat()}
  514. _save_keys(keys)
  515. return {"api_key": new_key}
  516. @app.delete("/admin/users/{user_id}/api-key", status_code=204)
  517. def admin_revoke_api_key(user_id: str, _admin: dict = Depends(require_admin)):
  518. users = _load_users()
  519. if user_id not in users:
  520. raise HTTPException(status_code=404, detail="User not found")
  521. user = users[user_id]
  522. keys = _load_keys()
  523. for k in [k for k, v in keys.items() if v["email"] == user["email"]]:
  524. del keys[k]
  525. _save_keys(keys)
  526. return Response(status_code=204)
  527. @app.get("/admin/metrics")
  528. def admin_metrics(_admin: dict = Depends(require_admin)):
  529. with _jobs_lock:
  530. jobs = list(_jobs.values())
  531. by_status: dict = {}
  532. by_model: dict = {}
  533. by_language: dict = {}
  534. by_user: dict = {}
  535. times = []
  536. for j in jobs:
  537. s = j.get("status", "unknown")
  538. by_status[s] = by_status.get(s, 0) + 1
  539. m = j.get("model", "unknown")
  540. by_model[m] = by_model.get(m, 0) + 1
  541. lang = j.get("language", "unknown")
  542. by_language[lang] = by_language.get(lang, 0) + 1
  543. u = j.get("submitted_by", "api")
  544. by_user[u] = by_user.get(u, 0) + 1
  545. if j.get("transcription_time"):
  546. times.append(j["transcription_time"])
  547. return {
  548. "total_jobs": len(jobs),
  549. "by_status": by_status,
  550. "by_model": by_model,
  551. "by_language": by_language,
  552. "by_user": by_user,
  553. "avg_transcription_time": round(sum(times) / len(times), 1) if times else None,
  554. }
  555. # ---------------------------------------------------------------------------
  556. # Transcription endpoints (API key OR session token)
  557. # ---------------------------------------------------------------------------
  558. @app.post("/transcribe", status_code=202)
  559. async def start_transcription(
  560. background_tasks: BackgroundTasks,
  561. file: UploadFile = File(...),
  562. model: str = Form("large-v3"),
  563. device: str = Form("cuda"),
  564. language: str = Form("es"),
  565. srt: bool = Form(False),
  566. txt: bool = Form(False),
  567. srt_nh: bool = Form(False),
  568. txt_nh: bool = Form(False),
  569. initial_prompt: str = Form(""),
  570. user: dict = Depends(verify_any_auth),
  571. ):
  572. job_id = str(uuid.uuid4())
  573. job_dir = os.path.join(JOBS_DIR, job_id)
  574. os.makedirs(job_dir)
  575. file_path = os.path.join(job_dir, file.filename or "upload")
  576. content = await file.read()
  577. with open(file_path, "wb") as f:
  578. f.write(content)
  579. with _jobs_lock:
  580. _jobs[job_id] = {
  581. "job_id": job_id,
  582. "status": "pending",
  583. "filename": file.filename,
  584. "model": model,
  585. "language": language,
  586. "submitted_by": user.get("email", "unknown"),
  587. "created_at": datetime.now().isoformat(),
  588. "error": None,
  589. "segments": None,
  590. "results": {},
  591. }
  592. background_tasks.add_task(
  593. _run_pipeline,
  594. job_id=job_id,
  595. file_path=file_path,
  596. model=model,
  597. device=device,
  598. language=language,
  599. do_srt=srt,
  600. do_txt=txt,
  601. do_srt_nh=srt_nh,
  602. do_txt_nh=txt_nh,
  603. initial_prompt=initial_prompt,
  604. )
  605. return {"job_id": job_id, "status": "pending"}
  606. @app.get("/admin/jobs")
  607. def admin_list_jobs(_admin: dict = Depends(require_admin)):
  608. with _jobs_lock:
  609. out = []
  610. for job in _jobs.values():
  611. row = {k: v for k, v in job.items() if k not in ("segments", "results")}
  612. row["formats"] = list(job.get("results", {}).keys())
  613. out.append(row)
  614. return sorted(out, key=lambda j: j.get("created_at", ""), reverse=True)
  615. @app.get("/jobs")
  616. def list_jobs(user: dict = Depends(verify_any_auth)):
  617. user_email = user.get("email", "")
  618. with _jobs_lock:
  619. out = []
  620. for job in _jobs.values():
  621. if job.get("submitted_by") != user_email:
  622. continue
  623. row = {k: v for k, v in job.items() if k not in ("segments", "results")}
  624. row["formats"] = list(job.get("results", {}).keys())
  625. out.append(row)
  626. return out
  627. @app.get("/jobs/{job_id}")
  628. def get_job(job_id: str, user: dict = Depends(verify_any_auth)):
  629. with _jobs_lock:
  630. job = _jobs.get(job_id)
  631. if job is None:
  632. raise HTTPException(status_code=404, detail="Job not found")
  633. return job
  634. @app.get("/jobs/{job_id}/download/{fmt}")
  635. def download_result(
  636. job_id: str,
  637. fmt: Literal["srt", "txt", "srt_nh", "txt_nh"],
  638. user: dict = Depends(verify_any_auth),
  639. ):
  640. with _jobs_lock:
  641. job = _jobs.get(job_id)
  642. if job is None:
  643. raise HTTPException(status_code=404, detail="Job not found")
  644. if job["status"] != "completed":
  645. raise HTTPException(status_code=400, detail=f"Job is '{job['status']}', not completed")
  646. if fmt not in job["results"]:
  647. raise HTTPException(status_code=404, detail=f"Format '{fmt}' was not requested for this job")
  648. ext = fmt.split("_")[0]
  649. filename = f"{os.path.splitext(job['filename'])[0]}_{fmt}.{ext}"
  650. return PlainTextResponse(
  651. content=job["results"][fmt],
  652. headers={"Content-Disposition": f'attachment; filename="{filename}"'},
  653. )
  654. @app.delete("/jobs/{job_id}", status_code=204)
  655. def delete_job(job_id: str, user: dict = Depends(verify_any_auth)):
  656. with _jobs_lock:
  657. if job_id not in _jobs:
  658. raise HTTPException(status_code=404, detail="Job not found")
  659. del _jobs[job_id]
  660. path = os.path.join(PERSIST_DIR, f"{job_id}.json")
  661. if os.path.exists(path):
  662. os.remove(path)
  663. return Response(status_code=204)
  664. # ---------------------------------------------------------------------------
  665. # Static frontend — mounted last so API routes take precedence
  666. # ---------------------------------------------------------------------------
  667. STATIC_DIR = os.path.join(os.path.dirname(__file__), "static")
  668. @app.get("/")
  669. def index():
  670. return FileResponse(os.path.join(STATIC_DIR, "index.html"))
  671. app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")