ソースを参照

initial commit

Erwin Jacimino 17 時間 前
コミット
7e64f29413
12 ファイル変更3580 行追加0 行削除
  1. 8 0
      .gitignore
  2. 0 0
      core/__init__.py
  3. 62 0
      core/combine.py
  4. 17 0
      core/diarize.py
  5. 36 0
      core/formats.py
  6. 24 0
      core/transcribe.py
  7. 48 0
      create_api_key.py
  8. 70 0
      create_user.py
  9. 850 0
      main.py
  10. 5 0
      requirements.txt
  11. 2427 0
      static/index.html
  12. 33 0
      workers/diarize_worker.py

+ 8 - 0
.gitignore

@@ -0,0 +1,8 @@
+graphify-out
+test-audios
+__pycache__
+.env
+.claude
+users.json
+api_keys.json
+jobs

+ 0 - 0
core/__init__.py


+ 62 - 0
core/combine.py

@@ -0,0 +1,62 @@
+from faster_whisper.transcribe import Segment
+
+
+def combine(whisper_segments: list[Segment], diarization_turns: list[dict]) -> list[dict]:
+    raw: list[dict] = []
+
+    for segment in whisper_segments:
+        if not segment.words:
+            continue
+
+        current_speaker = None
+        current_text = ""
+        seg_start = segment.words[0].start
+
+        for word in segment.words:
+            mid = word.start + (word.end - word.start) / 2
+            speaker = None
+
+            for turn in diarization_turns:
+                if turn["start"] <= mid < turn["end"]:
+                    speaker = turn["speaker"]
+                    break
+
+            if not speaker:
+                if current_speaker:
+                    speaker = current_speaker
+                else:
+                    closest = min(
+                        diarization_turns,
+                        key=lambda t: min(abs(t["start"] - mid), abs(t["end"] - mid)),
+                    )
+                    speaker = closest["speaker"]
+
+            if current_speaker is None:
+                current_speaker = speaker
+                seg_start = word.start
+                current_text = word.word
+            elif speaker != current_speaker:
+                raw.append({"start": seg_start, "end": word.start, "speaker": current_speaker, "text": current_text.strip()})
+                current_speaker = speaker
+                seg_start = word.start
+                current_text = word.word
+            else:
+                current_text += " " + word.word
+
+        if current_text:
+            raw.append({"start": seg_start, "end": segment.end, "speaker": current_speaker, "text": current_text.strip()})
+
+    # Merge consecutive same-speaker segments within 0.5s gap
+    merged: list[dict] = []
+    if raw:
+        curr = raw[0]
+        for nxt in raw[1:]:
+            if nxt["speaker"] == curr["speaker"] and (nxt["start"] - curr["end"]) < 0.5:
+                curr["text"] += " " + nxt["text"]
+                curr["end"] = nxt["end"]
+            else:
+                merged.append(curr)
+                curr = nxt
+        merged.append(curr)
+
+    return merged

+ 17 - 0
core/diarize.py

@@ -0,0 +1,17 @@
+import json
+import subprocess
+import os
+
+WORKER_SCRIPT = os.path.join(os.path.dirname(__file__), "..", "workers", "diarize_worker.py")
+
+
+def diarize(audio_file: str) -> list[dict] | None:
+    script = os.path.abspath(WORKER_SCRIPT)
+    result = subprocess.run(
+        [script, audio_file],
+        capture_output=True,
+        text=True,
+        encoding="utf-8",
+        check=True,
+    )
+    return json.loads(result.stdout)

+ 36 - 0
core/formats.py

@@ -0,0 +1,36 @@
+def _srt_time(seconds: float) -> str:
+    total = int(seconds)
+    ms = int((seconds - total) * 1000)
+    h, m, s = total // 3600, (total % 3600) // 60, total % 60
+    return f"{h:02}:{m:02}:{s:02},{ms:03d}"
+
+
+def _segment_fields(segment) -> tuple:
+    if isinstance(segment, dict):
+        return segment["start"], segment["end"], segment["text"], segment.get("speaker")
+    return segment.start, segment.end, segment.text, None
+
+
+def to_srt(segments: list, with_speaker: bool = True) -> str:
+    lines = []
+    for i, seg in enumerate(segments, start=1):
+        start, end, text, speaker = _segment_fields(seg)
+        lines.append(str(i))
+        lines.append(f"{_srt_time(start)} --> {_srt_time(end)}")
+        if with_speaker and speaker:
+            lines.append(f"({speaker}): {text.strip()}")
+        else:
+            lines.append(text.strip())
+        lines.append("")
+    return "\n".join(lines)
+
+
+def to_txt(segments: list, with_speaker: bool = True) -> str:
+    lines = []
+    for seg in segments:
+        _, _, text, speaker = _segment_fields(seg)
+        if with_speaker and speaker:
+            lines.append(f"({speaker}): {text.strip()}")
+        else:
+            lines.append(text.strip())
+    return "\n".join(lines)

+ 24 - 0
core/transcribe.py

@@ -0,0 +1,24 @@
+import time
+import faster_whisper
+from faster_whisper.transcribe import Segment
+
+
+def transcribe(
+    file: str,
+    model_name: str = "large-v3",
+    device: str = "cuda",
+    language: str = "es",
+    download_root: str = "/home/superti/workspace/models",
+    initial_prompt: str = "",
+) -> tuple[list[Segment], float]:
+    whisper = faster_whisper.WhisperModel(model_name, device=device, download_root=download_root)
+    start = time.time()
+    trns, _ = whisper.transcribe(
+        file,
+        word_timestamps=True,
+        language=language,
+        initial_prompt=initial_prompt or None,
+    )
+    segments = list(trns)
+    elapsed = time.time() - start
+    return segments, elapsed

+ 48 - 0
create_api_key.py

@@ -0,0 +1,48 @@
+#!/usr/bin/env python3
+"""
+Usage: python create_api_key.py <email> <name>
+Creates an API key for the given user and stores it in api_keys.json.
+"""
+import json
+import os
+import secrets
+import sys
+from datetime import datetime
+
+KEYS_FILE = os.path.join(os.path.dirname(__file__), "api_keys.json")
+
+
+def create_key(email: str, name: str) -> str:
+    keys: dict = {}
+    if os.path.exists(KEYS_FILE):
+        with open(KEYS_FILE) as f:
+            keys = json.load(f)
+
+    # Check for duplicate email
+    for k, v in keys.items():
+        if v["email"] == email:
+            print(f"[!] A key already exists for {email}: {k}")
+            print("    Delete it from api_keys.json first if you want a new one.")
+            sys.exit(1)
+
+    key = "tk_" + secrets.token_hex(24)
+    keys[key] = {
+        "email": email,
+        "name": name,
+        "created_at": datetime.now().isoformat(),
+    }
+
+    with open(KEYS_FILE, "w") as f:
+        json.dump(keys, f, indent=2)
+
+    print(f"\n✓ API key created for {name} <{email}>")
+    print(f"\n  Key: {key}\n")
+    print("  Share the key with the user — it cannot be recovered later.")
+    return key
+
+
+if __name__ == "__main__":
+    if len(sys.argv) != 3:
+        print("Usage: python create_api_key.py <email> <name>")
+        sys.exit(1)
+    create_key(sys.argv[1], sys.argv[2])

+ 70 - 0
create_user.py

@@ -0,0 +1,70 @@
+#!/usr/bin/env python3
+"""
+Usage: python create_user.py <email> <name> <password> [--admin]
+
+Creates a GUI user account in users.json.
+The user will be required to change their password on first login.
+Use --admin to grant admin privileges.
+"""
+import hashlib
+import json
+import os
+import secrets
+import sys
+import uuid
+from datetime import datetime
+
+USERS_FILE = os.path.join(os.path.dirname(__file__), "users.json")
+
+
+def hash_password(password: str, salt: str | None = None) -> tuple:
+    if salt is None:
+        salt = secrets.token_hex(16)
+    key = hashlib.pbkdf2_hmac("sha256", password.encode(), salt.encode(), 200_000)
+    return key.hex(), salt
+
+
+def create_user(email: str, name: str, password: str, role: str = "user") -> str:
+    users: dict = {}
+    if os.path.exists(USERS_FILE):
+        with open(USERS_FILE) as f:
+            users = json.load(f)
+
+    for u in users.values():
+        if u["email"] == email:
+            print(f"[!] A user already exists with email {email}")
+            print("    Delete the entry from users.json first if you want to recreate it.")
+            sys.exit(1)
+
+    user_id = str(uuid.uuid4())
+    pw_hash, pw_salt = hash_password(password)
+    users[user_id] = {
+        "id": user_id,
+        "email": email,
+        "name": name,
+        "role": role,
+        "password_hash": pw_hash,
+        "password_salt": pw_salt,
+        "is_default_password": True,
+        "created_at": datetime.now().isoformat(),
+    }
+
+    with open(USERS_FILE, "w") as f:
+        json.dump(users, f, indent=2)
+
+    print(f"\n✓ User created: {name} <{email}> [{role}]")
+    print(f"  Default password: {password}")
+    print(f"  The user will be prompted to change it on first login.\n")
+    return user_id
+
+
+if __name__ == "__main__":
+    if len(sys.argv) < 4:
+        print("Usage: python create_user.py <email> <name> <password> [--admin]")
+        sys.exit(1)
+
+    _email    = sys.argv[1]
+    _name     = sys.argv[2]
+    _password = sys.argv[3]
+    _role     = "admin" if "--admin" in sys.argv else "user"
+    create_user(_email, _name, _password, _role)

+ 850 - 0
main.py

@@ -0,0 +1,850 @@
+import hashlib
+import json
+import os
+import secrets
+import shutil
+import subprocess
+import tempfile
+import threading
+import uuid
+from datetime import datetime, timedelta
+from typing import Literal, Optional
+
+import dotenv
+from fastapi import BackgroundTasks, Depends, FastAPI, File, Form, Header, HTTPException, UploadFile
+from fastapi.responses import PlainTextResponse, Response, FileResponse
+from fastapi.staticfiles import StaticFiles
+from pydantic import BaseModel
+
+from core.combine import combine
+from core.diarize import diarize
+from core.formats import to_srt, to_txt
+from core.transcribe import transcribe
+
+dotenv.load_dotenv()
+
+app = FastAPI(title="Transcriptor API", description="Audio/Video transcription and speaker diarization")
+
+# ---------------------------------------------------------------------------
+# API key store (direct REST access)
+# ---------------------------------------------------------------------------
+
+KEYS_FILE = os.path.join(os.path.dirname(__file__), "api_keys.json")
+
+
+def _load_keys() -> dict:
+    if os.path.exists(KEYS_FILE):
+        with open(KEYS_FILE) as f:
+            return json.load(f)
+    return {}
+
+
+def _save_keys(keys: dict):
+    with open(KEYS_FILE, "w") as f:
+        json.dump(keys, f, indent=2)
+
+
+# ---------------------------------------------------------------------------
+# User store (GUI accounts with passwords)
+# ---------------------------------------------------------------------------
+
+USERS_FILE = os.path.join(os.path.dirname(__file__), "users.json")
+_users_lock = threading.Lock()
+
+
+def _load_users() -> dict:
+    if os.path.exists(USERS_FILE):
+        with open(USERS_FILE) as f:
+            return json.load(f)
+    return {}
+
+
+def _save_users(users: dict):
+    with _users_lock:
+        with open(USERS_FILE, "w", encoding="utf-8") as f:
+            json.dump(users, f, indent=2, ensure_ascii=False)
+
+
+def _hash_password(password: str, salt: Optional[str] = None) -> tuple[str, str]:
+    if salt is None:
+        salt = secrets.token_hex(16)
+    key = hashlib.pbkdf2_hmac("sha256", password.encode(), salt.encode(), 200_000)
+    return key.hex(), salt
+
+
+def _verify_password(password: str, stored_hash: str, salt: str) -> bool:
+    computed, _ = _hash_password(password, salt)
+    return secrets.compare_digest(computed, stored_hash)
+
+
+# ---------------------------------------------------------------------------
+# Session store (in-memory, 8-hour TTL)
+# ---------------------------------------------------------------------------
+
+_sessions: dict = {}
+_sessions_lock = threading.Lock()
+SESSION_TTL_HOURS = 8
+
+
+def _create_session(user: dict) -> str:
+    token = secrets.token_hex(32)
+    expires_at = (datetime.now() + timedelta(hours=SESSION_TTL_HOURS)).isoformat()
+    with _sessions_lock:
+        _sessions[token] = {
+            "user_id": user["id"],
+            "email": user["email"],
+            "name": user["name"],
+            "role": user["role"],
+            "expires_at": expires_at,
+        }
+    return token
+
+
+def _get_session(token: str) -> Optional[dict]:
+    with _sessions_lock:
+        session = _sessions.get(token)
+    if not session:
+        return None
+    if datetime.now() > datetime.fromisoformat(session["expires_at"]):
+        with _sessions_lock:
+            _sessions.pop(token, None)
+        return None
+    return session
+
+
+# ---------------------------------------------------------------------------
+# Auth dependencies
+# ---------------------------------------------------------------------------
+
+def verify_api_key(x_api_key: str = Header(..., description="Your API key")) -> dict:
+    keys = _load_keys()
+    if x_api_key not in keys:
+        raise HTTPException(status_code=401, detail="Invalid or missing API key")
+    return keys[x_api_key]
+
+
+def verify_session(x_session_token: Optional[str] = Header(None)) -> dict:
+    if not x_session_token:
+        raise HTTPException(status_code=401, detail="Not authenticated")
+    session = _get_session(x_session_token)
+    if not session:
+        raise HTTPException(status_code=401, detail="Invalid or expired session")
+    return session
+
+
+def require_admin(session: dict = Depends(verify_session)) -> dict:
+    if session.get("role") != "admin":
+        raise HTTPException(status_code=403, detail="Admin access required")
+    return session
+
+
+def verify_any_auth(
+    x_api_key: Optional[str] = Header(None),
+    x_session_token: Optional[str] = Header(None),
+) -> dict:
+    if x_session_token:
+        session = _get_session(x_session_token)
+        if session:
+            return session
+    if x_api_key:
+        keys = _load_keys()
+        if x_api_key in keys:
+            info = keys[x_api_key]
+            return {"user_id": None, "email": info["email"], "name": info.get("name", ""), "role": "user"}
+    raise HTTPException(status_code=401, detail="Authentication required")
+
+
+# ---------------------------------------------------------------------------
+# Persistent job store
+# ---------------------------------------------------------------------------
+
+_jobs: dict = {}
+_jobs_lock = threading.Lock()
+
+JOBS_DIR    = os.path.join(tempfile.gettempdir(), "transcriptor_jobs")
+PERSIST_DIR = os.path.join(os.path.dirname(__file__), "jobs")
+os.makedirs(JOBS_DIR,    exist_ok=True)
+os.makedirs(PERSIST_DIR, exist_ok=True)
+
+
+def _persist_job(job: dict):
+    path = os.path.join(PERSIST_DIR, f"{job['job_id']}.json")
+    with open(path, "w", encoding="utf-8") as f:
+        json.dump(job, f, ensure_ascii=False, indent=2)
+
+
+def _load_persisted_jobs():
+    for fname in os.listdir(PERSIST_DIR):
+        if not fname.endswith(".json"):
+            continue
+        try:
+            with open(os.path.join(PERSIST_DIR, fname), encoding="utf-8") as f:
+                job = json.load(f)
+            _jobs[job["job_id"]] = job
+        except Exception:
+            pass
+
+
+def _update_job(job_id: str, **kwargs):
+    with _jobs_lock:
+        _jobs[job_id].update(kwargs)
+        job = dict(_jobs[job_id])
+    if job.get("status") in ("completed", "failed"):
+        _persist_job(job)
+
+
+_load_persisted_jobs()
+
+
+# ---------------------------------------------------------------------------
+# Pipeline helpers
+# ---------------------------------------------------------------------------
+
+def _video_to_audio(src: str, dest: str):
+    subprocess.run(
+        ["ffmpeg", "-i", src, "-vn", "-acodec", "pcm_s16le", "-ar", "16000", "-y", dest],
+        stdout=subprocess.DEVNULL,
+        stderr=subprocess.DEVNULL,
+        check=True,
+    )
+
+
+def _run_pipeline(
+    job_id: str,
+    file_path: str,
+    model: str,
+    device: str,
+    language: str,
+    do_srt: bool,
+    do_txt: bool,
+    do_srt_nh: bool,
+    do_txt_nh: bool,
+    initial_prompt: str = "",
+):
+    try:
+        audio_path = file_path
+        if not file_path.lower().endswith(".wav"):
+            wav_path = file_path.rsplit(".", 1)[0] + "_converted.wav"
+            _update_job(job_id, status="converting")
+            _video_to_audio(file_path, wav_path)
+            audio_path = wav_path
+
+        _update_job(job_id, status="transcribing")
+        segments, elapsed = transcribe(audio_path, model, device, language, initial_prompt=initial_prompt)
+        if not segments:
+            _update_job(job_id, status="failed", error="Transcription returned no segments")
+            return
+
+        results: dict = {}
+        raw_segments_json = [
+            {"start": s.start, "end": s.end, "text": s.text}
+            for s in segments
+        ]
+
+        if do_txt_nh:
+            results["txt_nh"] = to_txt(segments, with_speaker=False)
+        if do_srt_nh:
+            results["srt_nh"] = to_srt(segments, with_speaker=False)
+
+        if do_srt or do_txt:
+            _update_job(job_id, status="diarizing")
+            diarization = diarize(audio_path)
+            if diarization is None:
+                _update_job(job_id, status="failed", error="Diarization failed")
+                return
+
+            _update_job(job_id, status="combining")
+            final_segments = combine(segments, diarization)
+
+            if do_srt:
+                results["srt"] = to_srt(final_segments, with_speaker=True)
+            if do_txt:
+                results["txt"] = to_txt(final_segments, with_speaker=True)
+
+            raw_segments_json = [
+                {"start": s["start"], "end": s["end"], "text": s["text"], "speaker": s["speaker"]}
+                for s in final_segments
+            ]
+
+        _update_job(
+            job_id,
+            status="completed",
+            transcription_time=round(elapsed, 2),
+            segments=raw_segments_json,
+            results=results,
+        )
+
+    except Exception as exc:
+        _update_job(job_id, status="failed", error=str(exc))
+    finally:
+        job_dir = os.path.join(JOBS_DIR, job_id)
+        if os.path.exists(job_dir):
+            shutil.rmtree(job_dir, ignore_errors=True)
+
+
+# ---------------------------------------------------------------------------
+# Skill / usage guide
+# ---------------------------------------------------------------------------
+
+_SKILL_MD = """# Transcriptor — Usage Guide
+
+## What is this?
+
+Transcriptor is an audio and video transcription API with speaker diarization.
+It converts speech to text (Whisper) and identifies who is speaking at each moment (PyAnnote).
+
+---
+
+## Authentication
+
+### Web Interface
+Log in with your **email** and **password**. Your session is saved in the browser.
+On first login, you will be prompted to set a new password.
+
+### REST API
+Every request requires an `X-API-Key` header.
+API keys are managed by an administrator via the Admin Panel.
+
+---
+
+## Web Interface
+
+Open the app at `http://<host>:8010/` and follow these steps:
+
+### 1 — Upload
+- Drag and drop any audio or video file onto the upload zone, or click to browse.
+- Supported formats: MP3, MP4, WAV, OGG, M4A, WebM, MKV, and more.
+- Files that are not WAV are automatically converted before processing.
+
+### 2 — Settings
+
+| Setting | Options | Default |
+|---------|---------|---------|
+| Language | es, en, pt, fr, de, it, ja, zh, auto | es |
+| Model | large-v3, large-v2, medium, small, base | large-v3 |
+
+**Output formats** (select one or more):
+
+| Format | Description |
+|--------|-------------|
+| `.txt` | Plain text transcript with speaker labels |
+| `.srt` | Subtitle file with timestamps and speaker labels |
+| `.txt (no spk)` | Plain text without speaker labels |
+| `.srt (no spk)` | Subtitle file without speaker labels |
+
+> Formats with speaker labels trigger diarization (slower).
+> No-speaker formats skip diarization and finish faster.
+
+**Initial Prompt (optional)**
+
+Expand the "Initial Prompt" field to provide Whisper with context before transcription begins.
+Use it for proper nouns, acronyms, technical vocabulary, or speaker names that Whisper might
+otherwise misspell. The prompt is not included in the output — it only guides the model.
+
+Example: `"Participants: Dr. Ramírez and Lic. Ortega. Topic: quarterly budget review."`
+
+### 3 — Processing
+After submitting, the job moves through these stages:
+
+```
+pending → converting → transcribing → diarizing → combining → completed
+```
+
+- **converting**: video/audio is normalised to 16 kHz WAV
+- **transcribing**: Whisper extracts word-level timestamps
+- **diarizing**: PyAnnote identifies each speaker turn
+- **combining**: words are aligned to speaker turns and merged
+
+### 4 — Results
+Once completed, download buttons appear for each requested format.
+Click any button to download the file instantly.
+You can also preview the first segments with timestamps and speaker labels directly on the page.
+
+---
+
+## Jobs History
+
+The history table at the bottom of the page shows **only your own jobs**.
+- Click **↻ Refresh** to reload from the server.
+- Click any **completed** row to restore its results view.
+- Use the format buttons in each row to re-download files from previous jobs.
+
+Jobs are persisted on disk and survive server restarts.
+
+---
+
+## Admin Panel
+
+The Admin Panel is accessible to admin users via the **Admin Panel** button in the top navigation.
+
+### Users tab
+- View all registered users with their roles, password status, and API key assignment.
+- **Create User**: set email, name, default password, and role.
+- **Reset Password**: assign a new default password (user is forced to change on next login).
+- **Generate / Revoke API Key**: manage REST API access per user.
+- **Delete User**: permanently remove an account.
+
+### History tab
+- Global view of **all jobs across all accounts**, with the submitting user shown per row.
+- Columns: File, User, Status, Language, Model, Time, Created.
+- Click **↻ Refresh** to reload.
+
+### Metrics tab
+- Overview of total jobs processed.
+- Breakdown by status, model, language, and user.
+- Average transcription time.
+
+---
+
+## REST API
+
+All endpoints require `X-API-Key` header.
+
+### Verify credentials
+```
+GET /auth/verify
+```
+
+### Submit a transcription job
+```
+POST /transcribe
+Content-Type: multipart/form-data
+
+file           — audio or video file (required)
+language       — language code, default: es
+model          — whisper model, default: large-v3
+device         — cuda or cpu, default: cuda
+txt            — true/false, default: false
+srt            — true/false, default: false
+txt_nh         — true/false, default: false
+srt_nh         — true/false, default: false
+initial_prompt — text hint passed to Whisper before transcription, default: (empty)
+```
+
+Response: `{"job_id": "...", "status": "pending"}`
+
+### Poll job status
+```
+GET /jobs/{job_id}
+```
+
+Returns the full job object including segments and results when completed.
+
+### List your jobs
+```
+GET /jobs
+```
+
+Returns jobs submitted by the authenticated account only (no file content).
+
+### Download a result file
+```
+GET /jobs/{job_id}/download/{fmt}
+```
+
+`fmt` is one of: `txt`, `srt`, `txt_nh`, `srt_nh`
+
+### Delete a job
+```
+DELETE /jobs/{job_id}
+```
+
+Removes the job from memory and from disk.
+
+### Admin — list all jobs (admin only)
+```
+GET /admin/jobs
+```
+
+Returns all jobs across all accounts, sorted by creation date descending.
+Each entry includes `submitted_by` (email of the submitting account).
+
+### This guide
+```
+GET /skill
+```
+
+Returns this markdown document.
+
+---
+
+## Tips
+
+- Use `large-v3` for best accuracy. Use `small` or `base` for faster results on short clips.
+- Set `language` explicitly — auto-detection adds latency.
+- If you only need a transcript without identifying speakers, use `txt_nh` or `srt_nh`; it skips the diarization step and finishes much faster.
+- Use `initial_prompt` when you know the topic, speaker names, or domain vocabulary upfront — it measurably reduces hallucinations and misspellings on proper nouns.
+- The segments preview on the results page shows the first 5 segments. The full content is in the downloaded file.
+"""
+
+
+@app.get("/skill", response_class=PlainTextResponse)
+def skill_guide():
+    return PlainTextResponse(content=_SKILL_MD, media_type="text/markdown")
+
+
+# ---------------------------------------------------------------------------
+# Pydantic request models
+# ---------------------------------------------------------------------------
+
+class LoginRequest(BaseModel):
+    email: str
+    password: str
+
+
+class ChangePasswordRequest(BaseModel):
+    new_password: str
+    current_password: Optional[str] = None
+
+
+class CreateUserRequest(BaseModel):
+    email: str
+    name: str
+    password: str
+    role: str = "user"
+
+
+class ResetPasswordRequest(BaseModel):
+    new_password: str
+
+
+# ---------------------------------------------------------------------------
+# Auth endpoints
+# ---------------------------------------------------------------------------
+
+@app.get("/auth/verify")
+def auth_verify(
+    x_session_token: Optional[str] = Header(None),
+    x_api_key: Optional[str] = Header(None),
+):
+    if x_session_token:
+        session = _get_session(x_session_token)
+        if session:
+            users = _load_users()
+            user = users.get(session["user_id"])
+            is_default = user.get("is_default_password", False) if user else False
+            return {
+                "email": session["email"],
+                "name": session["name"],
+                "role": session["role"],
+                "is_default_password": is_default,
+            }
+    if x_api_key:
+        keys = _load_keys()
+        if x_api_key in keys:
+            info = keys[x_api_key]
+            return {"email": info["email"], "name": info.get("name", ""), "role": "user", "is_default_password": False}
+    raise HTTPException(status_code=401, detail="Invalid credentials")
+
+
+@app.post("/auth/login")
+def auth_login(req: LoginRequest):
+    users = _load_users()
+    user = next((u for u in users.values() if u["email"] == req.email), None)
+    if not user or not _verify_password(req.password, user["password_hash"], user["password_salt"]):
+        raise HTTPException(status_code=401, detail="Invalid email or password")
+    token = _create_session(user)
+    return {
+        "session_token": token,
+        "user": {"email": user["email"], "name": user["name"], "role": user["role"]},
+        "is_default_password": user.get("is_default_password", False),
+    }
+
+
+@app.post("/auth/logout")
+def auth_logout(x_session_token: Optional[str] = Header(None)):
+    if x_session_token:
+        with _sessions_lock:
+            _sessions.pop(x_session_token, None)
+    return {"message": "Logged out"}
+
+
+@app.post("/auth/change-password")
+def auth_change_password(req: ChangePasswordRequest, session: dict = Depends(verify_session)):
+    users = _load_users()
+    user = users.get(session["user_id"])
+    if not user:
+        raise HTTPException(status_code=404, detail="User not found")
+    if not user.get("is_default_password"):
+        if not req.current_password:
+            raise HTTPException(status_code=400, detail="current_password required")
+        if not _verify_password(req.current_password, user["password_hash"], user["password_salt"]):
+            raise HTTPException(status_code=401, detail="Wrong current password")
+    if len(req.new_password) < 8:
+        raise HTTPException(status_code=400, detail="Password must be at least 8 characters")
+    pw_hash, pw_salt = _hash_password(req.new_password)
+    user["password_hash"] = pw_hash
+    user["password_salt"] = pw_salt
+    user["is_default_password"] = False
+    _save_users(users)
+    return {"message": "Password changed"}
+
+
+# ---------------------------------------------------------------------------
+# Admin endpoints
+# ---------------------------------------------------------------------------
+
+@app.get("/admin/users")
+def admin_list_users(_admin: dict = Depends(require_admin)):
+    users = _load_users()
+    keys = _load_keys()
+    email_to_key = {v["email"]: k for k, v in keys.items()}
+    return [
+        {
+            "id": u["id"],
+            "email": u["email"],
+            "name": u["name"],
+            "role": u["role"],
+            "is_default_password": u.get("is_default_password", False),
+            "created_at": u.get("created_at", ""),
+            "has_api_key": u["email"] in email_to_key,
+        }
+        for u in users.values()
+    ]
+
+
+@app.post("/admin/users", status_code=201)
+def admin_create_user(req: CreateUserRequest, _admin: dict = Depends(require_admin)):
+    if req.role not in ("user", "admin"):
+        raise HTTPException(status_code=400, detail="role must be 'user' or 'admin'")
+    users = _load_users()
+    if any(u["email"] == req.email for u in users.values()):
+        raise HTTPException(status_code=409, detail="Email already exists")
+    user_id = str(uuid.uuid4())
+    pw_hash, pw_salt = _hash_password(req.password)
+    users[user_id] = {
+        "id": user_id,
+        "email": req.email,
+        "name": req.name,
+        "role": req.role,
+        "password_hash": pw_hash,
+        "password_salt": pw_salt,
+        "is_default_password": True,
+        "created_at": datetime.now().isoformat(),
+    }
+    _save_users(users)
+    return {"id": user_id, "email": req.email, "name": req.name, "role": req.role}
+
+
+@app.delete("/admin/users/{user_id}", status_code=204)
+def admin_delete_user(user_id: str, session: dict = Depends(require_admin)):
+    users = _load_users()
+    if user_id not in users:
+        raise HTTPException(status_code=404, detail="User not found")
+    if users[user_id]["email"] == session["email"]:
+        raise HTTPException(status_code=400, detail="Cannot delete your own account")
+    del users[user_id]
+    _save_users(users)
+    return Response(status_code=204)
+
+
+@app.patch("/admin/users/{user_id}/reset-password")
+def admin_reset_password(user_id: str, req: ResetPasswordRequest, _admin: dict = Depends(require_admin)):
+    users = _load_users()
+    if user_id not in users:
+        raise HTTPException(status_code=404, detail="User not found")
+    pw_hash, pw_salt = _hash_password(req.new_password)
+    users[user_id]["password_hash"] = pw_hash
+    users[user_id]["password_salt"] = pw_salt
+    users[user_id]["is_default_password"] = True
+    _save_users(users)
+    return {"message": "Password reset"}
+
+
+@app.post("/admin/users/{user_id}/api-key")
+def admin_generate_api_key(user_id: str, _admin: dict = Depends(require_admin)):
+    users = _load_users()
+    if user_id not in users:
+        raise HTTPException(status_code=404, detail="User not found")
+    user = users[user_id]
+    keys = _load_keys()
+    for k in [k for k, v in keys.items() if v["email"] == user["email"]]:
+        del keys[k]
+    new_key = "tk_" + secrets.token_hex(24)
+    keys[new_key] = {"email": user["email"], "name": user["name"], "created_at": datetime.now().isoformat()}
+    _save_keys(keys)
+    return {"api_key": new_key}
+
+
+@app.delete("/admin/users/{user_id}/api-key", status_code=204)
+def admin_revoke_api_key(user_id: str, _admin: dict = Depends(require_admin)):
+    users = _load_users()
+    if user_id not in users:
+        raise HTTPException(status_code=404, detail="User not found")
+    user = users[user_id]
+    keys = _load_keys()
+    for k in [k for k, v in keys.items() if v["email"] == user["email"]]:
+        del keys[k]
+    _save_keys(keys)
+    return Response(status_code=204)
+
+
+@app.get("/admin/metrics")
+def admin_metrics(_admin: dict = Depends(require_admin)):
+    with _jobs_lock:
+        jobs = list(_jobs.values())
+    by_status: dict = {}
+    by_model: dict = {}
+    by_language: dict = {}
+    by_user: dict = {}
+    times = []
+    for j in jobs:
+        s = j.get("status", "unknown")
+        by_status[s] = by_status.get(s, 0) + 1
+        m = j.get("model", "unknown")
+        by_model[m] = by_model.get(m, 0) + 1
+        lang = j.get("language", "unknown")
+        by_language[lang] = by_language.get(lang, 0) + 1
+        u = j.get("submitted_by", "api")
+        by_user[u] = by_user.get(u, 0) + 1
+        if j.get("transcription_time"):
+            times.append(j["transcription_time"])
+    return {
+        "total_jobs": len(jobs),
+        "by_status": by_status,
+        "by_model": by_model,
+        "by_language": by_language,
+        "by_user": by_user,
+        "avg_transcription_time": round(sum(times) / len(times), 1) if times else None,
+    }
+
+
+# ---------------------------------------------------------------------------
+# Transcription endpoints (API key OR session token)
+# ---------------------------------------------------------------------------
+
+@app.post("/transcribe", status_code=202)
+async def start_transcription(
+    background_tasks: BackgroundTasks,
+    file: UploadFile = File(...),
+    model: str = Form("large-v3"),
+    device: str = Form("cuda"),
+    language: str = Form("es"),
+    srt: bool = Form(False),
+    txt: bool = Form(False),
+    srt_nh: bool = Form(False),
+    txt_nh: bool = Form(False),
+    initial_prompt: str = Form(""),
+    user: dict = Depends(verify_any_auth),
+):
+    job_id = str(uuid.uuid4())
+    job_dir = os.path.join(JOBS_DIR, job_id)
+    os.makedirs(job_dir)
+
+    file_path = os.path.join(job_dir, file.filename or "upload")
+    content = await file.read()
+    with open(file_path, "wb") as f:
+        f.write(content)
+
+    with _jobs_lock:
+        _jobs[job_id] = {
+            "job_id": job_id,
+            "status": "pending",
+            "filename": file.filename,
+            "model": model,
+            "language": language,
+            "submitted_by": user.get("email", "unknown"),
+            "created_at": datetime.now().isoformat(),
+            "error": None,
+            "segments": None,
+            "results": {},
+        }
+
+    background_tasks.add_task(
+        _run_pipeline,
+        job_id=job_id,
+        file_path=file_path,
+        model=model,
+        device=device,
+        language=language,
+        do_srt=srt,
+        do_txt=txt,
+        do_srt_nh=srt_nh,
+        do_txt_nh=txt_nh,
+        initial_prompt=initial_prompt,
+    )
+
+    return {"job_id": job_id, "status": "pending"}
+
+
+@app.get("/admin/jobs")
+def admin_list_jobs(_admin: dict = Depends(require_admin)):
+    with _jobs_lock:
+        out = []
+        for job in _jobs.values():
+            row = {k: v for k, v in job.items() if k not in ("segments", "results")}
+            row["formats"] = list(job.get("results", {}).keys())
+            out.append(row)
+    return sorted(out, key=lambda j: j.get("created_at", ""), reverse=True)
+
+
+@app.get("/jobs")
+def list_jobs(user: dict = Depends(verify_any_auth)):
+    user_email = user.get("email", "")
+    with _jobs_lock:
+        out = []
+        for job in _jobs.values():
+            if job.get("submitted_by") != user_email:
+                continue
+            row = {k: v for k, v in job.items() if k not in ("segments", "results")}
+            row["formats"] = list(job.get("results", {}).keys())
+            out.append(row)
+        return out
+
+
+@app.get("/jobs/{job_id}")
+def get_job(job_id: str, user: dict = Depends(verify_any_auth)):
+    with _jobs_lock:
+        job = _jobs.get(job_id)
+    if job is None:
+        raise HTTPException(status_code=404, detail="Job not found")
+    return job
+
+
+@app.get("/jobs/{job_id}/download/{fmt}")
+def download_result(
+    job_id: str,
+    fmt: Literal["srt", "txt", "srt_nh", "txt_nh"],
+    user: dict = Depends(verify_any_auth),
+):
+    with _jobs_lock:
+        job = _jobs.get(job_id)
+    if job is None:
+        raise HTTPException(status_code=404, detail="Job not found")
+    if job["status"] != "completed":
+        raise HTTPException(status_code=400, detail=f"Job is '{job['status']}', not completed")
+    if fmt not in job["results"]:
+        raise HTTPException(status_code=404, detail=f"Format '{fmt}' was not requested for this job")
+    ext = fmt.split("_")[0]
+    filename = f"{os.path.splitext(job['filename'])[0]}_{fmt}.{ext}"
+    return PlainTextResponse(
+        content=job["results"][fmt],
+        headers={"Content-Disposition": f'attachment; filename="{filename}"'},
+    )
+
+
+@app.delete("/jobs/{job_id}", status_code=204)
+def delete_job(job_id: str, user: dict = Depends(verify_any_auth)):
+    with _jobs_lock:
+        if job_id not in _jobs:
+            raise HTTPException(status_code=404, detail="Job not found")
+        del _jobs[job_id]
+    path = os.path.join(PERSIST_DIR, f"{job_id}.json")
+    if os.path.exists(path):
+        os.remove(path)
+    return Response(status_code=204)
+
+
+# ---------------------------------------------------------------------------
+# Static frontend — mounted last so API routes take precedence
+# ---------------------------------------------------------------------------
+
+STATIC_DIR = os.path.join(os.path.dirname(__file__), "static")
+
+
+@app.get("/")
+def index():
+    return FileResponse(os.path.join(STATIC_DIR, "index.html"))
+
+
+app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")

+ 5 - 0
requirements.txt

@@ -0,0 +1,5 @@
+fastapi
+uvicorn[standard]
+python-multipart
+faster-whisper
+python-dotenv

+ 2427 - 0
static/index.html

@@ -0,0 +1,2427 @@
+<!DOCTYPE html>
+<html lang="es">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
+<title>Transcriptor</title>
+<link rel="preconnect" href="https://fonts.googleapis.com">
+<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
+<link href="https://fonts.googleapis.com/css2?family=Syne:wght@600;700;800&family=IBM+Plex+Mono:wght@400;500&family=Manrope:wght@300;400;500;600&display=swap" rel="stylesheet">
+<style>
+:root {
+  --bg:        #080808;
+  --s1:        #111111;
+  --s2:        #181818;
+  --s3:        #222222;
+  --border:    #2a2a2a;
+  --border2:   #383838;
+  --accent:    #e8a000;
+  --accent-lo: rgba(232,160,0,0.12);
+  --accent-md: rgba(232,160,0,0.25);
+  --text:      #e4dfd6;
+  --text2:     #7a756e;
+  --text3:     #3d3a36;
+  --ok:        #4caf74;
+  --err:       #e05252;
+  --radius:    6px;
+}
+
+*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
+
+html, body {
+  height: 100%;
+  background: var(--bg);
+  color: var(--text);
+  font-family: 'Manrope', sans-serif;
+  font-size: 14px;
+  line-height: 1.6;
+  -webkit-font-smoothing: antialiased;
+}
+
+body::before {
+  content: '';
+  position: fixed;
+  inset: 0;
+  background-image: radial-gradient(circle, #282420 1px, transparent 1px);
+  background-size: 28px 28px;
+  opacity: 0.55;
+  pointer-events: none;
+  z-index: 0;
+}
+
+::-webkit-scrollbar { width: 6px; }
+::-webkit-scrollbar-track { background: var(--s1); }
+::-webkit-scrollbar-thumb { background: var(--border2); border-radius: 3px; }
+
+.mono { font-family: 'IBM Plex Mono', monospace; }
+.hidden { display: none !important; }
+
+/* ─── INPUT ─── */
+input, select {
+  width: 100%;
+  background: var(--s2);
+  border: 1px solid var(--border);
+  border-radius: var(--radius);
+  color: var(--text);
+  font-family: 'Manrope', sans-serif;
+  font-size: 14px;
+  padding: 10px 14px;
+  outline: none;
+  transition: border-color .2s, box-shadow .2s;
+}
+input:focus, select:focus {
+  border-color: var(--accent);
+  box-shadow: 0 0 0 3px var(--accent-lo);
+}
+select option { background: var(--s2); }
+input::placeholder { color: var(--text3); }
+
+/* ─── BUTTON ─── */
+.btn {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  gap: 8px;
+  padding: 11px 22px;
+  border: none;
+  border-radius: var(--radius);
+  font-family: 'Syne', sans-serif;
+  font-size: 13px;
+  font-weight: 700;
+  letter-spacing: .06em;
+  text-transform: uppercase;
+  cursor: pointer;
+  transition: transform .15s, opacity .15s, box-shadow .2s;
+}
+.btn:active { transform: scale(.97); }
+.btn-primary {
+  background: var(--accent);
+  color: #000;
+}
+.btn-primary:hover { box-shadow: 0 0 24px rgba(232,160,0,.35); }
+.btn-primary:disabled { opacity: .35; cursor: not-allowed; transform: none; box-shadow: none; }
+.btn-ghost {
+  background: var(--s2);
+  border: 1px solid var(--border);
+  color: var(--text2);
+  font-size: 12px;
+  padding: 7px 14px;
+}
+.btn-ghost:hover { border-color: var(--border2); color: var(--text); }
+.btn-sm { padding: 7px 14px; font-size: 11px; }
+.btn-danger {
+  background: rgba(224,82,82,.1);
+  border: 1px solid rgba(224,82,82,.25);
+  color: var(--err);
+  font-size: 12px;
+  padding: 7px 14px;
+}
+.btn-danger:hover { background: rgba(224,82,82,.18); border-color: rgba(224,82,82,.5); }
+.btn-success {
+  background: rgba(76,175,116,.1);
+  border: 1px solid rgba(76,175,116,.25);
+  color: var(--ok);
+  font-size: 12px;
+  padding: 7px 14px;
+}
+.btn-success:hover { background: rgba(76,175,116,.18); border-color: rgba(76,175,116,.5); }
+
+/* ═══════════════════════════════════════════
+   LOGIN / CHANGE-PASSWORD SCREENS
+═══════════════════════════════════════════ */
+#login-screen, #change-password-screen {
+  position: fixed;
+  inset: 0;
+  z-index: 10;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  animation: fadeIn .4s ease;
+}
+
+.login-card {
+  position: relative;
+  width: 100%;
+  max-width: 420px;
+  margin: 0 16px;
+  background: var(--s1);
+  border: 1px solid var(--border);
+  border-top: 2px solid var(--accent);
+  border-radius: var(--radius);
+  padding: 40px 36px 36px;
+  box-shadow: 0 32px 80px rgba(0,0,0,.6);
+}
+
+.login-logo {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  margin-bottom: 28px;
+}
+.login-logo .waveform {
+  display: flex;
+  align-items: center;
+  gap: 3px;
+  height: 28px;
+}
+.login-logo .waveform span {
+  display: block;
+  width: 3px;
+  border-radius: 2px;
+  background: var(--accent);
+}
+.login-logo h1 {
+  font-family: 'Syne', sans-serif;
+  font-size: 22px;
+  font-weight: 800;
+  letter-spacing: .08em;
+  text-transform: uppercase;
+}
+.login-logo small {
+  display: block;
+  color: var(--text2);
+  font-size: 10px;
+  letter-spacing: .12em;
+  text-transform: uppercase;
+  margin-top: 2px;
+}
+
+.login-card .field { margin-bottom: 16px; }
+.login-card label {
+  display: block;
+  font-size: 11px;
+  font-weight: 600;
+  letter-spacing: .1em;
+  text-transform: uppercase;
+  color: var(--text2);
+  margin-bottom: 7px;
+}
+.input-wrap { position: relative; }
+.input-wrap input { padding-right: 44px; }
+.input-wrap .eye {
+  position: absolute;
+  right: 12px;
+  top: 50%;
+  transform: translateY(-50%);
+  background: none;
+  border: none;
+  color: var(--text3);
+  cursor: pointer;
+  padding: 4px;
+  line-height: 1;
+  transition: color .2s;
+}
+.input-wrap .eye:hover { color: var(--text2); }
+
+.login-card .btn-primary { width: 100%; margin-top: 8px; }
+
+.login-error {
+  margin-top: 14px;
+  padding: 10px 14px;
+  background: rgba(224,82,82,.1);
+  border: 1px solid rgba(224,82,82,.3);
+  border-radius: var(--radius);
+  color: var(--err);
+  font-size: 12px;
+  text-align: center;
+  animation: shake .3s ease;
+}
+
+.login-subtitle {
+  font-size: 13px;
+  color: var(--text2);
+  margin-bottom: 24px;
+  line-height: 1.5;
+}
+.login-subtitle strong { color: var(--accent); }
+
+.cp-back-link {
+  display: inline-block;
+  margin-bottom: 20px;
+  font-size: 12px;
+  color: var(--text2);
+  cursor: pointer;
+  background: none;
+  border: none;
+  padding: 0;
+  text-decoration: underline;
+  text-underline-offset: 3px;
+}
+.cp-back-link:hover { color: var(--text); }
+
+/* ═══════════════════════════════════════════
+   APP / ADMIN SCREENS (shared layout)
+═══════════════════════════════════════════ */
+#app-screen, #admin-screen {
+  position: relative;
+  z-index: 1;
+  min-height: 100vh;
+  display: flex;
+  flex-direction: column;
+}
+
+.app-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 14px 28px;
+  border-bottom: 1px solid var(--border);
+  background: rgba(8,8,8,.85);
+  backdrop-filter: blur(8px);
+  position: sticky;
+  top: 0;
+  z-index: 5;
+}
+.header-logo {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  font-family: 'Syne', sans-serif;
+  font-size: 17px;
+  font-weight: 800;
+  letter-spacing: .1em;
+  text-transform: uppercase;
+}
+.header-logo .dot {
+  width: 8px;
+  height: 8px;
+  border-radius: 50%;
+  background: var(--accent);
+  box-shadow: 0 0 8px var(--accent);
+}
+.header-left {
+  display: flex;
+  align-items: center;
+  gap: 20px;
+}
+.header-right {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+}
+.user-badge {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  padding: 6px 12px 6px 8px;
+  background: var(--s2);
+  border: 1px solid var(--border);
+  border-radius: 20px;
+}
+.user-badge .avatar {
+  width: 26px;
+  height: 26px;
+  border-radius: 50%;
+  background: var(--accent-lo);
+  border: 1px solid var(--accent-md);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-family: 'Syne', sans-serif;
+  font-size: 11px;
+  font-weight: 700;
+  color: var(--accent);
+}
+.user-badge .user-info { line-height: 1.3; }
+.user-badge .user-name { font-size: 12px; font-weight: 600; }
+.user-badge .user-email { font-size: 10px; color: var(--text2); font-family: 'IBM Plex Mono', monospace; }
+
+.app-main {
+  flex: 1;
+  max-width: 720px;
+  width: 100%;
+  margin: 0 auto;
+  padding: 36px 20px 60px;
+}
+.app-main-wide {
+  flex: 1;
+  max-width: 1000px;
+  width: 100%;
+  margin: 0 auto;
+  padding: 36px 20px 60px;
+}
+
+/* ─── SECTION HEADING ─── */
+.section-label {
+  font-size: 10px;
+  font-weight: 700;
+  letter-spacing: .18em;
+  text-transform: uppercase;
+  color: var(--text2);
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  margin-bottom: 14px;
+}
+.section-label::after {
+  content: '';
+  flex: 1;
+  height: 1px;
+  background: var(--border);
+}
+
+/* ─── CARD ─── */
+.card {
+  background: var(--s1);
+  border: 1px solid var(--border);
+  border-radius: var(--radius);
+  padding: 22px;
+  margin-bottom: 16px;
+}
+
+/* ─── UPLOAD ZONE ─── */
+.upload-zone {
+  border: 1.5px dashed var(--border2);
+  border-radius: var(--radius);
+  padding: 44px 24px;
+  text-align: center;
+  cursor: pointer;
+  transition: border-color .2s, background .2s;
+  position: relative;
+  overflow: hidden;
+}
+.upload-zone:hover, .upload-zone.drag-over {
+  border-color: var(--accent);
+  background: var(--accent-lo);
+}
+.upload-zone input[type=file] {
+  position: absolute;
+  inset: 0;
+  opacity: 0;
+  cursor: pointer;
+  width: 100%;
+  height: 100%;
+}
+.upload-icon {
+  margin: 0 auto 16px;
+  width: 48px;
+  height: 48px;
+  border: 1.5px solid var(--border2);
+  border-radius: 12px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  transition: border-color .2s;
+}
+.upload-zone:hover .upload-icon { border-color: var(--accent); }
+.upload-zone h3 {
+  font-family: 'Syne', sans-serif;
+  font-size: 15px;
+  font-weight: 700;
+  margin-bottom: 6px;
+}
+.upload-zone p { color: var(--text2); font-size: 12px; }
+
+.file-chip {
+  display: inline-flex;
+  align-items: center;
+  gap: 8px;
+  padding: 8px 14px;
+  background: var(--s3);
+  border: 1px solid var(--border2);
+  border-radius: 20px;
+  font-size: 12px;
+  font-family: 'IBM Plex Mono', monospace;
+  color: var(--text);
+  margin-top: 12px;
+}
+.file-chip .remove {
+  background: none;
+  border: none;
+  color: var(--text2);
+  cursor: pointer;
+  line-height: 1;
+  padding: 0 2px;
+  font-size: 16px;
+  transition: color .15s;
+}
+.file-chip .remove:hover { color: var(--err); }
+
+/* ─── SETTINGS GRID ─── */
+.settings-grid {
+  display: grid;
+  grid-template-columns: 1fr 1fr;
+  gap: 12px;
+}
+.field label {
+  display: block;
+  font-size: 11px;
+  font-weight: 600;
+  letter-spacing: .1em;
+  text-transform: uppercase;
+  color: var(--text2);
+  margin-bottom: 7px;
+}
+
+/* ─── FORMAT CHECKBOXES ─── */
+.formats-grid {
+  display: grid;
+  grid-template-columns: repeat(4, 1fr);
+  gap: 10px;
+  margin-top: 4px;
+}
+.fmt-check { position: relative; }
+.fmt-check input[type=checkbox] { position: absolute; opacity: 0; width: 0; height: 0; }
+.fmt-check label {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  gap: 6px;
+  padding: 14px 8px;
+  background: var(--s2);
+  border: 1.5px solid var(--border);
+  border-radius: var(--radius);
+  cursor: pointer;
+  transition: border-color .2s, background .2s;
+  text-transform: none;
+  letter-spacing: 0;
+  color: var(--text2);
+  font-size: 12px;
+  font-weight: 500;
+}
+.fmt-check label .fmt-tag {
+  font-family: 'IBM Plex Mono', monospace;
+  font-size: 13px;
+  font-weight: 500;
+  color: var(--text);
+}
+.fmt-check label .fmt-desc { font-size: 10px; color: var(--text3); text-align: center; line-height: 1.4; }
+.fmt-check input:checked + label { border-color: var(--accent); background: var(--accent-lo); color: var(--text); }
+.fmt-check input:checked + label .fmt-tag { color: var(--accent); }
+.fmt-check input:checked + label .fmt-desc { color: var(--text2); }
+.fmt-check label:hover { border-color: var(--border2); }
+
+.submit-row {
+  display: flex;
+  align-items: center;
+  justify-content: flex-end;
+  gap: 12px;
+  margin-top: 20px;
+}
+.submit-row .btn-primary { min-width: 160px; }
+
+/* ─── JOB STATUS ─── */
+.job-card {
+  background: var(--s1);
+  border: 1px solid var(--border);
+  border-radius: var(--radius);
+  overflow: hidden;
+  margin-bottom: 16px;
+  animation: slideDown .3s ease;
+}
+.job-card-top {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 16px 20px;
+  border-bottom: 1px solid var(--border);
+}
+.job-card-top .filename {
+  font-family: 'IBM Plex Mono', monospace;
+  font-size: 13px;
+  color: var(--text);
+  max-width: 60%;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.status-pill {
+  display: inline-flex;
+  align-items: center;
+  gap: 7px;
+  padding: 4px 11px;
+  border-radius: 20px;
+  font-size: 11px;
+  font-weight: 700;
+  letter-spacing: .08em;
+  text-transform: uppercase;
+}
+.status-dot { width: 7px; height: 7px; border-radius: 50%; }
+.status-pending   { background: rgba(100,100,100,.15); color: var(--text2); }
+.status-pending .status-dot { background: var(--text2); }
+.status-active    { background: rgba(232,160,0,.12); color: var(--accent); }
+.status-active .status-dot { background: var(--accent); animation: pulse 1.2s ease-in-out infinite; }
+.status-completed { background: rgba(76,175,116,.12); color: var(--ok); }
+.status-completed .status-dot { background: var(--ok); }
+.status-failed    { background: rgba(224,82,82,.12); color: var(--err); }
+.status-failed .status-dot { background: var(--err); }
+
+.job-card-body { padding: 20px; }
+
+.waveform-anim {
+  display: flex;
+  align-items: center;
+  gap: 4px;
+  height: 48px;
+  justify-content: center;
+  margin-bottom: 16px;
+}
+.waveform-anim .bar {
+  width: 4px;
+  border-radius: 2px;
+  background: var(--accent);
+  transform-origin: bottom center;
+  animation: wave 1.4s ease-in-out infinite;
+}
+.waveform-anim .bar:nth-child(1)  { height: 12px; animation-delay: 0.00s; }
+.waveform-anim .bar:nth-child(2)  { height: 22px; animation-delay: 0.07s; }
+.waveform-anim .bar:nth-child(3)  { height: 34px; animation-delay: 0.14s; }
+.waveform-anim .bar:nth-child(4)  { height: 28px; animation-delay: 0.21s; }
+.waveform-anim .bar:nth-child(5)  { height: 40px; animation-delay: 0.28s; }
+.waveform-anim .bar:nth-child(6)  { height: 32px; animation-delay: 0.35s; }
+.waveform-anim .bar:nth-child(7)  { height: 44px; animation-delay: 0.42s; }
+.waveform-anim .bar:nth-child(8)  { height: 36px; animation-delay: 0.49s; }
+.waveform-anim .bar:nth-child(9)  { height: 48px; animation-delay: 0.56s; }
+.waveform-anim .bar:nth-child(10) { height: 40px; animation-delay: 0.63s; }
+.waveform-anim .bar:nth-child(11) { height: 48px; animation-delay: 0.70s; }
+.waveform-anim .bar:nth-child(12) { height: 36px; animation-delay: 0.77s; }
+.waveform-anim .bar:nth-child(13) { height: 44px; animation-delay: 0.84s; }
+.waveform-anim .bar:nth-child(14) { height: 28px; animation-delay: 0.91s; }
+.waveform-anim .bar:nth-child(15) { height: 38px; animation-delay: 0.98s; }
+.waveform-anim .bar:nth-child(16) { height: 22px; animation-delay: 1.05s; }
+.waveform-anim .bar:nth-child(17) { height: 32px; animation-delay: 1.12s; }
+.waveform-anim .bar:nth-child(18) { height: 16px; animation-delay: 1.19s; }
+.waveform-anim .bar:nth-child(19) { height: 24px; animation-delay: 1.26s; }
+.waveform-anim .bar:nth-child(20) { height: 12px; animation-delay: 1.33s; }
+
+.status-text { text-align: center; font-size: 13px; color: var(--text2); }
+.status-text strong { color: var(--accent); }
+
+.pipeline-steps {
+  display: flex;
+  align-items: center;
+  gap: 0;
+  margin-top: 18px;
+}
+.pipeline-step {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  gap: 6px;
+  font-size: 10px;
+  letter-spacing: .08em;
+  text-transform: uppercase;
+  color: var(--text3);
+  position: relative;
+}
+.pipeline-step::after {
+  content: '';
+  position: absolute;
+  top: 13px;
+  left: calc(50% + 14px);
+  right: calc(-50% + 14px);
+  height: 1px;
+  background: var(--border);
+}
+.pipeline-step:last-child::after { display: none; }
+.step-node {
+  width: 28px;
+  height: 28px;
+  border-radius: 50%;
+  border: 1.5px solid var(--border2);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 10px;
+  transition: all .3s;
+}
+.pipeline-step.active .step-node { border-color: var(--accent); background: var(--accent-lo); color: var(--accent); box-shadow: 0 0 12px var(--accent-lo); }
+.pipeline-step.done .step-node   { border-color: var(--ok); background: rgba(76,175,116,.12); color: var(--ok); }
+.pipeline-step.active { color: var(--accent); }
+.pipeline-step.done   { color: var(--ok); }
+
+/* ─── RESULTS ─── */
+.results-grid {
+  display: grid;
+  grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
+  gap: 10px;
+  margin-bottom: 20px;
+}
+.result-btn {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  gap: 8px;
+  padding: 16px 12px;
+  background: var(--s2);
+  border: 1.5px solid var(--border);
+  border-radius: var(--radius);
+  cursor: pointer;
+  transition: border-color .2s, background .2s, transform .15s;
+  font-family: 'IBM Plex Mono', monospace;
+  font-size: 13px;
+  color: var(--text);
+}
+.result-btn:hover { border-color: var(--accent); background: var(--accent-lo); transform: translateY(-2px); }
+.result-btn .icon { font-size: 22px; }
+.result-btn .format { font-weight: 500; color: var(--accent); }
+.result-btn .label { font-size: 10px; color: var(--text2); font-family: 'Manrope', sans-serif; text-align: center; }
+
+.segments-preview { margin-top: 4px; }
+.segment-row {
+  display: grid;
+  grid-template-columns: 110px 90px 1fr;
+  gap: 12px;
+  padding: 10px 0;
+  border-bottom: 1px solid var(--border);
+  font-size: 12px;
+  align-items: start;
+}
+.segment-row:last-child { border-bottom: none; }
+.seg-time { font-family: 'IBM Plex Mono', monospace; color: var(--text2); font-size: 11px; padding-top: 1px; }
+.seg-speaker {
+  font-family: 'IBM Plex Mono', monospace;
+  font-size: 11px;
+  padding: 2px 8px;
+  border-radius: 3px;
+  background: var(--accent-lo);
+  color: var(--accent);
+  text-align: center;
+  align-self: start;
+  width: fit-content;
+}
+.seg-text { color: var(--text); line-height: 1.5; }
+.preview-more { text-align: center; color: var(--text3); font-size: 11px; padding-top: 10px; font-family: 'IBM Plex Mono', monospace; }
+
+/* ─── JOBS HISTORY ─── */
+.history-table {
+  width: 100%;
+  border-collapse: collapse;
+  font-size: 12px;
+}
+.history-table th {
+  text-align: left;
+  padding: 8px 12px;
+  color: var(--text2);
+  font-size: 10px;
+  font-weight: 700;
+  letter-spacing: .12em;
+  text-transform: uppercase;
+  border-bottom: 1px solid var(--border);
+}
+.history-table td {
+  padding: 10px 12px;
+  border-bottom: 1px solid var(--border);
+  vertical-align: middle;
+}
+.history-table tr:last-child td { border-bottom: none; }
+.history-table .mono { color: var(--text2); }
+.history-empty { text-align: center; color: var(--text3); font-size: 12px; padding: 24px; }
+
+/* ═══════════════════════════════════════════
+   ADMIN PANEL
+═══════════════════════════════════════════ */
+.tab-nav {
+  display: flex;
+  gap: 2px;
+  align-items: center;
+}
+.tab-btn {
+  padding: 6px 16px;
+  border: none;
+  background: none;
+  color: var(--text2);
+  font-family: 'Syne', sans-serif;
+  font-weight: 700;
+  font-size: 11px;
+  letter-spacing: .08em;
+  text-transform: uppercase;
+  cursor: pointer;
+  border-radius: var(--radius);
+  transition: all .15s;
+}
+.tab-btn.active { background: var(--accent-lo); color: var(--accent); }
+.tab-btn:hover:not(.active) { background: var(--s2); color: var(--text); }
+
+/* Role + status badges */
+.badge {
+  display: inline-block;
+  padding: 2px 8px;
+  border-radius: 3px;
+  font-size: 10px;
+  font-weight: 700;
+  font-family: 'IBM Plex Mono', monospace;
+  letter-spacing: .06em;
+  text-transform: uppercase;
+}
+.badge-admin  { background: rgba(232,160,0,.15); color: var(--accent); border: 1px solid rgba(232,160,0,.3); }
+.badge-user   { background: var(--s3); color: var(--text2); border: 1px solid var(--border); }
+.badge-default { background: rgba(224,82,82,.1); color: var(--err); border: 1px solid rgba(224,82,82,.25); }
+.badge-set    { background: rgba(76,175,116,.1); color: var(--ok); border: 1px solid rgba(76,175,116,.25); }
+.badge-yes    { background: rgba(76,175,116,.1); color: var(--ok); border: 1px solid rgba(76,175,116,.25); }
+.badge-no     { background: var(--s3); color: var(--text3); border: 1px solid var(--border); }
+
+/* Metrics */
+.metrics-grid {
+  display: grid;
+  grid-template-columns: repeat(4, 1fr);
+  gap: 12px;
+  margin-bottom: 20px;
+}
+.metric-card {
+  background: var(--s1);
+  border: 1px solid var(--border);
+  border-radius: var(--radius);
+  padding: 20px 16px 16px;
+  text-align: center;
+}
+.metric-value {
+  font-family: 'Syne', sans-serif;
+  font-size: 32px;
+  font-weight: 800;
+  color: var(--text);
+  line-height: 1;
+}
+.metric-label {
+  font-size: 10px;
+  color: var(--text2);
+  text-transform: uppercase;
+  letter-spacing: .12em;
+  margin-top: 6px;
+}
+.metric-card.accent .metric-value { color: var(--accent); }
+.metric-card.ok .metric-value { color: var(--ok); }
+.metric-card.err .metric-value { color: var(--err); }
+
+.metrics-cols {
+  display: grid;
+  grid-template-columns: 1fr 1fr 1fr;
+  gap: 14px;
+  margin-bottom: 14px;
+}
+.bar-list { display: flex; flex-direction: column; gap: 10px; }
+.bar-item { display: flex; align-items: center; gap: 10px; font-size: 12px; }
+.bar-label {
+  width: 90px;
+  font-family: 'IBM Plex Mono', monospace;
+  color: var(--text2);
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  font-size: 11px;
+}
+.bar-track {
+  flex: 1;
+  height: 5px;
+  background: var(--s3);
+  border-radius: 3px;
+  overflow: hidden;
+}
+.bar-fill { height: 100%; background: var(--accent); border-radius: 3px; transition: width .6s ease; }
+.bar-count { width: 28px; text-align: right; color: var(--text2); font-family: 'IBM Plex Mono', monospace; font-size: 11px; }
+
+/* ═══════════════════════════════════════════
+   MODAL
+═══════════════════════════════════════════ */
+#modal-overlay {
+  position: fixed;
+  inset: 0;
+  background: rgba(0,0,0,.72);
+  z-index: 50;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  animation: fadeIn .2s ease;
+}
+.modal {
+  background: var(--s1);
+  border: 1px solid var(--border);
+  border-top: 2px solid var(--accent);
+  border-radius: var(--radius);
+  padding: 28px 32px 32px;
+  width: 100%;
+  max-width: 440px;
+  margin: 0 16px;
+  box-shadow: 0 24px 60px rgba(0,0,0,.6);
+}
+.modal-title {
+  font-family: 'Syne', sans-serif;
+  font-size: 16px;
+  font-weight: 700;
+  letter-spacing: .04em;
+  margin-bottom: 20px;
+}
+.modal-field { margin-bottom: 14px; }
+.modal-field label {
+  display: block;
+  font-size: 11px;
+  font-weight: 600;
+  letter-spacing: .1em;
+  text-transform: uppercase;
+  color: var(--text2);
+  margin-bottom: 7px;
+}
+.modal-actions {
+  display: flex;
+  gap: 10px;
+  justify-content: flex-end;
+  margin-top: 24px;
+}
+.modal-note {
+  font-size: 11px;
+  color: var(--text2);
+  margin-top: 8px;
+  line-height: 1.5;
+}
+.key-display {
+  font-family: 'IBM Plex Mono', monospace;
+  font-size: 12px;
+  background: var(--s2);
+  border: 1px solid var(--border);
+  border-radius: var(--radius);
+  padding: 12px 14px;
+  word-break: break-all;
+  color: var(--accent);
+  margin: 12px 0;
+  line-height: 1.6;
+}
+.modal-error {
+  margin-top: 10px;
+  padding: 8px 12px;
+  background: rgba(224,82,82,.1);
+  border: 1px solid rgba(224,82,82,.3);
+  border-radius: var(--radius);
+  color: var(--err);
+  font-size: 12px;
+}
+.role-toggle {
+  display: grid;
+  grid-template-columns: 1fr 1fr;
+  gap: 8px;
+}
+.role-option input[type=radio] { position: absolute; opacity: 0; width: 0; height: 0; }
+.role-option {
+  position: relative;
+}
+.role-option label {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  gap: 4px;
+  padding: 12px 8px;
+  background: var(--s2);
+  border: 1.5px solid var(--border);
+  border-radius: var(--radius);
+  cursor: pointer;
+  transition: border-color .2s, background .2s;
+  text-transform: none;
+  letter-spacing: 0;
+  color: var(--text2);
+  font-size: 12px;
+}
+.role-option input:checked + label {
+  border-color: var(--accent);
+  background: var(--accent-lo);
+  color: var(--text);
+}
+
+/* ─── TOAST ─── */
+#toast {
+  position: fixed;
+  bottom: 24px;
+  left: 50%;
+  transform: translateX(-50%) translateY(20px);
+  background: var(--s3);
+  border: 1px solid var(--border2);
+  border-radius: var(--radius);
+  padding: 10px 20px;
+  font-size: 13px;
+  color: var(--text);
+  opacity: 0;
+  transition: opacity .25s, transform .25s;
+  z-index: 100;
+  pointer-events: none;
+  white-space: nowrap;
+}
+#toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
+
+/* ─── ANIMATIONS ─── */
+@keyframes fadeIn  { from { opacity: 0; } to { opacity: 1; } }
+@keyframes slideDown { from { opacity: 0; transform: translateY(-10px); } to { opacity: 1; transform: translateY(0); } }
+@keyframes shake { 0%,100% { transform: translateX(0); } 25% { transform: translateX(-6px); } 75% { transform: translateX(6px); } }
+@keyframes pulse { 0%,100% { opacity: 1; box-shadow: 0 0 0 0 var(--accent); } 50% { opacity: .6; box-shadow: 0 0 0 4px transparent; } }
+@keyframes wave  { 0%,100% { transform: scaleY(.25); opacity: .4; } 50% { transform: scaleY(1); opacity: 1; } }
+
+@media (max-width: 600px) {
+  .settings-grid  { grid-template-columns: 1fr; }
+  .formats-grid   { grid-template-columns: repeat(2, 1fr); }
+  .pipeline-steps { flex-wrap: wrap; gap: 10px; }
+  .pipeline-step::after { display: none; }
+  .metrics-grid   { grid-template-columns: repeat(2, 1fr); }
+  .metrics-cols   { grid-template-columns: 1fr; }
+}
+
+/* ─── LANG TOGGLE ─── */
+.lang-toggle-btn {
+  font-family: 'IBM Plex Mono', monospace;
+  font-size: 11px;
+  font-weight: 500;
+  letter-spacing: .08em;
+  min-width: 36px;
+  padding: 6px 10px;
+  background: var(--s2);
+  border: 1px solid var(--border);
+  color: var(--text2);
+  border-radius: var(--radius);
+  cursor: pointer;
+  transition: border-color .2s, color .2s;
+}
+.lang-toggle-btn:hover { border-color: var(--border2); color: var(--text); }
+.card-lang-toggle { position: absolute; top: 14px; right: 16px; }
+</style>
+</head>
+<body>
+
+<!-- ═══════════════════ LOGIN ═══════════════════ -->
+<div id="login-screen">
+  <div class="login-card">
+    <button class="lang-toggle-btn card-lang-toggle" onclick="toggleLanguage()">EN</button>
+    <div class="login-logo">
+      <div class="waveform" aria-hidden="true">
+        <span style="height:8px"></span>
+        <span style="height:14px"></span>
+        <span style="height:22px"></span>
+        <span style="height:18px"></span>
+        <span style="height:28px"></span>
+        <span style="height:20px"></span>
+        <span style="height:12px"></span>
+        <span style="height:6px"></span>
+      </div>
+      <div>
+        <h1>Transcriptor</h1>
+        <small data-i18n="login.subtitle">Inteligencia de audio</small>
+      </div>
+    </div>
+
+    <div class="field">
+      <label for="login-email" data-i18n="login.email">Correo electrónico</label>
+      <input type="email" id="login-email" placeholder="tu@correo.com" data-i18n-placeholder="login.email_ph" autocomplete="email">
+    </div>
+    <div class="field">
+      <label for="login-password" data-i18n="login.password">Contraseña</label>
+      <div class="input-wrap">
+        <input type="password" id="login-password" placeholder="••••••••" autocomplete="current-password">
+        <button class="eye" id="toggle-password" type="button" title="Show/hide password">
+          <svg id="eye-show" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
+          <svg id="eye-hide" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="display:none"><path d="M17.94 17.94A10.07 10.07 0 0112 20c-7 0-11-8-11-8a18.45 18.45 0 015.06-5.94M9.9 4.24A9.12 9.12 0 0112 4c7 0 11 8 11 8a18.5 18.5 0 01-2.16 3.19m-6.72-1.07a3 3 0 11-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/></svg>
+        </button>
+      </div>
+    </div>
+
+    <button class="btn btn-primary" onclick="login()">
+      <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M15 3h4a2 2 0 012 2v14a2 2 0 01-2 2h-4"/><polyline points="10 17 15 12 10 7"/><line x1="15" y1="12" x2="3" y2="12"/></svg>
+      <span data-i18n="login.sign_in">Iniciar sesión</span>
+    </button>
+
+    <div id="login-error" class="login-error hidden"></div>
+  </div>
+</div>
+
+<!-- ═══════════════════ CHANGE PASSWORD ═══════════════════ -->
+<div id="change-password-screen" class="hidden">
+  <div class="login-card">
+    <button class="lang-toggle-btn card-lang-toggle" onclick="toggleLanguage()">EN</button>
+    <button class="cp-back-link hidden" id="cp-back-btn" onclick="cancelChangePassword()" data-i18n="cp.back">← Volver</button>
+    <div class="login-logo">
+      <div class="waveform" aria-hidden="true">
+        <span style="height:8px"></span>
+        <span style="height:14px"></span>
+        <span style="height:22px"></span>
+        <span style="height:18px"></span>
+        <span style="height:28px"></span>
+        <span style="height:20px"></span>
+        <span style="height:12px"></span>
+        <span style="height:6px"></span>
+      </div>
+      <div>
+        <h1 data-i18n="cp.title">Nueva contraseña</h1>
+        <small data-i18n="cp.head">Seguridad de cuenta</small>
+      </div>
+    </div>
+
+    <p class="login-subtitle" id="cp-subtitle"></p>
+
+    <div class="field hidden" id="cp-current-field">
+      <label for="cp-current" data-i18n="cp.current">Contraseña actual</label>
+      <input type="password" id="cp-current" placeholder="Tu contraseña actual" data-i18n-placeholder="cp.current_ph" autocomplete="current-password">
+    </div>
+    <div class="field">
+      <label for="cp-new" data-i18n="cp.new">Nueva contraseña</label>
+      <input type="password" id="cp-new" placeholder="Mínimo 8 caracteres" data-i18n-placeholder="cp.new_ph" autocomplete="new-password">
+    </div>
+    <div class="field">
+      <label for="cp-confirm" data-i18n="cp.confirm">Confirmar contraseña</label>
+      <input type="password" id="cp-confirm" placeholder="Repite la nueva contraseña" data-i18n-placeholder="cp.confirm_ph" autocomplete="new-password">
+    </div>
+
+    <button class="btn btn-primary" onclick="submitChangePassword()" data-i18n="cp.submit">Establecer contraseña</button>
+    <div id="cp-error" class="login-error hidden"></div>
+  </div>
+</div>
+
+<!-- ═══════════════════ APP ═══════════════════ -->
+<div id="app-screen" class="hidden">
+  <header class="app-header">
+    <div class="header-logo">
+      <div class="dot"></div>
+      Transcriptor
+    </div>
+    <div class="header-right">
+      <div class="user-badge">
+        <div class="avatar user-avatar-el">?</div>
+        <div class="user-info">
+          <div class="user-name user-name-el">—</div>
+          <div class="user-email user-email-el">—</div>
+        </div>
+      </div>
+      <button class="lang-toggle-btn" onclick="toggleLanguage()">EN</button>
+      <button id="admin-btn" class="btn btn-ghost btn-sm hidden" onclick="showAdmin()" data-i18n="app.admin_panel">Panel de admin</button>
+      <button class="btn btn-ghost btn-sm" onclick="startChangePassword()" data-i18n="app.password">Contraseña</button>
+      <button class="btn btn-ghost btn-sm" onclick="logout()" data-i18n="app.sign_out">Cerrar sesión</button>
+    </div>
+  </header>
+
+  <main class="app-main">
+
+    <div class="section-label" data-i18n="s.upload">01 — Subir archivo</div>
+    <div class="card" style="padding: 0; overflow: hidden;">
+      <div class="upload-zone" id="upload-zone">
+        <input type="file" id="file-input" accept="audio/*,video/*,.mp4,.mp3,.wav,.ogg,.m4a,.webm,.mkv" onchange="onFileSelect(event)">
+        <div class="upload-icon">
+          <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round">
+            <path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/>
+            <polyline points="17 8 12 3 7 8"/>
+            <line x1="12" y1="3" x2="12" y2="15"/>
+          </svg>
+        </div>
+        <h3 data-i18n="upload.h3">Arrastra tu audio o video</h3>
+        <p data-i18n="upload.p">MP3, MP4, WAV, OGG, M4A, WebM — cualquier formato</p>
+        <div id="file-chip" class="hidden" style="justify-content:center;display:flex;margin-top:12px;" onclick="event.stopPropagation()">
+          <span class="file-chip">
+            <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>
+            <span id="file-name-display"></span>
+            <button class="remove" onclick="clearFile()" title="Remove file">×</button>
+          </span>
+        </div>
+      </div>
+    </div>
+
+    <div class="section-label" style="margin-top:24px;" data-i18n="s.settings">02 — Configuración</div>
+    <div class="card">
+      <div class="settings-grid" style="margin-bottom:20px;">
+        <div class="field">
+          <label for="lang-select" data-i18n="settings.language">Idioma</label>
+          <select id="lang-select">
+            <option value="es" selected>Spanish (es)</option>
+            <option value="en">English (en)</option>
+            <option value="pt">Portuguese (pt)</option>
+            <option value="fr">French (fr)</option>
+            <option value="de">German (de)</option>
+            <option value="it">Italian (it)</option>
+            <option value="ja">Japanese (ja)</option>
+            <option value="zh">Chinese (zh)</option>
+            <option value="auto">Auto-detect</option>
+          </select>
+        </div>
+        <div class="field">
+          <label for="model-select" data-i18n="settings.model">Modelo</label>
+          <select id="model-select">
+            <option value="large-v3" selected>large-v3 (best)</option>
+            <option value="large-v2">large-v2</option>
+            <option value="medium">medium</option>
+            <option value="small">small (fast)</option>
+            <option value="base">base (fastest)</option>
+          </select>
+        </div>
+      </div>
+
+      <div class="field">
+        <label data-i18n="settings.formats">Formatos de salida</label>
+        <div class="formats-grid">
+          <div class="fmt-check">
+            <input type="checkbox" id="fmt-txt" checked>
+            <label for="fmt-txt">
+              <span class="fmt-tag">.txt</span>
+              <span class="fmt-desc" data-i18n="fmt.txt_spk">Texto con hablantes</span>
+            </label>
+          </div>
+          <div class="fmt-check">
+            <input type="checkbox" id="fmt-srt" checked>
+            <label for="fmt-srt">
+              <span class="fmt-tag">.srt</span>
+              <span class="fmt-desc" data-i18n="fmt.srt_spk">Subtítulos con hablantes</span>
+            </label>
+          </div>
+          <div class="fmt-check">
+            <input type="checkbox" id="fmt-txt_nh">
+            <label for="fmt-txt_nh">
+              <span class="fmt-tag">.txt</span>
+              <span class="fmt-desc" data-i18n="fmt.txt_only">Solo texto</span>
+            </label>
+          </div>
+          <div class="fmt-check">
+            <input type="checkbox" id="fmt-srt_nh">
+            <label for="fmt-srt_nh">
+              <span class="fmt-tag">.srt</span>
+              <span class="fmt-desc" data-i18n="fmt.srt_only">Solo subtítulos</span>
+            </label>
+          </div>
+        </div>
+      </div>
+
+      <div class="field" style="margin-top:20px;">
+        <label style="display:flex;align-items:center;gap:8px;cursor:pointer;user-select:none;">
+          <span data-i18n="settings.prompt">Prompt inicial</span>
+          <span style="font-size:10px;color:var(--text3);font-weight:400;" data-i18n="settings.prompt_optional">(opcional)</span>
+          <button type="button" onclick="togglePromptField()" id="prompt-toggle-btn" style="background:none;border:none;color:var(--text3);cursor:pointer;padding:0;font-size:11px;margin-left:auto;" data-i18n="settings.prompt_expand">▼ Mostrar</button>
+        </label>
+        <div id="prompt-field" class="hidden" style="margin-top:8px;">
+          <textarea id="initial-prompt" rows="3" placeholder="" data-i18n-placeholder="settings.prompt_ph" style="width:100%;resize:vertical;font-family:'IBM Plex Mono',monospace;font-size:12px;background:var(--s2);border:1px solid var(--brd);border-radius:8px;color:var(--text1);padding:10px 12px;box-sizing:border-box;"></textarea>
+          <p style="font-size:11px;color:var(--text3);margin:4px 0 0;" data-i18n="settings.prompt_hint">Nombres propios, términos técnicos o contexto que ayude a Whisper a transcribir con mayor precisión.</p>
+        </div>
+      </div>
+
+      <div class="submit-row">
+        <span id="submit-hint" style="color:var(--text3);font-size:12px;"></span>
+        <button class="btn btn-primary" id="submit-btn" onclick="submitJob()" disabled>
+          <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polygon points="5 3 19 12 5 21 5 3"/></svg>
+          <span data-i18n="submit.transcribe">Transcribir</span>
+        </button>
+      </div>
+    </div>
+
+    <div id="job-section" class="hidden">
+      <div class="section-label" style="margin-top:24px;" data-i18n="s.processing">03 — Procesando</div>
+      <div class="job-card" id="job-card">
+        <div class="job-card-top">
+          <span class="filename mono" id="job-filename"></span>
+          <span class="status-pill" id="status-pill">
+            <span class="status-dot"></span>
+            <span id="status-text-pill">Pending</span>
+          </span>
+        </div>
+        <div class="job-card-body" id="job-body">
+          <div class="waveform-anim" id="wave-anim">
+            <div class="bar"></div><div class="bar"></div><div class="bar"></div>
+            <div class="bar"></div><div class="bar"></div><div class="bar"></div>
+            <div class="bar"></div><div class="bar"></div><div class="bar"></div>
+            <div class="bar"></div><div class="bar"></div><div class="bar"></div>
+            <div class="bar"></div><div class="bar"></div><div class="bar"></div>
+            <div class="bar"></div><div class="bar"></div><div class="bar"></div>
+            <div class="bar"></div><div class="bar"></div>
+          </div>
+          <div class="status-text" id="job-status-desc" data-i18n="status.initializing">Inicializando…</div>
+          <div class="pipeline-steps" style="margin-top:22px;">
+            <div class="pipeline-step" id="step-transcribing">
+              <div class="step-node">1</div><span data-i18n="step.transcribe">Transcribir</span>
+            </div>
+            <div class="pipeline-step" id="step-diarizing">
+              <div class="step-node">2</div><span data-i18n="step.diarize">Identificar</span>
+            </div>
+            <div class="pipeline-step" id="step-combining">
+              <div class="step-node">3</div><span data-i18n="step.combine">Combinar</span>
+            </div>
+            <div class="pipeline-step" id="step-completed">
+              <div class="step-node">✓</div><span data-i18n="step.done">Listo</span>
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+
+    <div id="results-section" class="hidden">
+      <div class="section-label" style="margin-top:24px;" data-i18n="s.results">04 — Resultados</div>
+      <div class="card">
+        <div class="results-grid" id="results-grid"></div>
+        <div class="section-label" style="margin-top:4px;margin-bottom:12px;font-size:10px;" data-i18n="s.preview">Vista previa — primeros segmentos</div>
+        <div class="segments-preview" id="segments-preview"></div>
+      </div>
+    </div>
+
+    <div style="margin-top:32px;">
+      <div class="section-label" style="justify-content:space-between;">
+        <span style="display:flex;align-items:center;gap:10px;"><span data-i18n="history.title">Historial de trabajos</span> <span id="history-count" style="font-family:'IBM Plex Mono',monospace;background:var(--s3);padding:1px 7px;border-radius:10px;font-size:10px;color:var(--text2);">0</span></span>
+        <button class="btn btn-ghost btn-sm" onclick="loadHistory()" style="font-size:10px;padding:4px 10px;" data-i18n="history.refresh">↻ Actualizar</button>
+      </div>
+      <div class="card" style="padding:0;overflow:hidden;">
+        <table class="history-table">
+          <thead>
+            <tr>
+              <th data-i18n="history.th_file">Archivo</th>
+              <th data-i18n="history.th_status">Estado</th>
+              <th data-i18n="history.th_language">Idioma</th>
+              <th data-i18n="history.th_segments">Segmentos</th>
+              <th data-i18n="history.th_time">Tiempo</th>
+              <th data-i18n="history.th_actions">Acciones</th>
+            </tr>
+          </thead>
+          <tbody id="history-tbody">
+            <tr><td colspan="6" class="history-empty">Loading…</td></tr>
+          </tbody>
+        </table>
+      </div>
+    </div>
+
+  </main>
+</div>
+
+<!-- ═══════════════════ ADMIN PANEL ═══════════════════ -->
+<div id="admin-screen" class="hidden">
+  <header class="app-header">
+    <div class="header-left">
+      <div class="header-logo">
+        <div class="dot"></div>
+        <span data-i18n="admin.title">Admin</span>
+      </div>
+      <div class="tab-nav">
+        <button class="tab-btn active" id="tab-btn-users" onclick="switchAdminTab('users')" data-i18n="admin.tab_users">Usuarios</button>
+        <button class="tab-btn" id="tab-btn-history" onclick="switchAdminTab('history')" data-i18n="admin.tab_history">Historia</button>
+        <button class="tab-btn" id="tab-btn-metrics" onclick="switchAdminTab('metrics')" data-i18n="admin.tab_metrics">Métricas</button>
+      </div>
+    </div>
+    <div class="header-right">
+      <div class="user-badge">
+        <div class="avatar user-avatar-el">?</div>
+        <div class="user-info">
+          <div class="user-name user-name-el">—</div>
+          <div class="user-email user-email-el">—</div>
+        </div>
+      </div>
+      <button class="lang-toggle-btn" onclick="toggleLanguage()">EN</button>
+      <button class="btn btn-ghost btn-sm" onclick="showApp()" data-i18n="admin.back_app">← Aplicación</button>
+      <button class="btn btn-ghost btn-sm" onclick="logout()" data-i18n="app.sign_out">Cerrar sesión</button>
+    </div>
+  </header>
+
+  <main class="app-main-wide">
+
+    <!-- Users tab -->
+    <div id="admin-tab-users">
+      <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;">
+        <div class="section-label" style="margin-bottom:0;flex:1;" data-i18n="admin.users_title">Cuentas de usuario</div>
+        <button class="btn btn-primary btn-sm" onclick="openCreateUserModal()" data-i18n="admin.create_user">+ Crear usuario</button>
+      </div>
+      <div class="card" style="padding:0;overflow:hidden;">
+        <table class="history-table">
+          <thead>
+            <tr>
+              <th data-i18n="admin.th_name">Nombre</th>
+              <th data-i18n="admin.th_email">Correo</th>
+              <th data-i18n="admin.th_role">Rol</th>
+              <th data-i18n="admin.th_password">Contraseña</th>
+              <th data-i18n="admin.th_api_key">Clave API</th>
+              <th data-i18n="admin.th_created">Creado</th>
+              <th data-i18n="admin.th_actions">Acciones</th>
+            </tr>
+          </thead>
+          <tbody id="admin-users-tbody">
+            <tr><td colspan="7" class="history-empty">Loading…</td></tr>
+          </tbody>
+        </table>
+      </div>
+    </div>
+
+    <!-- History tab -->
+    <div id="admin-tab-history" class="hidden">
+      <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;">
+        <div class="section-label" style="margin-bottom:0;flex:1;" data-i18n="admin.history_title">Historial global de trabajos</div>
+        <button class="btn btn-ghost btn-sm" onclick="loadAdminJobs()" style="font-size:10px;padding:4px 10px;" data-i18n="history.refresh">↻ Actualizar</button>
+      </div>
+      <div class="card" style="padding:0;overflow:hidden;">
+        <table class="history-table">
+          <thead>
+            <tr>
+              <th data-i18n="history.th_file">Archivo</th>
+              <th data-i18n="admin.th_user">Usuario</th>
+              <th data-i18n="history.th_status">Estado</th>
+              <th data-i18n="history.th_language">Idioma</th>
+              <th data-i18n="settings.model">Modelo</th>
+              <th data-i18n="history.th_time">Tiempo</th>
+              <th data-i18n="admin.th_created">Creado</th>
+            </tr>
+          </thead>
+          <tbody id="admin-history-tbody">
+            <tr><td colspan="7" class="history-empty">Loading…</td></tr>
+          </tbody>
+        </table>
+      </div>
+    </div>
+
+    <!-- Metrics tab -->
+    <div id="admin-tab-metrics" class="hidden">
+      <div class="section-label" data-i18n="metrics.overview">Resumen</div>
+      <div id="metrics-content">
+        <div class="history-empty">Loading metrics…</div>
+      </div>
+    </div>
+
+  </main>
+</div>
+
+<!-- ═══════════════════ MODAL ═══════════════════ -->
+<div id="modal-overlay" class="hidden" onclick="if(event.target===this)closeModal()">
+  <div id="modal-box" class="modal">
+    <div id="modal-title" class="modal-title"></div>
+    <div id="modal-body"></div>
+    <div id="modal-actions" class="modal-actions"></div>
+  </div>
+</div>
+
+<!-- Toast -->
+<div id="toast"></div>
+
+<script>
+// ─── I18N ─────────────────────────────────────────────────────
+let currentLang = localStorage.getItem('tx_lang') || 'es';
+
+const T = {
+  en: {
+    'login.subtitle':'Audio Intelligence','login.email':'Email','login.email_ph':'your@email.com',
+    'login.password':'Password','login.sign_in':'Sign In',
+    'login.err_fill':'Please fill in both fields.','login.err_invalid':'Invalid email or password.',
+    'login.err_server':'Could not reach the server.',
+    'cp.title':'New Password','cp.head':'Account Security','cp.back':'← Back',
+    'cp.subtitle_voluntary':'Enter your current password, then choose a new one.',
+    'cp.subtitle_forced':'Welcome, <strong>{name}</strong>! Please set a new password to activate your account.',
+    'cp.fallback_name':'there',
+    'cp.current':'Current Password','cp.current_ph':'Your current password',
+    'cp.new':'New Password','cp.new_ph':'Minimum 8 characters',
+    'cp.confirm':'Confirm Password','cp.confirm_ph':'Repeat new password',
+    'cp.submit':'Set Password',
+    'cp.err_short':'Password must be at least 8 characters.','cp.err_mismatch':'Passwords do not match.',
+    'cp.err_current_required':'Current password is required.','cp.err_failed':'Could not change password.',
+    'cp.err_network':'Network error — please try again.','cp.success':'Password updated successfully',
+    'app.admin_panel':'Admin Panel','app.password':'Password','app.sign_out':'Sign out',
+    's.upload':'01 — Upload','upload.h3':'Drop your audio or video',
+    'upload.p':'MP3, MP4, WAV, OGG, M4A, WebM — any format',
+    's.settings':'02 — Settings','settings.language':'Language','settings.model':'Model',
+    'settings.prompt':'Initial Prompt','settings.prompt_optional':'(optional)','settings.prompt_expand':'▼ Show','settings.prompt_collapse':'▲ Hide',
+    'settings.prompt_ph':'e.g. proper nouns, technical terms, or context to help Whisper…',
+    'settings.prompt_hint':'Proper nouns, technical terms, or context that helps Whisper transcribe more accurately.',
+    'settings.formats':'Output Formats','fmt.txt_spk':'Text with speakers',
+    'fmt.srt_spk':'Subtitles with speakers','fmt.txt_only':'Text only','fmt.srt_only':'Subtitles only',
+    'submit.transcribe':'Transcribe','submit.select_fmt':'Select at least one output format.',
+    's.processing':'03 — Processing','step.transcribe':'Transcribe','step.diarize':'Diarize',
+    'step.combine':'Combine','step.done':'Done','status.initializing':'Initializing…',
+    'status.pending':'Pending','status.converting':'Converting','status.transcribing':'Transcribing',
+    'status.diarizing':'Identifying speakers','status.combining':'Combining',
+    'status.completed':'Completed','status.failed':'Failed',
+    'status.pending_desc':'Queued — waiting to start…','status.converting_desc':'Converting video to audio…',
+    'status.transcribing_desc':'<strong>Step 1</strong> — Running Whisper speech recognition…',
+    'status.diarizing_desc':'<strong>Step 2</strong> — Running PyAnnote speaker diarization…',
+    'status.combining_desc':'<strong>Step 3</strong> — Aligning words with speaker turns…',
+    'status.completed_desc':'Done! Your transcription is ready.','status.failed_desc':'An error occurred.',
+    's.results':'04 — Results','s.preview':'Preview — first segments',
+    'results.more':'+ {n} more segments','results.downloaded':'Downloaded {fmt}',
+    'results.dl_failed':'Download failed','results.err':'Error: {msg}',
+    'fmtlabel.txt':'TXT with speakers','fmtlabel.srt':'SRT with speakers',
+    'fmtlabel.txt_nh':'TXT plain text','fmtlabel.srt_nh':'SRT plain text',
+    'fmt.no_spk':'(no spk)',
+    'history.title':'Jobs History','history.refresh':'↻ Refresh',
+    'history.th_file':'File','history.th_status':'Status','history.th_language':'Language',
+    'history.th_segments':'Segments','history.th_time':'Time','history.th_actions':'Actions',
+    'history.loading':'Loading…','history.empty':'No jobs yet','history.job_is':'Job is {status}',
+    'admin.title':'Admin','admin.tab_users':'Users','admin.tab_history':'History','admin.tab_metrics':'Metrics','admin.back_app':'← App',
+    'admin.history_title':'Global Job History','admin.th_user':'User',
+    'admin.users_title':'User Accounts','admin.create_user':'+ Create User',
+    'admin.th_name':'Name','admin.th_email':'Email','admin.th_role':'Role',
+    'admin.th_password':'Password','admin.th_api_key':'API Key','admin.th_created':'Created',
+    'admin.th_actions':'Actions','admin.loading':'Loading…','admin.no_users':'No users yet',
+    'admin.pwd_default':'Default','admin.pwd_set':'Set','admin.api_yes':'Yes','admin.api_no':'No',
+    'admin.revoke_key':'Revoke Key','admin.gen_key':'Gen Key','admin.reset_pwd':'Reset Pwd',
+    'admin.delete':'Delete','admin.load_failed':'Failed to load users',
+    'metrics.overview':'Overview','metrics.loading':'Loading metrics…',
+    'metrics.failed':'Failed to load metrics','metrics.total_jobs':'Total Jobs',
+    'metrics.completed':'Completed','metrics.failed_label':'Failed',
+    'metrics.avg_transcription':'Avg Transcription','metrics.by_model':'By Model',
+    'metrics.by_language':'By Language','metrics.by_user':'By User','metrics.no_data':'No data',
+    'modal.create_user':'Create User','modal.email':'Email','modal.email_ph':'user@example.com',
+    'modal.full_name':'Full Name','modal.full_name_ph':'Jane Smith',
+    'modal.default_password':'Default Password',
+    'modal.default_password_ph':'Temporary password (min 8 chars)',
+    'modal.default_password_note':'User will be required to change this on first login.',
+    'modal.role':'Role','modal.role_user':'User','modal.role_user_desc':'Standard access',
+    'modal.role_admin':'Admin','modal.role_admin_desc':'Full access',
+    'modal.cancel':'Cancel','modal.create':'Create',
+    'modal.all_required':'All fields are required.',
+    'modal.pwd_short':'Password must be at least 8 characters.',
+    'modal.user_created':'User {name} created','modal.user_create_failed':'Could not create user.',
+    'modal.network_error':'Network error.',
+    'modal.reset_pwd_title':'Reset Password',
+    'modal.reset_pwd_desc':'Set a new default password for <strong style="color:var(--text)">{name}</strong>. They will be required to change it on next login.',
+    'modal.new_default_pwd':'New Default Password','modal.min_8_ph':'Minimum 8 characters',
+    'modal.reset':'Reset','modal.reset_failed':'Failed.',
+    'modal.pwd_reset_success':'Password reset — user must change on next login',
+    'modal.confirm_delete':'Delete user "{name}"? This cannot be undone.',
+    'modal.user_deleted':'User {name} deleted','modal.delete_failed':'Failed to delete user',
+    'modal.api_generated_title':'API Key Generated',
+    'modal.api_copy_note':'Copy this key — it will <strong style="color:var(--err)">not be shown again</strong>.',
+    'modal.api_share_note':'Share it with the user to allow direct REST API access via the <code style="font-family:\'IBM Plex Mono\',monospace;background:var(--s3);padding:1px 5px;border-radius:3px;">X-API-Key</code> header.',
+    'modal.copy_key':'Copy Key','modal.done':'Done',
+    'modal.key_copied':'Key copied to clipboard','modal.copy_failed':'Copy failed — select manually',
+    'modal.revoke_confirm':'Revoke API key for "{name}"? They will no longer be able to use the REST API.',
+    'modal.revoke_failed':'Failed to revoke key','modal.key_revoked':'API key revoked',
+    'modal.gen_failed':'Failed','network_error':'Network error','submission_failed':'Submission failed',
+  },
+  es: {
+    'login.subtitle':'Inteligencia de audio','login.email':'Correo electrónico','login.email_ph':'tu@correo.com',
+    'login.password':'Contraseña','login.sign_in':'Iniciar sesión',
+    'login.err_fill':'Por favor completa ambos campos.','login.err_invalid':'Correo o contraseña inválidos.',
+    'login.err_server':'No se pudo conectar al servidor.',
+    'cp.title':'Nueva contraseña','cp.head':'Seguridad de cuenta','cp.back':'← Volver',
+    'cp.subtitle_voluntary':'Ingresa tu contraseña actual y elige una nueva.',
+    'cp.subtitle_forced':'¡Bienvenido/a, <strong>{name}</strong>! Por favor establece una nueva contraseña para activar tu cuenta.',
+    'cp.fallback_name':'usuario',
+    'cp.current':'Contraseña actual','cp.current_ph':'Tu contraseña actual',
+    'cp.new':'Nueva contraseña','cp.new_ph':'Mínimo 8 caracteres',
+    'cp.confirm':'Confirmar contraseña','cp.confirm_ph':'Repite la nueva contraseña',
+    'cp.submit':'Establecer contraseña',
+    'cp.err_short':'La contraseña debe tener al menos 8 caracteres.','cp.err_mismatch':'Las contraseñas no coinciden.',
+    'cp.err_current_required':'La contraseña actual es requerida.','cp.err_failed':'No se pudo cambiar la contraseña.',
+    'cp.err_network':'Error de red — inténtalo de nuevo.','cp.success':'Contraseña actualizada correctamente',
+    'app.admin_panel':'Panel de admin','app.password':'Contraseña','app.sign_out':'Cerrar sesión',
+    's.upload':'01 — Subir archivo','upload.h3':'Arrastra tu audio o video',
+    'upload.p':'MP3, MP4, WAV, OGG, M4A, WebM — cualquier formato',
+    's.settings':'02 — Configuración','settings.language':'Idioma','settings.model':'Modelo',
+    'settings.prompt':'Prompt inicial','settings.prompt_optional':'(opcional)','settings.prompt_expand':'▼ Mostrar','settings.prompt_collapse':'▲ Ocultar',
+    'settings.prompt_ph':'Ej: nombres propios, términos técnicos o contexto que ayude a Whisper…',
+    'settings.prompt_hint':'Nombres propios, términos técnicos o contexto que ayude a Whisper a transcribir con mayor precisión.',
+    'settings.formats':'Formatos de salida','fmt.txt_spk':'Texto con hablantes',
+    'fmt.srt_spk':'Subtítulos con hablantes','fmt.txt_only':'Solo texto','fmt.srt_only':'Solo subtítulos',
+    'submit.transcribe':'Transcribir','submit.select_fmt':'Selecciona al menos un formato de salida.',
+    's.processing':'03 — Procesando','step.transcribe':'Transcribir','step.diarize':'Identificar',
+    'step.combine':'Combinar','step.done':'Listo','status.initializing':'Inicializando…',
+    'status.pending':'En espera','status.converting':'Convirtiendo','status.transcribing':'Transcribiendo',
+    'status.diarizing':'Identificando hablantes','status.combining':'Combinando',
+    'status.completed':'Completado','status.failed':'Fallido',
+    'status.pending_desc':'En cola — esperando inicio…','status.converting_desc':'Convirtiendo video a audio…',
+    'status.transcribing_desc':'<strong>Paso 1</strong> — Ejecutando reconocimiento de voz Whisper…',
+    'status.diarizing_desc':'<strong>Paso 2</strong> — Ejecutando diarización de hablantes PyAnnote…',
+    'status.combining_desc':'<strong>Paso 3</strong> — Alineando palabras con turnos de hablantes…',
+    'status.completed_desc':'¡Listo! Tu transcripción está disponible.','status.failed_desc':'Ocurrió un error.',
+    's.results':'04 — Resultados','s.preview':'Vista previa — primeros segmentos',
+    'results.more':'+ {n} segmentos más','results.downloaded':'Descargado {fmt}',
+    'results.dl_failed':'Error al descargar','results.err':'Error: {msg}',
+    'fmtlabel.txt':'TXT con hablantes','fmtlabel.srt':'SRT con hablantes',
+    'fmtlabel.txt_nh':'TXT texto plano','fmtlabel.srt_nh':'SRT texto plano',
+    'fmt.no_spk':'(sin hbl)',
+    'history.title':'Historial de trabajos','history.refresh':'↻ Actualizar',
+    'history.th_file':'Archivo','history.th_status':'Estado','history.th_language':'Idioma',
+    'history.th_segments':'Segmentos','history.th_time':'Tiempo','history.th_actions':'Acciones',
+    'history.loading':'Cargando…','history.empty':'Sin trabajos aún','history.job_is':'El trabajo está {status}',
+    'admin.title':'Admin','admin.tab_users':'Usuarios','admin.tab_history':'Historia','admin.tab_metrics':'Métricas','admin.back_app':'← Aplicación',
+    'admin.history_title':'Historial global de trabajos','admin.th_user':'Usuario',
+    'admin.users_title':'Cuentas de usuario','admin.create_user':'+ Crear usuario',
+    'admin.th_name':'Nombre','admin.th_email':'Correo','admin.th_role':'Rol',
+    'admin.th_password':'Contraseña','admin.th_api_key':'Clave API','admin.th_created':'Creado',
+    'admin.th_actions':'Acciones','admin.loading':'Cargando…','admin.no_users':'Sin usuarios aún',
+    'admin.pwd_default':'Por defecto','admin.pwd_set':'Establecida','admin.api_yes':'Sí','admin.api_no':'No',
+    'admin.revoke_key':'Revocar','admin.gen_key':'Gen. clave','admin.reset_pwd':'Restablecer',
+    'admin.delete':'Eliminar','admin.load_failed':'Error al cargar usuarios',
+    'metrics.overview':'Resumen','metrics.loading':'Cargando métricas…',
+    'metrics.failed':'Error al cargar métricas','metrics.total_jobs':'Total de trabajos',
+    'metrics.completed':'Completados','metrics.failed_label':'Fallidos',
+    'metrics.avg_transcription':'Transcripción prom.','metrics.by_model':'Por modelo',
+    'metrics.by_language':'Por idioma','metrics.by_user':'Por usuario','metrics.no_data':'Sin datos',
+    'modal.create_user':'Crear usuario','modal.email':'Correo electrónico','modal.email_ph':'usuario@ejemplo.com',
+    'modal.full_name':'Nombre completo','modal.full_name_ph':'Juan Pérez',
+    'modal.default_password':'Contraseña por defecto',
+    'modal.default_password_ph':'Contraseña temporal (mín. 8 caracteres)',
+    'modal.default_password_note':'El usuario deberá cambiarla en el primer inicio de sesión.',
+    'modal.role':'Rol','modal.role_user':'Usuario','modal.role_user_desc':'Acceso estándar',
+    'modal.role_admin':'Admin','modal.role_admin_desc':'Acceso completo',
+    'modal.cancel':'Cancelar','modal.create':'Crear',
+    'modal.all_required':'Todos los campos son requeridos.',
+    'modal.pwd_short':'La contraseña debe tener al menos 8 caracteres.',
+    'modal.user_created':'Usuario {name} creado','modal.user_create_failed':'No se pudo crear el usuario.',
+    'modal.network_error':'Error de red.',
+    'modal.reset_pwd_title':'Restablecer contraseña',
+    'modal.reset_pwd_desc':'Establece una nueva contraseña por defecto para <strong style="color:var(--text)">{name}</strong>. Deberá cambiarla en el siguiente inicio de sesión.',
+    'modal.new_default_pwd':'Nueva contraseña por defecto','modal.min_8_ph':'Mínimo 8 caracteres',
+    'modal.reset':'Restablecer','modal.reset_failed':'Error.',
+    'modal.pwd_reset_success':'Contraseña restablecida — el usuario debe cambiarla al siguiente inicio',
+    'modal.confirm_delete':'¿Eliminar usuario "{name}"? Esta acción no se puede deshacer.',
+    'modal.user_deleted':'Usuario {name} eliminado','modal.delete_failed':'Error al eliminar usuario',
+    'modal.api_generated_title':'Clave API generada',
+    'modal.api_copy_note':'Copia esta clave — <strong style="color:var(--err)">no se mostrará de nuevo</strong>.',
+    'modal.api_share_note':'Compártela con el usuario para acceso REST API directo mediante el encabezado <code style="font-family:\'IBM Plex Mono\',monospace;background:var(--s3);padding:1px 5px;border-radius:3px;">X-API-Key</code>.',
+    'modal.copy_key':'Copiar clave','modal.done':'Hecho',
+    'modal.key_copied':'Clave copiada al portapapeles','modal.copy_failed':'Error al copiar — selecciona manualmente',
+    'modal.revoke_confirm':'¿Revocar la clave API de "{name}"? Ya no podrán usar la REST API.',
+    'modal.revoke_failed':'Error al revocar la clave','modal.key_revoked':'Clave API revocada',
+    'modal.gen_failed':'Error','network_error':'Error de red','submission_failed':'Error al enviar',
+  },
+};
+
+function t(key, vars) {
+  let str = (T[currentLang]?.[key]) ?? (T['en']?.[key]) ?? key;
+  if (vars) for (const [k, v] of Object.entries(vars)) str = str.replaceAll('{' + k + '}', v);
+  return str;
+}
+
+function applyTranslations() {
+  document.documentElement.lang = currentLang;
+  document.querySelectorAll('[data-i18n]').forEach(el => { el.textContent = t(el.dataset.i18n); });
+  document.querySelectorAll('[data-i18n-placeholder]').forEach(el => { el.placeholder = t(el.dataset.i18nPlaceholder); });
+  document.querySelectorAll('.lang-toggle-btn').forEach(btn => { btn.textContent = currentLang === 'es' ? 'EN' : 'ES'; });
+}
+
+function toggleLanguage() {
+  currentLang = currentLang === 'es' ? 'en' : 'es';
+  localStorage.setItem('tx_lang', currentLang);
+  applyTranslations();
+  // re-render visible dynamic content
+  if (!document.getElementById('app-screen').classList.contains('hidden')) renderHistory();
+  if (!document.getElementById('admin-screen').classList.contains('hidden')) {
+    if (currentAdminTab === 'users') renderAdminUsers();
+    else if (cachedMetrics) renderMetrics(cachedMetrics);
+  }
+}
+
+// ─── STATE ────────────────────────────────────────────────────
+let currentUser   = null;  // { email, name, role, session_token }
+let currentJob    = null;
+let pollTimer     = null;
+let jobHistory    = [];
+let changePwForced   = false;
+let changePwReturnTo = 'app';
+let adminUsers    = [];
+let currentAdminTab = 'users';
+let cachedMetrics = null;
+
+// ─── INIT ─────────────────────────────────────────────────────
+window.addEventListener('DOMContentLoaded', () => {
+  applyTranslations();
+  setupDragDrop();
+
+  const saved = localStorage.getItem('tx_session');
+  if (saved) {
+    try {
+      currentUser = JSON.parse(saved);
+      verifyAndShow();
+    } catch { showLogin(); }
+  }
+
+  document.getElementById('login-password').addEventListener('keydown', e => {
+    if (e.key === 'Enter') login();
+  });
+  document.getElementById('login-email').addEventListener('keydown', e => {
+    if (e.key === 'Enter') document.getElementById('login-password').focus();
+  });
+  document.getElementById('toggle-password').addEventListener('click', () => {
+    const inp  = document.getElementById('login-password');
+    const show = document.getElementById('eye-show');
+    const hide = document.getElementById('eye-hide');
+    if (inp.type === 'password') {
+      inp.type = 'text'; show.style.display = 'none'; hide.style.display = '';
+    } else {
+      inp.type = 'password'; show.style.display = ''; hide.style.display = 'none';
+    }
+  });
+
+  document.getElementById('cp-new').addEventListener('keydown', e => {
+    if (e.key === 'Enter') document.getElementById('cp-confirm').focus();
+  });
+  document.getElementById('cp-confirm').addEventListener('keydown', e => {
+    if (e.key === 'Enter') submitChangePassword();
+  });
+});
+
+// ─── AUTH ─────────────────────────────────────────────────────
+async function verifyAndShow() {
+  try {
+    const res = await apiFetch('/auth/verify');
+    if (res.ok) {
+      const data = await res.json();
+      currentUser = { ...currentUser, ...data };
+      localStorage.setItem('tx_session', JSON.stringify(currentUser));
+      if (data.is_default_password) {
+        showChangePassword(true, 'app');
+      } else {
+        showApp();
+      }
+    } else {
+      logout();
+    }
+  } catch { showLogin(); }
+}
+
+async function login() {
+  const email    = document.getElementById('login-email').value.trim();
+  const password = document.getElementById('login-password').value;
+  if (!email || !password) { showLoginError(t('login.err_fill')); return; }
+
+  try {
+    const res  = await fetch('/auth/login', {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ email, password }),
+    });
+    const data = await res.json();
+    if (!res.ok) { showLoginError(data.detail || t('login.err_invalid')); return; }
+
+    currentUser = {
+      email: data.user.email,
+      name:  data.user.name,
+      role:  data.user.role,
+      session_token: data.session_token,
+    };
+    localStorage.setItem('tx_session', JSON.stringify(currentUser));
+
+    if (data.is_default_password) {
+      showChangePassword(true, 'app');
+    } else {
+      showApp();
+    }
+  } catch { showLoginError(t('login.err_server')); }
+}
+
+async function logout() {
+  if (currentUser?.session_token) {
+    try { await apiFetch('/auth/logout', { method: 'POST' }); } catch {}
+  }
+  localStorage.removeItem('tx_session');
+  currentUser = null;
+  if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
+  showLogin();
+}
+
+function showLoginError(msg) {
+  const el = document.getElementById('login-error');
+  el.textContent = msg;
+  el.classList.remove('hidden');
+  el.style.animation = 'none';
+  requestAnimationFrame(() => { el.style.animation = ''; });
+}
+
+// ─── CHANGE PASSWORD ──────────────────────────────────────────
+function showChangePassword(forced, returnTo) {
+  changePwForced   = forced;
+  changePwReturnTo = returnTo || 'app';
+
+  document.getElementById('cp-new').value     = '';
+  document.getElementById('cp-confirm').value = '';
+  document.getElementById('cp-current').value = '';
+  document.getElementById('cp-error').classList.add('hidden');
+
+  if (forced) {
+    document.getElementById('cp-back-btn').classList.add('hidden');
+    document.getElementById('cp-current-field').classList.add('hidden');
+    const name = currentUser?.name ? currentUser.name.split(' ')[0] : t('cp.fallback_name');
+    document.getElementById('cp-subtitle').innerHTML = t('cp.subtitle_forced', { name: escHtml(name) });
+  } else {
+    document.getElementById('cp-back-btn').classList.remove('hidden');
+    document.getElementById('cp-current-field').classList.remove('hidden');
+    document.getElementById('cp-subtitle').textContent = t('cp.subtitle_voluntary');
+  }
+
+  showScreen('change-password');
+}
+
+function cancelChangePassword() {
+  showScreen(changePwReturnTo);
+}
+
+function startChangePassword() {
+  showChangePassword(false, currentAdminTab === 'users' && document.getElementById('admin-screen').classList.contains('hidden') === false ? 'admin' : 'app');
+}
+
+async function submitChangePassword() {
+  const newPw  = document.getElementById('cp-new').value;
+  const confirm = document.getElementById('cp-confirm').value;
+  const currPw = document.getElementById('cp-current').value;
+  const errEl  = document.getElementById('cp-error');
+
+  errEl.classList.add('hidden');
+
+  if (newPw.length < 8) { showCpError(t('cp.err_short')); return; }
+  if (newPw !== confirm) { showCpError(t('cp.err_mismatch')); return; }
+  if (!changePwForced && !currPw) { showCpError(t('cp.err_current_required')); return; }
+
+  try {
+    const body = { new_password: newPw };
+    if (!changePwForced) body.current_password = currPw;
+
+    const res  = await apiFetch('/auth/change-password', {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify(body),
+    });
+    const data = await res.json();
+    if (!res.ok) { showCpError(data.detail || t('cp.err_failed')); return; }
+
+    toast(t('cp.success'));
+    showScreen(changePwReturnTo);
+  } catch { showCpError(t('cp.err_network')); }
+}
+
+function showCpError(msg) {
+  const el = document.getElementById('cp-error');
+  el.textContent = msg;
+  el.classList.remove('hidden');
+  el.style.animation = 'none';
+  requestAnimationFrame(() => { el.style.animation = ''; });
+}
+
+// ─── NAVIGATION ───────────────────────────────────────────────
+function showScreen(name) {
+  ['login', 'change-password', 'app', 'admin'].forEach(s => {
+    const el = document.getElementById(s + '-screen');
+    if (el) el.classList.toggle('hidden', s !== name);
+  });
+}
+
+function showLogin() {
+  document.getElementById('login-error').classList.add('hidden');
+  document.getElementById('login-password').value = '';
+  showScreen('login');
+}
+
+function showApp() {
+  updateUserBadges();
+  const adminBtn = document.getElementById('admin-btn');
+  if (adminBtn) adminBtn.classList.toggle('hidden', currentUser?.role !== 'admin');
+  showScreen('app');
+  loadHistory();
+}
+
+function showAdmin() {
+  updateUserBadges();
+  showScreen('admin');
+  switchAdminTab(currentAdminTab);
+}
+
+function updateUserBadges() {
+  const initial = (currentUser?.name || currentUser?.email || '?')[0].toUpperCase();
+  document.querySelectorAll('.user-avatar-el').forEach(el => el.textContent = initial);
+  document.querySelectorAll('.user-name-el').forEach(el => el.textContent = currentUser?.name || '—');
+  document.querySelectorAll('.user-email-el').forEach(el => el.textContent = currentUser?.email || '—');
+}
+
+// ─── API HELPER ───────────────────────────────────────────────
+function apiFetch(path, opts = {}) {
+  opts.headers = {
+    ...(opts.headers || {}),
+    'X-Session-Token': currentUser?.session_token || '',
+  };
+  return fetch(path, opts);
+}
+
+// ─── FILE UPLOAD ──────────────────────────────────────────────
+function setupDragDrop() {
+  const zone = document.getElementById('upload-zone');
+  zone.addEventListener('dragover', e => { e.preventDefault(); zone.classList.add('drag-over'); });
+  zone.addEventListener('dragleave', () => zone.classList.remove('drag-over'));
+  zone.addEventListener('drop', e => {
+    e.preventDefault();
+    zone.classList.remove('drag-over');
+    const f = e.dataTransfer.files[0];
+    if (f) setFile(f);
+  });
+}
+
+function onFileSelect(e) {
+  const f = e.target.files[0];
+  if (f) setFile(f);
+}
+
+function setFile(f) {
+  const dt = new DataTransfer();
+  dt.items.add(f);
+  document.getElementById('file-input').files = dt.files;
+  document.getElementById('file-name-display').textContent = f.name;
+  document.getElementById('file-chip').classList.remove('hidden');
+  document.getElementById('submit-btn').disabled = false;
+  document.getElementById('submit-hint').textContent = `${(f.size / 1024 / 1024).toFixed(1)} MB`;
+}
+
+function clearFile() {
+  document.getElementById('file-input').value = '';
+  document.getElementById('file-chip').classList.add('hidden');
+  document.getElementById('submit-btn').disabled = true;
+  document.getElementById('submit-hint').textContent = '';
+}
+
+function togglePromptField() {
+  const field = document.getElementById('prompt-field');
+  const btn = document.getElementById('prompt-toggle-btn');
+  const hidden = field.classList.toggle('hidden');
+  btn.setAttribute('data-i18n', hidden ? 'settings.prompt_expand' : 'settings.prompt_collapse');
+  btn.textContent = hidden ? (t('settings.prompt_expand') || '▼ Mostrar') : (t('settings.prompt_collapse') || '▲ Ocultar');
+}
+
+// ─── SUBMIT ───────────────────────────────────────────────────
+async function submitJob() {
+  const fileInput = document.getElementById('file-input');
+  if (!fileInput.files[0]) return;
+
+  const fmts = ['txt', 'srt', 'txt_nh', 'srt_nh'];
+  const anyFmt = fmts.some(f => document.getElementById('fmt-' + f).checked);
+  if (!anyFmt) { toast(t('submit.select_fmt')); return; }
+
+  const fd = new FormData();
+  fd.append('file',           fileInput.files[0]);
+  fd.append('language',       document.getElementById('lang-select').value);
+  fd.append('model',          document.getElementById('model-select').value);
+  fd.append('initial_prompt', document.getElementById('initial-prompt').value.trim());
+  fmts.forEach(f => fd.append(f, document.getElementById('fmt-' + f).checked));
+
+  document.getElementById('submit-btn').disabled = true;
+
+  try {
+    const res  = await apiFetch('/transcribe', { method: 'POST', body: fd });
+    const data = await res.json();
+    if (!res.ok) { toast(data.detail || t('submission_failed')); document.getElementById('submit-btn').disabled = false; return; }
+
+    currentJob = data;
+    showJobSection(fileInput.files[0].name, data.job_id);
+    startPolling(data.job_id);
+  } catch(e) {
+    toast(t('network_error') + ': ' + e.message);
+    document.getElementById('submit-btn').disabled = false;
+  }
+}
+
+// ─── JOB SECTION ─────────────────────────────────────────────
+function showJobSection(filename) {
+  document.getElementById('job-section').classList.remove('hidden');
+  document.getElementById('results-section').classList.add('hidden');
+  document.getElementById('job-filename').textContent = filename;
+  setStatus('pending');
+  document.getElementById('job-section').scrollIntoView({ behavior: 'smooth', block: 'start' });
+}
+
+function getStatusMap() {
+  return {
+    pending:      { label: t('status.pending'),      cls: 'status-pending',   desc: t('status.pending_desc'),      step: null },
+    converting:   { label: t('status.converting'),   cls: 'status-active',    desc: t('status.converting_desc'),   step: 'transcribing' },
+    transcribing: { label: t('status.transcribing'), cls: 'status-active',    desc: t('status.transcribing_desc'), step: 'transcribing' },
+    diarizing:    { label: t('status.diarizing'),    cls: 'status-active',    desc: t('status.diarizing_desc'),    step: 'diarizing' },
+    combining:    { label: t('status.combining'),    cls: 'status-active',    desc: t('status.combining_desc'),    step: 'combining' },
+    completed:    { label: t('status.completed'),    cls: 'status-completed', desc: t('status.completed_desc'),    step: 'completed' },
+    failed:       { label: t('status.failed'),       cls: 'status-failed',    desc: t('status.failed_desc'),       step: null },
+  };
+}
+const STEP_ORDER = ['transcribing', 'diarizing', 'combining', 'completed'];
+
+function setStatus(status, job) {
+  const sm = getStatusMap();
+  const info = sm[status] || sm['pending'];
+  const pill = document.getElementById('status-pill');
+  pill.className = 'status-pill ' + info.cls;
+  document.getElementById('status-text-pill').textContent = info.label;
+  document.getElementById('job-status-desc').innerHTML = info.desc;
+  document.getElementById('wave-anim').style.display = info.cls === 'status-active' ? '' : 'none';
+
+  STEP_ORDER.forEach(s => {
+    const el = document.getElementById('step-' + s);
+    if (el) el.classList.remove('active', 'done');
+  });
+  if (info.step) {
+    const idx = STEP_ORDER.indexOf(info.step);
+    STEP_ORDER.forEach((s, i) => {
+      const el = document.getElementById('step-' + s);
+      if (!el) return;
+      if (i < idx) el.classList.add('done');
+      if (i === idx) el.classList.add('active');
+    });
+  }
+  if (status === 'failed' && job?.error) {
+    document.getElementById('job-status-desc').innerHTML = `<span style="color:var(--err)">${escHtml(job.error)}</span>`;
+  }
+}
+
+// ─── POLLING ──────────────────────────────────────────────────
+function startPolling(jobId) {
+  if (pollTimer) clearInterval(pollTimer);
+  pollTimer = setInterval(async () => {
+    try {
+      const res = await apiFetch(`/jobs/${jobId}`);
+      const job = await res.json();
+      setStatus(job.status, job);
+      if (job.status === 'completed' || job.status === 'failed') {
+        clearInterval(pollTimer);
+        pollTimer = null;
+        if (job.status === 'completed') showResults(job);
+        loadHistory();
+        document.getElementById('submit-btn').disabled = false;
+      }
+    } catch {}
+  }, 5000);
+}
+
+// ─── RESULTS ─────────────────────────────────────────────────
+function getFmtMeta() {
+  return {
+    txt:    { icon: '📄', label: t('fmtlabel.txt') },
+    srt:    { icon: '🎬', label: t('fmtlabel.srt') },
+    txt_nh: { icon: '📄', label: t('fmtlabel.txt_nh') },
+    srt_nh: { icon: '🎬', label: t('fmtlabel.srt_nh') },
+  };
+}
+
+function showResults(job) {
+  document.getElementById('results-section').classList.remove('hidden');
+
+  const grid = document.getElementById('results-grid');
+  grid.innerHTML = '';
+  Object.keys(job.results).forEach(fmt => {
+    const m = getFmtMeta()[fmt] || { icon: '📁', label: fmt };
+    const btn = document.createElement('button');
+    btn.className = 'result-btn';
+    btn.innerHTML = `<span class="icon">${m.icon}</span><span class="format">.${fmt.split('_')[0]}</span><span class="label">${m.label}</span>`;
+    btn.onclick = () => downloadFmt(job.job_id, fmt);
+    grid.appendChild(btn);
+  });
+
+  const preview = document.getElementById('segments-preview');
+  preview.innerHTML = '';
+  const segs = (job.segments || []).slice(0, 5);
+  segs.forEach(s => {
+    const row = document.createElement('div');
+    row.className = 'segment-row';
+    const t   = srtTime(s.start) + ' – ' + srtTime(s.end);
+    const spk = s.speaker ? `<span class="seg-speaker">${escHtml(s.speaker)}</span>` : '<span style="color:var(--text3)">—</span>';
+    row.innerHTML = `<span class="seg-time">${t}</span>${spk}<span class="seg-text">${escHtml(s.text)}</span>`;
+    preview.appendChild(row);
+  });
+  const total = (job.segments || []).length;
+  if (total > 5) {
+    const more = document.createElement('div');
+    more.className = 'preview-more';
+    more.textContent = t('results.more', { n: total - 5 });
+    preview.appendChild(more);
+  }
+
+  document.getElementById('results-section').scrollIntoView({ behavior: 'smooth', block: 'start' });
+}
+
+async function downloadFmt(jobId, fmt) {
+  try {
+    const res = await apiFetch(`/jobs/${jobId}/download/${fmt}`);
+    if (!res.ok) { toast(t('results.dl_failed')); return; }
+    const blob = await res.blob();
+    const url  = URL.createObjectURL(blob);
+    const a    = document.createElement('a');
+    a.href     = url;
+    const ext  = fmt.includes('srt') ? 'srt' : 'txt';
+    a.download = `transcription_${fmt}.${ext}`;
+    document.body.appendChild(a);
+    a.click();
+    document.body.removeChild(a);
+    URL.revokeObjectURL(url);
+    toast(t('results.downloaded', { fmt }));
+  } catch(e) { toast(t('results.err', { msg: e.message })); }
+}
+
+// ─── HISTORY ─────────────────────────────────────────────────
+async function loadHistory() {
+  try {
+    const res  = await apiFetch('/jobs');
+    const list = await res.json();
+    jobHistory = list.sort((a, b) => (b.job_id > a.job_id ? 1 : -1));
+    renderHistory();
+  } catch {}
+}
+
+async function openHistoryJob(jobId) {
+  try {
+    const res = await apiFetch(`/jobs/${jobId}`);
+    const job = await res.json();
+    if (job.status !== 'completed') { toast(t('history.job_is', { status: t('status.' + job.status) || job.status })); return; }
+    document.getElementById('job-section').classList.remove('hidden');
+    document.getElementById('job-filename').textContent = job.filename || jobId;
+    setStatus('completed', job);
+    showResults(job);
+  } catch(e) { toast('Could not load job: ' + e.message); }
+}
+
+function renderHistory() {
+  const tbody = document.getElementById('history-tbody');
+  document.getElementById('history-count').textContent = jobHistory.length;
+
+  if (!jobHistory.length) {
+    tbody.innerHTML = `<tr><td colspan="6" class="history-empty">${t('history.empty')}</td></tr>`;
+    return;
+  }
+  tbody.innerHTML = jobHistory.map(j => {
+    const statusCls = j.status === 'completed' ? 'status-completed' : j.status === 'failed' ? 'status-failed' : 'status-active';
+    const fmts = j.status === 'completed' ? (j.formats || []) : [];
+    const dlBtns = fmts.map(fmt => {
+      const ext = fmt.includes('srt') ? 'srt' : 'txt';
+      return `<button class="btn btn-ghost btn-sm" style="padding:3px 8px;font-size:10px;" onclick="event.stopPropagation();downloadFmt('${j.job_id}','${fmt}')">.${ext}${fmt.includes('_nh') ? ' ' + t('fmt.no_spk') : ''}</button>`;
+    }).join(' ');
+    const rowClick = j.status === 'completed' ? `onclick="openHistoryJob('${j.job_id}')" style="cursor:pointer;"` : '';
+    return `<tr ${rowClick}>
+      <td class="mono" style="max-width:180px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="${escHtml(j.filename || '')}">${escHtml(j.filename || '—')}</td>
+      <td><span class="status-pill ${statusCls}" style="font-size:10px;padding:3px 9px;"><span class="status-dot" style="width:5px;height:5px;"></span>${t('status.' + j.status) || j.status}</span></td>
+      <td class="mono" style="color:var(--text2)">${j.language || '—'}</td>
+      <td class="mono" style="color:var(--text2)">${j.segments ? j.segments.length : '—'}</td>
+      <td class="mono" style="color:var(--text2)">${j.transcription_time != null ? j.transcription_time + 's' : '—'}</td>
+      <td style="white-space:nowrap;">${dlBtns || '<span style="color:var(--text3);font-size:11px;">—</span>'}</td>
+    </tr>`;
+  }).join('');
+}
+
+// ═══════════════════════════════════════════
+// ADMIN PANEL
+// ═══════════════════════════════════════════
+
+function switchAdminTab(tab) {
+  currentAdminTab = tab;
+  document.getElementById('admin-tab-users').classList.toggle('hidden', tab !== 'users');
+  document.getElementById('admin-tab-history').classList.toggle('hidden', tab !== 'history');
+  document.getElementById('admin-tab-metrics').classList.toggle('hidden', tab !== 'metrics');
+  document.getElementById('tab-btn-users').classList.toggle('active', tab === 'users');
+  document.getElementById('tab-btn-history').classList.toggle('active', tab === 'history');
+  document.getElementById('tab-btn-metrics').classList.toggle('active', tab === 'metrics');
+
+  if (tab === 'users') loadAdminUsers();
+  if (tab === 'history') loadAdminJobs();
+  if (tab === 'metrics') loadMetrics();
+}
+
+async function loadAdminJobs() {
+  try {
+    const res = await apiFetch('/admin/jobs');
+    const jobs = await res.json();
+    renderAdminJobs(jobs);
+  } catch(e) {
+    document.getElementById('admin-history-tbody').innerHTML = `<tr><td colspan="7" class="history-empty" style="color:var(--err)">Error: ${escHtml(e.message)}</td></tr>`;
+  }
+}
+
+function renderAdminJobs(jobs) {
+  const tbody = document.getElementById('admin-history-tbody');
+  if (!jobs.length) {
+    tbody.innerHTML = `<tr><td colspan="7" class="history-empty">${t('history.empty')}</td></tr>`;
+    return;
+  }
+  tbody.innerHTML = jobs.map(j => {
+    const statusCls = j.status === 'completed' ? 'status-completed' : j.status === 'failed' ? 'status-failed' : 'status-active';
+    const created = j.created_at ? new Date(j.created_at).toLocaleString() : '—';
+    return `<tr>
+      <td class="mono" style="max-width:160px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="${escHtml(j.filename || '')}">${escHtml(j.filename || '—')}</td>
+      <td class="mono" style="color:var(--text2);font-size:11px;">${escHtml(j.submitted_by || '—')}</td>
+      <td><span class="status-pill ${statusCls}" style="font-size:10px;padding:3px 9px;"><span class="status-dot" style="width:5px;height:5px;"></span>${t('status.' + j.status) || j.status}</span></td>
+      <td class="mono" style="color:var(--text2)">${j.language || '—'}</td>
+      <td class="mono" style="color:var(--text2)">${j.model || '—'}</td>
+      <td class="mono" style="color:var(--text2)">${j.transcription_time != null ? j.transcription_time + 's' : '—'}</td>
+      <td class="mono" style="color:var(--text3);font-size:11px;">${created}</td>
+    </tr>`;
+  }).join('');
+}
+
+// ─── USERS ────────────────────────────────────────────────────
+async function loadAdminUsers() {
+  try {
+    const res = await apiFetch('/admin/users');
+    if (!res.ok) { toast(t('admin.load_failed')); return; }
+    adminUsers = await res.json();
+    renderAdminUsers();
+  } catch(e) { toast('Error: ' + e.message); }
+}
+
+function renderAdminUsers() {
+  const tbody = document.getElementById('admin-users-tbody');
+  if (!adminUsers.length) {
+    tbody.innerHTML = `<tr><td colspan="7" class="history-empty">${t('admin.no_users')}</td></tr>`;
+    return;
+  }
+  tbody.innerHTML = adminUsers.map(u => {
+    const roleCls = u.role === 'admin' ? 'badge-admin' : 'badge-user';
+    const pwdCls  = u.is_default_password ? 'badge-default' : 'badge-set';
+    const pwdLbl  = u.is_default_password ? t('admin.pwd_default') : t('admin.pwd_set');
+    const apiCls  = u.has_api_key ? 'badge-yes' : 'badge-no';
+    const apiLbl  = u.has_api_key ? t('admin.api_yes') : t('admin.api_no');
+    const created = u.created_at ? u.created_at.split('T')[0] : '—';
+    const isSelf  = u.email === currentUser?.email;
+    const apiBtn  = u.has_api_key
+      ? `<button class="btn btn-danger btn-sm" style="font-size:10px;padding:3px 10px;" onclick="revokeApiKey('${u.id}','${escAttr(u.name)}')">${t('admin.revoke_key')}</button>`
+      : `<button class="btn btn-success btn-sm" style="font-size:10px;padding:3px 10px;" onclick="generateApiKey('${u.id}')">${t('admin.gen_key')}</button>`;
+    const delBtn = isSelf ? '' : `<button class="btn btn-danger btn-sm" style="font-size:10px;padding:3px 10px;" onclick="deleteUser('${u.id}','${escAttr(u.name)}')">${t('admin.delete')}</button>`;
+    return `<tr>
+      <td style="font-weight:600;">${escHtml(u.name)}</td>
+      <td class="mono" style="font-size:11px;color:var(--text2);">${escHtml(u.email)}</td>
+      <td><span class="badge ${roleCls}">${u.role}</span></td>
+      <td><span class="badge ${pwdCls}">${pwdLbl}</span></td>
+      <td><span class="badge ${apiCls}">${apiLbl}</span></td>
+      <td class="mono" style="color:var(--text3);font-size:11px;">${created}</td>
+      <td style="white-space:nowrap;display:flex;gap:6px;flex-wrap:wrap;">
+        <button class="btn btn-ghost btn-sm" style="font-size:10px;padding:3px 10px;" onclick="openResetPasswordModal('${u.id}','${escAttr(u.name)}')">${t('admin.reset_pwd')}</button>
+        ${apiBtn}
+        ${delBtn}
+      </td>
+    </tr>`;
+  }).join('');
+}
+
+// Create user modal
+function openCreateUserModal() {
+  document.getElementById('modal-title').textContent = t('modal.create_user');
+  document.getElementById('modal-body').innerHTML = `
+    <div class="modal-field">
+      <label>${t('modal.email')}</label>
+      <input type="email" id="mu-email" placeholder="${escHtml(t('modal.email_ph'))}">
+    </div>
+    <div class="modal-field">
+      <label>${t('modal.full_name')}</label>
+      <input type="text" id="mu-name" placeholder="${escHtml(t('modal.full_name_ph'))}">
+    </div>
+    <div class="modal-field">
+      <label>${t('modal.default_password')}</label>
+      <input type="text" id="mu-password" placeholder="${escHtml(t('modal.default_password_ph'))}">
+      <p class="modal-note">${t('modal.default_password_note')}</p>
+    </div>
+    <div class="modal-field">
+      <label>${t('modal.role')}</label>
+      <div class="role-toggle">
+        <div class="role-option">
+          <input type="radio" name="mu-role" id="mu-role-user" value="user" checked>
+          <label for="mu-role-user">${t('modal.role_user')}<br><span style="font-size:10px;color:var(--text3)">${t('modal.role_user_desc')}</span></label>
+        </div>
+        <div class="role-option">
+          <input type="radio" name="mu-role" id="mu-role-admin" value="admin">
+          <label for="mu-role-admin">${t('modal.role_admin')}<br><span style="font-size:10px;color:var(--text3)">${t('modal.role_admin_desc')}</span></label>
+        </div>
+      </div>
+    </div>
+    <div id="mu-error" class="modal-error hidden"></div>
+  `;
+  document.getElementById('modal-actions').innerHTML = `
+    <button class="btn btn-ghost" onclick="closeModal()">${t('modal.cancel')}</button>
+    <button class="btn btn-primary" onclick="createUser()">${t('modal.create')}</button>
+  `;
+  document.getElementById('modal-overlay').classList.remove('hidden');
+}
+
+async function createUser() {
+  const email    = document.getElementById('mu-email').value.trim();
+  const name     = document.getElementById('mu-name').value.trim();
+  const password = document.getElementById('mu-password').value;
+  const role     = document.querySelector('input[name="mu-role"]:checked').value;
+  const errEl    = document.getElementById('mu-error');
+  errEl.classList.add('hidden');
+
+  if (!email || !name || !password) { errEl.textContent = t('modal.all_required'); errEl.classList.remove('hidden'); return; }
+  if (password.length < 8) { errEl.textContent = t('modal.pwd_short'); errEl.classList.remove('hidden'); return; }
+
+  try {
+    const res  = await apiFetch('/admin/users', {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ email, name, password, role }),
+    });
+    const data = await res.json();
+    if (!res.ok) { errEl.textContent = data.detail || t('modal.user_create_failed'); errEl.classList.remove('hidden'); return; }
+    closeModal();
+    toast(t('modal.user_created', { name }));
+    loadAdminUsers();
+  } catch(e) { errEl.textContent = t('modal.network_error'); errEl.classList.remove('hidden'); }
+}
+
+// Reset password modal
+function openResetPasswordModal(userId, userName) {
+  document.getElementById('modal-title').textContent = t('modal.reset_pwd_title');
+  document.getElementById('modal-body').innerHTML = `
+    <p style="font-size:13px;color:var(--text2);margin-bottom:16px;">
+      ${t('modal.reset_pwd_desc', { name: escHtml(userName) })}
+    </p>
+    <div class="modal-field">
+      <label>${t('modal.new_default_pwd')}</label>
+      <input type="text" id="rp-password" placeholder="${escHtml(t('modal.min_8_ph'))}">
+    </div>
+    <div id="rp-error" class="modal-error hidden"></div>
+  `;
+  document.getElementById('modal-actions').innerHTML = `
+    <button class="btn btn-ghost" onclick="closeModal()">${t('modal.cancel')}</button>
+    <button class="btn btn-primary" onclick="resetPassword('${userId}')">${t('modal.reset')}</button>
+  `;
+  document.getElementById('modal-overlay').classList.remove('hidden');
+}
+
+async function resetPassword(userId) {
+  const password = document.getElementById('rp-password').value;
+  const errEl    = document.getElementById('rp-error');
+  errEl.classList.add('hidden');
+
+  if (password.length < 8) { errEl.textContent = t('modal.pwd_short'); errEl.classList.remove('hidden'); return; }
+
+  try {
+    const res = await apiFetch(`/admin/users/${userId}/reset-password`, {
+      method: 'PATCH',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ new_password: password }),
+    });
+    if (!res.ok) { const d = await res.json(); errEl.textContent = d.detail || t('modal.reset_failed'); errEl.classList.remove('hidden'); return; }
+    closeModal();
+    toast(t('modal.pwd_reset_success'));
+    loadAdminUsers();
+  } catch { errEl.textContent = t('modal.network_error'); errEl.classList.remove('hidden'); }
+}
+
+// Delete user
+async function deleteUser(userId, userName) {
+  if (!confirm(t('modal.confirm_delete', { name: userName }))) return;
+  try {
+    const res = await apiFetch(`/admin/users/${userId}`, { method: 'DELETE' });
+    if (res.status === 400) { const d = await res.json(); toast(d.detail); return; }
+    if (!res.ok) { toast(t('modal.delete_failed')); return; }
+    toast(t('modal.user_deleted', { name: userName }));
+    loadAdminUsers();
+  } catch { toast(t('network_error')); }
+}
+
+// Generate API key
+async function generateApiKey(userId) {
+  try {
+    const res  = await apiFetch(`/admin/users/${userId}/api-key`, { method: 'POST' });
+    const data = await res.json();
+    if (!res.ok) { toast(data.detail || t('modal.gen_failed')); return; }
+    loadAdminUsers();
+    showApiKeyModal(data.api_key);
+  } catch { toast('Network error'); }
+}
+
+function showApiKeyModal(key) {
+  document.getElementById('modal-title').textContent = t('modal.api_generated_title');
+  document.getElementById('modal-body').innerHTML = `
+    <p style="font-size:13px;color:var(--text2);margin-bottom:4px;">${t('modal.api_copy_note')}</p>
+    <div class="key-display" id="api-key-display">${escHtml(key)}</div>
+    <p class="modal-note">${t('modal.api_share_note')}</p>
+  `;
+  document.getElementById('modal-actions').innerHTML = `
+    <button class="btn btn-ghost" onclick="copyApiKey('${escAttr(key)}')">${t('modal.copy_key')}</button>
+    <button class="btn btn-primary" onclick="closeModal()">${t('modal.done')}</button>
+  `;
+  document.getElementById('modal-overlay').classList.remove('hidden');
+}
+
+function copyApiKey(key) {
+  navigator.clipboard.writeText(key).then(() => toast(t('modal.key_copied'))).catch(() => toast(t('modal.copy_failed')));
+}
+
+// Revoke API key
+async function revokeApiKey(userId, userName) {
+  if (!confirm(t('modal.revoke_confirm', { name: userName }))) return;
+  try {
+    const res = await apiFetch(`/admin/users/${userId}/api-key`, { method: 'DELETE' });
+    if (!res.ok) { toast(t('modal.revoke_failed')); return; }
+    toast(t('modal.key_revoked'));
+    loadAdminUsers();
+  } catch { toast(t('network_error')); }
+}
+
+// ─── METRICS ──────────────────────────────────────────────────
+async function loadMetrics() {
+  const container = document.getElementById('metrics-content');
+  container.innerHTML = `<div class="history-empty">${t('metrics.loading')}</div>`;
+  try {
+    const res  = await apiFetch('/admin/metrics');
+    const data = await res.json();
+    renderMetrics(data);
+  } catch(e) {
+    container.innerHTML = `<div class="history-empty" style="color:var(--err)">${t('metrics.failed')}</div>`;
+  }
+}
+
+function renderMetrics(d) {
+  cachedMetrics = d;
+  const completed = d.by_status?.completed || 0;
+  const failed    = d.by_status?.failed    || 0;
+
+  const cards = `
+    <div class="metrics-grid">
+      <div class="metric-card accent">
+        <div class="metric-value">${d.total_jobs}</div>
+        <div class="metric-label">${t('metrics.total_jobs')}</div>
+      </div>
+      <div class="metric-card ok">
+        <div class="metric-value">${completed}</div>
+        <div class="metric-label">${t('metrics.completed')}</div>
+      </div>
+      <div class="metric-card err">
+        <div class="metric-value">${failed}</div>
+        <div class="metric-label">${t('metrics.failed_label')}</div>
+      </div>
+      <div class="metric-card">
+        <div class="metric-value">${d.avg_transcription_time != null ? d.avg_transcription_time + 's' : '—'}</div>
+        <div class="metric-label">${t('metrics.avg_transcription')}</div>
+      </div>
+    </div>`;
+
+  const noData = `<div style="color:var(--text3);font-size:12px;">${t('metrics.no_data')}</div>`;
+  const modelBars = barList(d.by_model);
+  const langBars  = barList(d.by_language);
+  const userBars  = barList(d.by_user);
+
+  const cols = `
+    <div class="metrics-cols">
+      <div class="card" style="margin-bottom:0;">
+        <div class="section-label" style="margin-bottom:12px;">${t('metrics.by_model')}</div>
+        ${modelBars || noData}
+      </div>
+      <div class="card" style="margin-bottom:0;">
+        <div class="section-label" style="margin-bottom:12px;">${t('metrics.by_language')}</div>
+        ${langBars || noData}
+      </div>
+      <div class="card" style="margin-bottom:0;">
+        <div class="section-label" style="margin-bottom:12px;">${t('metrics.by_user')}</div>
+        ${userBars || noData}
+      </div>
+    </div>`;
+
+  document.getElementById('metrics-content').innerHTML = cards + cols;
+}
+
+function barList(obj) {
+  if (!obj || !Object.keys(obj).length) return '';
+  const entries = Object.entries(obj).sort((a, b) => b[1] - a[1]).slice(0, 8);
+  const max = entries[0][1];
+  return `<div class="bar-list">${entries.map(([label, count]) => `
+    <div class="bar-item">
+      <div class="bar-label" title="${escHtml(label)}">${escHtml(label)}</div>
+      <div class="bar-track"><div class="bar-fill" style="width:${Math.round(count/max*100)}%"></div></div>
+      <div class="bar-count">${count}</div>
+    </div>`).join('')}</div>`;
+}
+
+// ═══════════════════════════════════════════
+// MODAL
+// ═══════════════════════════════════════════
+function closeModal() {
+  document.getElementById('modal-overlay').classList.add('hidden');
+}
+
+// ─── HELPERS ─────────────────────────────────────────────────
+function srtTime(sec) {
+  const t = Math.floor(sec);
+  const h = Math.floor(t / 3600);
+  const m = Math.floor((t % 3600) / 60);
+  const s = t % 60;
+  return `${pad(h)}:${pad(m)}:${pad(s)}`;
+}
+function pad(n) { return String(n).padStart(2, '0'); }
+
+function escHtml(str) {
+  return String(str)
+    .replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
+}
+
+function escAttr(str) {
+  return String(str).replace(/'/g, "\\'").replace(/"/g, '&quot;');
+}
+
+let toastTimer = null;
+function toast(msg) {
+  const el = document.getElementById('toast');
+  el.textContent = msg;
+  el.classList.add('show');
+  if (toastTimer) clearTimeout(toastTimer);
+  toastTimer = setTimeout(() => el.classList.remove('show'), 2800);
+}
+</script>
+</body>
+</html>

+ 33 - 0
workers/diarize_worker.py

@@ -0,0 +1,33 @@
+#!/home/superti/miniconda3/envs/diarization/bin/python
+import warnings
+warnings.filterwarnings("ignore")
+import json
+from pyannote.audio import Pipeline
+import os
+from torch import device as torch_device
+import sys
+import dotenv
+dotenv.load_dotenv()
+
+def diarize(file):
+    pipeline = Pipeline.from_pretrained(
+        "pyannote/speaker-diarization-3.1",
+        token=os.environ["HF_TOKEN"],
+    )
+    pipeline.to(torch_device("cuda"))
+    if not pipeline:
+        raise RuntimeError("Pipeline not found")
+
+    segments = pipeline(file)
+    diariz = []
+    for turn, speaker in segments.speaker_diarization:
+        diariz.append({
+            "start": turn.start,
+            "end": turn.end,
+            "speaker": speaker
+        })
+
+    print(json.dumps(diariz))
+
+if __name__ == "__main__":
+    diarize(sys.argv[1])