From 008ef63bfda85ed1add82cad29c3ab24d4f5ab6e Mon Sep 17 00:00:00 2001 From: Dominic Ballenthin Date: Wed, 28 Jan 2026 23:16:44 +0100 Subject: [PATCH] Initial commit: Whisper API with FastAPI, GPU support and Admin Dashboard --- .env.example | 27 +++ .gitignore | 48 +++++ README.md | 335 ++++++++++++++++++++++++++++++++ data/.gitkeep | 0 docker-compose.yml | 39 ++++ docker/Dockerfile | 44 +++++ models/.gitkeep | 0 requirements.txt | 35 ++++ src/__init__.py | 0 src/api/__init__.py | 0 src/api/health.py | 43 ++++ src/api/transcriptions.py | 160 +++++++++++++++ src/config.py | 51 +++++ src/database/__init__.py | 32 +++ src/database/db.py | 32 +++ src/database/models.py | 30 +++ src/main.py | 49 +++++ src/services/__init__.py | 0 src/services/cleanup_service.py | 44 +++++ src/services/stats_service.py | 112 +++++++++++ src/services/whisper_service.py | 109 +++++++++++ src/templates/base.html | 229 ++++++++++++++++++++++ src/templates/dashboard.html | 116 +++++++++++ src/templates/keys.html | 110 +++++++++++ src/templates/login.html | 39 ++++ src/web/__init__.py | 0 src/web/routes.py | 187 ++++++++++++++++++ uploads/.gitkeep | 0 28 files changed, 1871 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 README.md create mode 100644 data/.gitkeep create mode 100644 docker-compose.yml create mode 100644 docker/Dockerfile create mode 100644 models/.gitkeep create mode 100644 requirements.txt create mode 100644 src/__init__.py create mode 100644 src/api/__init__.py create mode 100644 src/api/health.py create mode 100644 src/api/transcriptions.py create mode 100644 src/config.py create mode 100644 src/database/__init__.py create mode 100644 src/database/db.py create mode 100644 src/database/models.py create mode 100644 src/main.py create mode 100644 src/services/__init__.py create mode 100644 src/services/cleanup_service.py create mode 100644 src/services/stats_service.py create mode 100644 src/services/whisper_service.py create mode 100644 src/templates/base.html create mode 100644 src/templates/dashboard.html create mode 100644 src/templates/keys.html create mode 100644 src/templates/login.html create mode 100644 src/web/__init__.py create mode 100644 src/web/routes.py create mode 100644 uploads/.gitkeep diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..13daa6c --- /dev/null +++ b/.env.example @@ -0,0 +1,27 @@ +# Whisper API Environment Configuration + +# Server Settings +PORT=8000 +HOST=0.0.0.0 + +# Whisper Model Configuration +WHISPER_MODEL=large-v3 +WHISPER_DEVICE=cuda +WHISPER_COMPUTE_TYPE=float16 + +# API Authentication +# Multiple API keys separated by commas +API_KEYS=sk-your-api-key-here,sk-another-api-key + +# Admin Panel Authentication +ADMIN_USER=admin +ADMIN_PASSWORD=-whisper12510- + +# Data Retention (days) +LOG_RETENTION_DAYS=30 + +# Optional: Sentry for error tracking +# SENTRY_DSN=https://your-sentry-dsn-here + +# Optional: HuggingFace Cache (for model downloads) +# HF_HOME=/app/models diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fe230a0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,48 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +env/ +venv/ +.venv/ +*.egg-info/ +dist/ +build/ + +# Environment +.env +.env.local + +# Database +*.db +*.sqlite +*.sqlite3 + +# Models (large files) +models/* +!models/.gitkeep + +# Uploads +uploads/* +!uploads/.gitkeep + +# Data +data/* +!data/.gitkeep + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log +logs/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..92ed2b3 --- /dev/null +++ b/README.md @@ -0,0 +1,335 @@ +# Whisper API + +Eine lokale Whisper-API mit GPU-Beschleunigung und Web-Admin-Interface für die Transkription von Audio-Dateien. + +## Features + +- **OpenAI-kompatible API** - Drop-in Ersatz für OpenAI Whisper API +- **GPU-beschleunigt** - Nutzt NVIDIA GPUs (CUDA) für schnelle Transkription +- **Default: large-v3** - Beste Qualität mit deiner RTX 3090 +- **Web-Admin-Interface** - API-Key Management und Statistiken unter `/admin` +- **API-Key Authentifizierung** - Sichere Zugriffskontrolle +- **Cross-Platform** - Docker-basiert, läuft auf Windows und Linux +- **Automatische Cleanup** - Logs nach 30 Tagen automatisch gelöscht + +## Architektur + +``` +┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐ +│ Client/App │────▶│ FastAPI App │────▶│ Whisper GPU │ +│ (Clawdbot etc) │ │ (Port 8000) │ │ (large-v3) │ +└─────────────────┘ └──────────────────┘ └─────────────────┘ + │ + ▼ + ┌──────────────────┐ + │ /admin Panel │ + │ - Key Mgmt │ + │ - Dashboard │ + │ - Logs │ + └──────────────────┘ +``` + +## Schnellstart + +### Voraussetzungen + +- Docker Desktop (Windows) oder Docker + docker-compose (Linux) +- NVIDIA GPU mit CUDA-Unterstützung (RTX 3090) +- NVIDIA Container Toolkit installiert + +### Installation + +1. **Repository klonen:** +```bash +git clone https://gitea.ragtag.rocks/b0rborad/whisper-api.git +cd whisper-api +``` + +2. **Umgebungsvariablen konfigurieren:** +```bash +cp .env.example .env +# Bearbeite .env nach deinen Wünschen +``` + +3. **Docker-Container starten:** +```bash +docker-compose up -d +``` + +4. **Erster Start:** + - Das `large-v3` Modell (~3GB) wird automatisch heruntergeladen + - Dies kann 5-10 Minuten dauern + - Status überprüfen: `docker-compose logs -f` + +### Verifizierung + +```bash +# Health-Check +curl http://localhost:8000/health + +# API-Info +curl http://localhost:8000/v1/models +``` + +## API-Dokumentation + +### Authentifizierung + +Alle API-Endpunkte (außer `/health` und `/admin`) benötigen einen API-Key: + +```bash +Authorization: Bearer sk-dein-api-key-hier +``` + +### Endpunkte + +#### POST /v1/audio/transcriptions + +Transkribiert eine Audio-Datei. + +**Request:** +```bash +curl -X POST http://localhost:8000/v1/audio/transcriptions \ + -H "Authorization: Bearer sk-dein-api-key" \ + -H "Content-Type: multipart/form-data" \ + -F "file=@/pfad/zur/audio.mp3" \ + -F "model=large-v3" \ + -F "language=de" \ + -F "response_format=json" +``` + +**Response:** +```json +{ + "text": "Hallo Welt, das ist ein Test." +} +``` + +#### POST /v1/audio/transcriptions (mit Timestamps) + +**Request:** +```bash +curl -X POST http://localhost:8000/v1/audio/transcriptions \ + -H "Authorization: Bearer sk-dein-api-key" \ + -F "file=@audio.mp3" \ + -F "timestamp_granularities[]=word" \ + -F "response_format=verbose_json" +``` + +**Response:** +```json +{ + "text": "Hallo Welt", + "segments": [ + { + "id": 0, + "start": 0.0, + "end": 1.5, + "text": "Hallo Welt", + "words": [ + {"word": "Hallo", "start": 0.0, "end": 0.5}, + {"word": "Welt", "start": 0.6, "end": 1.2} + ] + } + ] +} +``` + +#### GET /v1/models + +Liste verfügbarer Modelle. + +#### GET /health + +Health-Check mit GPU-Status. + +**Response:** +```json +{ + "status": "healthy", + "gpu": { + "available": true, + "name": "NVIDIA GeForce RTX 3090", + "vram_used": "2.1 GB", + "vram_total": "24.0 GB" + }, + "model": "large-v3", + "version": "1.0.0" +} +``` + +## Admin-Interface + +Das Web-Interface ist erreichbar unter: `http://localhost:8000/admin` + +### Login + +- **Benutzername:** `admin` (konfigurierbar in `.env`) +- **Passwort:** `-whisper12510-` (konfigurierbar in `.env`) + +### Features + +- **Dashboard:** Übersicht über Nutzung, Performance-Statistiken +- **API-Keys:** Verwalten (erstellen, deaktivieren, löschen) +- **Logs:** Detaillierte Transkriptions-Logs mit Filter + +## Konfiguration + +### .env.example + +```bash +# Server +PORT=8000 +HOST=0.0.0.0 + +# Whisper +WHISPER_MODEL=large-v3 +WHISPER_DEVICE=cuda +WHISPER_COMPUTE_TYPE=float16 + +# Authentifizierung +# Mehrere API-Keys mit Komma trennen +API_KEYS=sk-dein-erster-key,sk-dein-zweiter-key +ADMIN_USER=admin +ADMIN_PASSWORD=-whisper12510- + +# Daten-Retention (Tage) +LOG_RETENTION_DAYS=30 + +# Optional: Sentry für Error-Tracking +# SENTRY_DSN=https://... +``` + +### Docker-Compose Anpassungen + +```yaml +services: + whisper-api: + # ... + environment: + - PORT=8000 # Änderbar + - WHISPER_MODEL=large-v3 + volumes: + - ./models:/app/models # Persistiert Modelle + - ./data:/app/data # SQLite Datenbank + - ./uploads:/app/uploads # Temporäre Uploads + deploy: + resources: + reservations: + devices: + - driver: nvidia + count: 1 + capabilities: [gpu] +``` + +## Migration zu Linux + +Die Docker-Konfiguration ist plattformunabhängig. Für Linux: + +1. **NVIDIA Docker installieren:** +```bash +# Ubuntu/Debian +distribution=$(. /etc/os-release;echo $ID$VERSION_ID) +curl -s -L https://nvidia.github.io/nvidia-docker/gpgkey | sudo apt-key add - +curl -s -L https://nvidia.github.io/nvidia-docker/$distribution/nvidia-docker.list | sudo tee /etc/apt/sources.list.d/nvidia-docker.list + +sudo apt-get update +sudo apt-get install -y nvidia-docker2 +sudo systemctl restart docker +``` + +2. **Projekt klonen und starten:** +```bash +git clone https://gitea.ragtag.rocks/b0rborad/whisper-api.git +cd whisper-api +docker-compose up -d +``` + +3. **GPU-Passthrough verifizieren:** +```bash +docker run --rm --gpus all nvidia/cuda:12.0-base nvidia-smi +``` + +## Integration mit Clawdbot + +Für die Integration in einen Clawdbot Skill: + +```python +import requests + +API_URL = "http://localhost:8000/v1/audio/transcriptions" +API_KEY = "sk-dein-api-key" + +def transcribe_audio(audio_path): + with open(audio_path, "rb") as f: + response = requests.post( + API_URL, + headers={"Authorization": f"Bearer {API_KEY}"}, + files={"file": f}, + data={"language": "de"} + ) + return response.json()["text"] +``` + +## Performance + +Mit RTX 3090 und large-v3: +- **1 Minute Audio:** ~3-5 Sekunden Verarbeitungszeit +- **VRAM-Nutzung:** ~10 GB +- **Batch-Verarbeitung:** Möglich für parallele Requests + +## Troubleshooting + +### GPU nicht erkannt + +```bash +# NVIDIA Container Toolkit prüfen +docker run --rm --gpus all nvidia/cuda:12.0-base nvidia-smi + +# Logs prüfen +docker-compose logs whisper-api +``` + +### Modell-Download langsam + +```bash +# Manuelles Downloaden möglich +mkdir -p models +# Modelle werden von HuggingFace heruntergeladen +``` + +### Port belegt + +```bash +# Port in .env ändern +PORT=8001 +``` + +## Backup + +Wichtige Daten: +- `./data/` - SQLite Datenbank (API-Keys, Logs) +- `./models/` - Heruntergeladene Whisper-Modelle +- `./.env` - Konfiguration + +```bash +# Backup erstellen +tar -czvf whisper-api-backup.tar.gz data/ models/ .env +``` + +## Lizenz + +MIT License - Siehe LICENSE Datei + +## Support + +Bei Problemen: +1. Logs prüfen: `docker-compose logs -f` +2. Health-Check: `curl http://localhost:8000/health` +3. Issue auf Gitea erstellen + +--- + +**Erstellt für:** b0rborad @ ragtag.rocks +**Hardware:** Dual RTX 3090 Setup +**Zweck:** Clawdbot Skill Integration diff --git a/data/.gitkeep b/data/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..b69fa60 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,39 @@ +version: '3.8' + +services: + whisper-api: + build: + context: . + dockerfile: docker/Dockerfile + container_name: whisper-api + restart: unless-stopped + ports: + - "${PORT:-8000}:8000" + environment: + - PORT=8000 + - HOST=0.0.0.0 + - WHISPER_MODEL=${WHISPER_MODEL:-large-v3} + - WHISPER_DEVICE=${WHISPER_DEVICE:-cuda} + - WHISPER_COMPUTE_TYPE=${WHISPER_COMPUTE_TYPE:-float16} + - API_KEYS=${API_KEYS} + - ADMIN_USER=${ADMIN_USER:-admin} + - ADMIN_PASSWORD=${ADMIN_PASSWORD:--whisper12510-} + - LOG_RETENTION_DAYS=${LOG_RETENTION_DAYS:-30} + - DATABASE_URL=sqlite:///app/data/whisper_api.db + volumes: + - ./models:/app/models + - ./data:/app/data + - ./uploads:/app/uploads + deploy: + resources: + reservations: + devices: + - driver: nvidia + count: all + capabilities: [gpu] + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 60s diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000..28c94d6 --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,44 @@ +FROM nvidia/cuda:12.1.0-runtime-ubuntu22.04 + +# Prevent interactive prompts +ENV DEBIAN_FRONTEND=noninteractive + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + python3.11 \ + python3.11-pip \ + python3.11-venv \ + ffmpeg \ + curl \ + && rm -rf /var/lib/apt/lists/* + +# Set Python 3.11 as default +RUN update-alternatives --install /usr/bin/python python /usr/bin/python3.11 1 +RUN update-alternatives --install /usr/bin/pip pip /usr/bin/pip3 1 + +# Set working directory +WORKDIR /app + +# Copy requirements first for better caching +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Create necessary directories +RUN mkdir -p /app/models /app/data /app/uploads + +# Copy application code +COPY src/ ./src/ + +# Set environment variables +ENV PYTHONPATH=/app +ENV PYTHONUNBUFFERED=1 + +# Expose port +EXPOSE 8000 + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \ + CMD curl -f http://localhost:8000/health || exit 1 + +# Run the application +CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/models/.gitkeep b/models/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..c387a9e --- /dev/null +++ b/requirements.txt @@ -0,0 +1,35 @@ +# Web Framework +fastapi==0.109.0 +uvicorn[standard]==0.27.0 +python-multipart==0.0.6 +jinja2==3.1.3 +aiofiles==23.2.1 + +# Whisper +openai-whisper==20231117 +faster-whisper==0.10.0 +torch==2.1.2 +torchaudio==2.1.2 + +# Database +sqlalchemy==2.0.25 +alembic==1.13.1 + +# Audio processing +librosa==0.10.1 +soundfile==0.12.1 + +# Utilities +python-dotenv==1.0.0 +pydantic==2.5.3 +pydantic-settings==2.1.0 +httpx==0.26.0 + +# Monitoring +psutil==5.9.6 + +# Scheduling +apscheduler==3.10.4 + +# Optional: Sentry +# sentry-sdk==1.39.2 diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/api/__init__.py b/src/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/api/health.py b/src/api/health.py new file mode 100644 index 0000000..3754394 --- /dev/null +++ b/src/api/health.py @@ -0,0 +1,43 @@ +from fastapi import APIRouter +import torch +import psutil + +from src.config import settings +from src.services.whisper_service import get_model_info + +router = APIRouter() + + +@router.get("/health") +async def health_check(): + """Health check endpoint with GPU status""" + + # Get GPU info if available + gpu_info = {"available": False} + if torch.cuda.is_available(): + gpu_name = torch.cuda.get_device_name(0) + vram_total = torch.cuda.get_device_properties(0).total_memory / (1024**3) + vram_allocated = torch.cuda.memory_allocated(0) / (1024**3) + + gpu_info = { + "available": True, + "name": gpu_name, + "vram_total_gb": round(vram_total, 2), + "vram_used_gb": round(vram_allocated, 2), + "vram_free_gb": round(vram_total - vram_allocated, 2) + } + + # Get system info + memory = psutil.virtual_memory() + + return { + "status": "healthy", + "version": "1.0.0", + "model": settings.whisper_model, + "gpu": gpu_info, + "system": { + "cpu_percent": psutil.cpu_percent(interval=1), + "memory_percent": memory.percent, + "memory_available_gb": round(memory.available / (1024**3), 2) + } + } diff --git a/src/api/transcriptions.py b/src/api/transcriptions.py new file mode 100644 index 0000000..aac0dda --- /dev/null +++ b/src/api/transcriptions.py @@ -0,0 +1,160 @@ +from fastapi import APIRouter, File, UploadFile, Form, HTTPException, Depends, Header +from fastapi.responses import JSONResponse +from typing import Optional, List +import time +import os +import hashlib + +from src.config import settings +from src.services.whisper_service import transcribe_audio +from src.services.stats_service import log_usage +from src.database.db import get_db +from sqlalchemy.orm import Session + +router = APIRouter() + + +def verify_api_key(authorization: Optional[str] = Header(None)): + """Verify API key from Authorization header""" + if not authorization: + raise HTTPException(status_code=401, detail="Authorization header missing") + + # Extract Bearer token + if not authorization.startswith("Bearer "): + raise HTTPException(status_code=401, detail="Invalid authorization format") + + api_key = authorization.replace("Bearer ", "").strip() + valid_keys = settings.get_api_keys_list() + + if not valid_keys: + raise HTTPException(status_code=500, detail="No API keys configured") + + if api_key not in valid_keys: + raise HTTPException(status_code=401, detail="Invalid API key") + + return api_key + + +@router.get("/models") +async def list_models(api_key: str = Depends(verify_api_key)): + """List available models (OpenAI compatible)""" + return { + "data": [ + { + "id": "whisper-1", + "object": "model", + "created": 1677532384, + "owned_by": "openai" + }, + { + "id": "large-v3", + "object": "model", + "created": 1698796800, + "owned_by": "openai" + } + ] + } + + +@router.post("/audio/transcriptions") +async def create_transcription( + file: UploadFile = File(...), + model: str = Form("whisper-1"), + language: Optional[str] = Form(None), + prompt: Optional[str] = Form(None), + response_format: str = Form("json"), + temperature: float = Form(0.0), + timestamp_granularities: Optional[List[str]] = Form(None), + api_key: str = Depends(verify_api_key), + db: Session = Depends(get_db) +): + """ + Transcribe audio file (OpenAI compatible endpoint) + + - **file**: Audio file (mp3, mp4, mpeg, mpga, m4a, wav, webm) + - **model**: Model ID (whisper-1 or large-v3) + - **language**: Language code (e.g., 'de', 'en') + - **response_format**: json, text, srt, verbose_json, vtt + - **timestamp_granularities**: word, segment (for verbose_json) + """ + + start_time = time.time() + temp_path = None + + try: + # Validate file type + allowed_extensions = {'.mp3', '.mp4', '.mpeg', '.mpga', '.m4a', '.wav', '.webm'} + file_ext = os.path.splitext(file.filename)[1].lower() + + if file_ext not in allowed_extensions: + raise HTTPException( + status_code=400, + detail=f"Unsupported file format: {file_ext}" + ) + + # Save uploaded file + temp_filename = f"{api_key[:8]}_{int(time.time())}_{file.filename}" + temp_path = os.path.join(settings.uploads_path, temp_filename) + + with open(temp_path, "wb") as f: + content = await file.read() + f.write(content) + + file_size = len(content) + + # Transcribe + include_word_timestamps = timestamp_granularities and "word" in timestamp_granularities + + result = await transcribe_audio( + audio_path=temp_path, + language=language, + include_word_timestamps=include_word_timestamps + ) + + processing_time = int((time.time() - start_time) * 1000) + + # Log usage + await log_usage( + db=db, + api_key=api_key, + endpoint="/v1/audio/transcriptions", + file_size=file_size, + duration=result.get("duration"), + processing_time=processing_time, + model=settings.whisper_model, + status="success" + ) + + # Format response based on requested format + if response_format == "text": + return result["text"] + elif response_format == "verbose_json": + return result + else: + return {"text": result["text"]} + + except Exception as e: + processing_time = int((time.time() - start_time) * 1000) + + # Log error + await log_usage( + db=db, + api_key=api_key, + endpoint="/v1/audio/transcriptions", + file_size=file_size if 'file_size' in locals() else None, + duration=None, + processing_time=processing_time, + model=settings.whisper_model, + status="error", + error_message=str(e) + ) + + raise HTTPException(status_code=500, detail=str(e)) + + finally: + # Cleanup temp file + if temp_path and os.path.exists(temp_path): + try: + os.remove(temp_path) + except: + pass diff --git a/src/config.py b/src/config.py new file mode 100644 index 0000000..f886e1a --- /dev/null +++ b/src/config.py @@ -0,0 +1,51 @@ +import os +from functools import lru_cache +from pydantic_settings import BaseSettings + + +class Settings(BaseSettings): + """Application settings""" + # Server + port: int = 8000 + host: str = "0.0.0.0" + + # Whisper + whisper_model: str = "large-v3" + whisper_device: str = "cuda" + whisper_compute_type: str = "float16" + + # Auth + api_keys: str = "" + admin_user: str = "admin" + admin_password: str = "-whisper12510-" + + # Database + database_url: str = "sqlite:///app/data/whisper_api.db" + + # Retention + log_retention_days: int = 30 + + # Paths + models_path: str = "/app/models" + uploads_path: str = "/app/uploads" + + class Config: + env_file = ".env" + env_file_encoding = "utf-8" + env_prefix = "" + case_sensitive = False + extra = "ignore" + + def get_api_keys_list(self): + """Get list of valid API keys""" + if not self.api_keys: + return [] + return [key.strip() for key in self.api_keys.split(",") if key.strip()] + + +@lru_cache() +def get_settings(): + return Settings() + + +settings = get_settings() diff --git a/src/database/__init__.py b/src/database/__init__.py new file mode 100644 index 0000000..9e69cf4 --- /dev/null +++ b/src/database/__init__.py @@ -0,0 +1,32 @@ +from sqlalchemy import create_engine +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker + +from src.config import settings + +# Create engine +engine = create_engine( + settings.database_url, + connect_args={"check_same_thread": False} if "sqlite" in settings.database_url else {} +) + +# Create session factory +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +# Base class for models +Base = declarative_base() + + +async def init_db(): + """Initialize database tables""" + from src.database import models + Base.metadata.create_all(bind=engine) + + +def get_db(): + """Dependency to get database session""" + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/src/database/db.py b/src/database/db.py new file mode 100644 index 0000000..9e69cf4 --- /dev/null +++ b/src/database/db.py @@ -0,0 +1,32 @@ +from sqlalchemy import create_engine +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker + +from src.config import settings + +# Create engine +engine = create_engine( + settings.database_url, + connect_args={"check_same_thread": False} if "sqlite" in settings.database_url else {} +) + +# Create session factory +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +# Base class for models +Base = declarative_base() + + +async def init_db(): + """Initialize database tables""" + from src.database import models + Base.metadata.create_all(bind=engine) + + +def get_db(): + """Dependency to get database session""" + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/src/database/models.py b/src/database/models.py new file mode 100644 index 0000000..815fead --- /dev/null +++ b/src/database/models.py @@ -0,0 +1,30 @@ +from sqlalchemy import Column, Integer, String, DateTime, Boolean, Float, Text +from sqlalchemy.sql import func +from src.database.db import Base + + +class ApiKey(Base): + __tablename__ = "api_keys" + + id = Column(Integer, primary_key=True, index=True) + key_hash = Column(String(64), unique=True, index=True) + description = Column(String(255), nullable=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + last_used_at = Column(DateTime(timezone=True), nullable=True) + is_active = Column(Boolean, default=True) + usage_count = Column(Integer, default=0) + + +class UsageLog(Base): + __tablename__ = "usage_logs" + + id = Column(Integer, primary_key=True, index=True) + api_key_id = Column(Integer, nullable=True) + endpoint = Column(String(100)) + file_size_bytes = Column(Integer, nullable=True) + duration_seconds = Column(Float, nullable=True) + processing_time_ms = Column(Integer) + model_used = Column(String(50)) + status = Column(String(20)) # success, error + error_message = Column(Text, nullable=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) diff --git a/src/main.py b/src/main.py new file mode 100644 index 0000000..a19a5e0 --- /dev/null +++ b/src/main.py @@ -0,0 +1,49 @@ +from fastapi import FastAPI, Request +from fastapi.staticfiles import StaticFiles +from fastapi.templating import Jinja2Templates +from contextlib import asynccontextmanager +import os + +from src.api import transcriptions, health +from src.web import routes as web_routes +from src.database.db import init_db +from src.services.cleanup_service import start_cleanup_scheduler + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """Application lifespan manager""" + # Startup + await init_db() + start_cleanup_scheduler() + yield + # Shutdown + pass + + +app = FastAPI( + title="Whisper API", + description="Local Whisper API with GPU acceleration", + version="1.0.0", + lifespan=lifespan +) + +# Mount static files +app.mount("/static", StaticFiles(directory="src/web/static"), name="static") + +# Include API routes +app.include_router(health.router, tags=["Health"]) +app.include_router(transcriptions.router, prefix="/v1", tags=["Transcriptions"]) + +# Include web routes +app.include_router(web_routes.router, prefix="/admin", tags=["Admin"]) + +# Root redirect to admin +@app.get("/") +async def root(): + return {"message": "Whisper API is running", "admin": "/admin", "docs": "/docs"} + + +if __name__ == "__main__": + import uvicorn + uvicorn.run(app, host="0.0.0.0", port=8000) diff --git a/src/services/__init__.py b/src/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/services/cleanup_service.py b/src/services/cleanup_service.py new file mode 100644 index 0000000..a99ddb4 --- /dev/null +++ b/src/services/cleanup_service.py @@ -0,0 +1,44 @@ +import asyncio +from datetime import datetime +from apscheduler.schedulers.asyncio import AsyncIOScheduler +from apscheduler.triggers.cron import CronTrigger + +from src.config import settings +from src.database.db import SessionLocal +from src.services.stats_service import cleanup_old_logs + +scheduler = None + + +async def cleanup_job(): + """Daily cleanup job""" + print(f"[{datetime.now()}] Running cleanup job...") + db = SessionLocal() + try: + deleted = await cleanup_old_logs(db, settings.log_retention_days) + print(f"[{datetime.now()}] Cleaned up {deleted} old log entries") + finally: + db.close() + + +def start_cleanup_scheduler(): + """Start the background cleanup scheduler""" + global scheduler + scheduler = AsyncIOScheduler() + + # Run cleanup daily at 3 AM + scheduler.add_job( + cleanup_job, + trigger=CronTrigger(hour=3, minute=0), + id="cleanup_logs", + replace_existing=True + ) + + scheduler.start() + print("Cleanup scheduler started (runs daily at 3:00 AM)") + + +def stop_cleanup_scheduler(): + """Stop the scheduler""" + if scheduler: + scheduler.shutdown() diff --git a/src/services/stats_service.py b/src/services/stats_service.py new file mode 100644 index 0000000..757eaa9 --- /dev/null +++ b/src/services/stats_service.py @@ -0,0 +1,112 @@ +import hashlib +from datetime import datetime, timedelta +from typing import Optional, List +from sqlalchemy.orm import Session +from sqlalchemy import func + +from src.database.models import ApiKey, UsageLog + + +def hash_api_key(key: str) -> str: + """Hash API key for storage""" + return hashlib.sha256(key.encode()).hexdigest() + + +async def log_usage( + db: Session, + api_key: str, + endpoint: str, + file_size: Optional[int], + duration: Optional[float], + processing_time: int, + model: str, + status: str, + error_message: Optional[str] = None +): + """Log API usage""" + key_hash = hash_api_key(api_key) + + # Find or create API key record + db_key = db.query(ApiKey).filter(ApiKey.key_hash == key_hash).first() + if not db_key: + db_key = ApiKey( + key_hash=key_hash, + description=f"Auto-created for key {api_key[:8]}..." + ) + db.add(db_key) + db.commit() + db.refresh(db_key) + + # Update last used + db_key.last_used_at = datetime.utcnow() + db_key.usage_count += 1 + + # Create usage log + log = UsageLog( + api_key_id=db_key.id, + endpoint=endpoint, + file_size_bytes=file_size, + duration_seconds=duration, + processing_time_ms=processing_time, + model_used=model, + status=status, + error_message=error_message + ) + db.add(log) + db.commit() + + +async def get_usage_stats(db: Session, days: int = 30): + """Get usage statistics for dashboard""" + since = datetime.utcnow() - timedelta(days=days) + + # Total requests + total_requests = db.query(UsageLog).filter(UsageLog.created_at >= since).count() + + # Success rate + success_count = db.query(UsageLog).filter( + UsageLog.created_at >= since, + UsageLog.status == "success" + ).count() + success_rate = (success_count / total_requests * 100) if total_requests > 0 else 0 + + # Average processing time + avg_time = db.query(func.avg(UsageLog.processing_time_ms)).filter( + UsageLog.created_at >= since, + UsageLog.status == "success" + ).scalar() or 0 + + # Daily stats for chart + daily_stats = db.query( + func.date(UsageLog.created_at).label("date"), + func.count().label("count"), + func.avg(UsageLog.processing_time_ms).label("avg_time") + ).filter( + UsageLog.created_at >= since + ).group_by( + func.date(UsageLog.created_at) + ).order_by("date").all() + + # Recent logs + recent_logs = db.query(UsageLog).order_by( + UsageLog.created_at.desc() + ).limit(50).all() + + return { + "total_requests": total_requests, + "success_rate": round(success_rate, 2), + "avg_processing_time_ms": round(avg_time, 2), + "daily_stats": [ + {"date": str(stat.date), "count": stat.count, "avg_time": round(stat.avg_time or 0, 2)} + for stat in daily_stats + ], + "recent_logs": recent_logs + } + + +async def cleanup_old_logs(db: Session, retention_days: int): + """Delete logs older than retention period""" + cutoff = datetime.utcnow() - timedelta(days=retention_days) + deleted = db.query(UsageLog).filter(UsageLog.created_at < cutoff).delete() + db.commit() + return deleted diff --git a/src/services/whisper_service.py b/src/services/whisper_service.py new file mode 100644 index 0000000..c84a8c1 --- /dev/null +++ b/src/services/whisper_service.py @@ -0,0 +1,109 @@ +import whisper +import torch +import os +from typing import Optional, Dict, Any +import asyncio +from concurrent.futures import ThreadPoolExecutor + +from src.config import settings + +# Global model cache +_model = None +_executor = ThreadPoolExecutor(max_workers=1) + + +def load_model(): + """Load Whisper model""" + global _model + if _model is None: + print(f"Loading Whisper model: {settings.whisper_model}") + _model = whisper.load_model( + settings.whisper_model, + device=settings.whisper_device, + download_root=settings.models_path + ) + print(f"Model loaded on {settings.whisper_device}") + return _model + + +def get_model_info(): + """Get model information""" + model = load_model() + return { + "name": settings.whisper_model, + "device": settings.whisper_device, + "loaded": _model is not None + } + + +def _transcribe_sync( + audio_path: str, + language: Optional[str] = None, + include_word_timestamps: bool = False +) -> Dict[str, Any]: + """Synchronous transcription (runs in thread pool)""" + model = load_model() + + # Set decode options + decode_options = {} + if language: + decode_options["language"] = language + + # Transcribe + result = model.transcribe( + audio_path, + **decode_options, + word_timestamps=include_word_timestamps + ) + + # Format result + output = { + "text": result["text"].strip(), + "task": "transcribe", + "language": result.get("language", "unknown"), + "duration": result.get("duration", 0) + } + + # Add segments if available + if "segments" in result: + segments = [] + for segment in result["segments"]: + seg_data = { + "id": segment["id"], + "start": segment["start"], + "end": segment["end"], + "text": segment["text"].strip() + } + + # Add word timestamps if requested + if include_word_timestamps and "words" in segment: + seg_data["words"] = [ + { + "word": word["word"].strip(), + "start": word["start"], + "end": word["end"] + } + for word in segment["words"] + ] + + segments.append(seg_data) + + output["segments"] = segments + + return output + + +async def transcribe_audio( + audio_path: str, + language: Optional[str] = None, + include_word_timestamps: bool = False +) -> Dict[str, Any]: + """Async wrapper for transcription""" + loop = asyncio.get_event_loop() + return await loop.run_in_executor( + _executor, + _transcribe_sync, + audio_path, + language, + include_word_timestamps + ) diff --git a/src/templates/base.html b/src/templates/base.html new file mode 100644 index 0000000..b58e2e9 --- /dev/null +++ b/src/templates/base.html @@ -0,0 +1,229 @@ + + + + + + {% block title %}Whisper API Admin{% endblock %} + + + {% block extra_css %}{% endblock %} + + + {% block content %}{% endblock %} + + {% block extra_js %}{% endblock %} + + diff --git a/src/templates/dashboard.html b/src/templates/dashboard.html new file mode 100644 index 0000000..e32f320 --- /dev/null +++ b/src/templates/dashboard.html @@ -0,0 +1,116 @@ +{% extends "base.html" %} + +{% block title %}Dashboard - Whisper API Admin{% endblock %} + +{% block content %} +
+
+

🎯 Whisper API Dashboard

+ +
+ +
+
+
{{ stats.total_requests }}
+
Requests (30 Tage)
+
+
+
{{ stats.success_rate }}%
+
Success Rate
+
+
+
{{ stats.avg_processing_time_ms }}ms
+
Ø Processing Time
+
+
+
{{ model }}
+
Model
+
+
+ +
+

📊 Usage Chart (Last 30 Days)

+ +
+ +
+

📝 Recent Activity

+ + + + + + + + + + + + {% for log in stats.recent_logs[:10] %} + + + + + + + + {% endfor %} + +
TimeEndpointDurationStatusProcessing
{{ log.created_at.strftime('%Y-%m-%d %H:%M:%S') }}{{ log.endpoint }} + {% if log.duration_seconds %} + {{ "%.1f"|format(log.duration_seconds) }}s + {% else %} + - + {% endif %} + + + {{ log.status }} + + {{ log.processing_time_ms }}ms
+
+ +
+

ℹ️ System Info

+

Model: {{ model }}

+

Log Retention: {{ retention_days }} days

+

API Version: v1.0.0

+
+
+{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/src/templates/keys.html b/src/templates/keys.html new file mode 100644 index 0000000..8658e93 --- /dev/null +++ b/src/templates/keys.html @@ -0,0 +1,110 @@ +{% extends "base.html" %} + +{% block title %}API Keys - Whisper API Admin{% endblock %} + +{% block content %} +
+
+

🔐 API Key Management

+ +
+ + {% if message %} +
+ {{ message }} +
+ {% endif %} + + {% if new_key %} +
+

✨ New API Key Generated

+

Copy this key now - it will not be shown again!

+
{{ new_key }}
+ +
+ {% endif %} + +
+

Create New API Key

+
+
+ + +
+ +
+
+ +
+

Active API Keys

+ + + + + + + + + + + + + + {% for key in keys %} + + + + + + + + + + {% endfor %} + +
IDDescriptionCreatedLast UsedUsage CountStatusActions
{{ key.id }}{{ key.description or '-' }}{{ key.created_at.strftime('%Y-%m-%d %H:%M') }} + {% if key.last_used_at %} + {{ key.last_used_at.strftime('%Y-%m-%d %H:%M') }} + {% else %} + Never + {% endif %} + {{ key.usage_count }} + + {{ 'Active' if key.is_active else 'Inactive' }} + + +
+ +
+
+ +
+
+ + {% if not keys %} +

+ No API keys found. Create one above. +

+ {% endif %} +
+
+ + +{% endblock %} diff --git a/src/templates/login.html b/src/templates/login.html new file mode 100644 index 0000000..033cf4c --- /dev/null +++ b/src/templates/login.html @@ -0,0 +1,39 @@ +{% extends "base.html" %} + +{% block title %}Login - Whisper API Admin{% endblock %} + +{% block content %} +
+
+
+

🔑 Admin Login

+ + {% if error %} +
+ {{ error }} +
+ {% endif %} + +
+
+ + +
+ +
+ + +
+ + +
+ +

+ Default: admin / -whisper12510- +

+
+
+
+{% endblock %} diff --git a/src/web/__init__.py b/src/web/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/web/routes.py b/src/web/routes.py new file mode 100644 index 0000000..3b621e0 --- /dev/null +++ b/src/web/routes.py @@ -0,0 +1,187 @@ +import uuid +import hashlib +from datetime import datetime +from typing import Optional +from fastapi import APIRouter, Request, Form, HTTPException, Depends +from fastapi.responses import HTMLResponse, RedirectResponse +from fastapi.templating import Jinja2Templates +from sqlalchemy.orm import Session + +from src.config import settings +from src.database.db import get_db +from src.database.models import ApiKey, UsageLog +from src.services.stats_service import get_usage_stats, hash_api_key + +router = APIRouter() +templates = Jinja2Templates(directory="src/templates") + +# Simple session store (in production, use Redis or proper session management) +active_sessions = {} + + +def generate_api_key(): + """Generate a new API key""" + return f"sk-{uuid.uuid4().hex}" + + +def check_admin_auth(request: Request): + """Check if user is authenticated as admin""" + session_token = request.cookies.get("admin_session") + if not session_token or session_token not in active_sessions: + raise HTTPException(status_code=302, detail="/admin/login") + return True + + +@router.get("/login", response_class=HTMLResponse) +async def login_page(request: Request, error: Optional[str] = None): + """Show login page""" + return templates.TemplateResponse("login.html", { + "request": request, + "error": error + }) + + +@router.post("/login") +async def login( + request: Request, + username: str = Form(...), + password: str = Form(...) +): + """Process login""" + if username == settings.admin_user and password == settings.admin_password: + session_token = uuid.uuid4().hex + active_sessions[session_token] = { + "username": username, + "login_time": datetime.utcnow() + } + + response = RedirectResponse(url="/admin", status_code=302) + response.set_cookie( + key="admin_session", + value=session_token, + httponly=True, + max_age=86400 # 24 hours + ) + return response + + return templates.TemplateResponse("login.html", { + "request": request, + "error": "Invalid username or password" + }) + + +@router.get("/logout") +async def logout(request: Request): + """Logout user""" + session_token = request.cookies.get("admin_session") + if session_token in active_sessions: + del active_sessions[session_token] + + response = RedirectResponse(url="/admin/login", status_code=302) + response.delete_cookie("admin_session") + return response + + +@router.get("/", response_class=HTMLResponse) +async def dashboard(request: Request, db: Session = Depends(get_db)): + """Admin dashboard""" + try: + check_admin_auth(request) + except HTTPException as e: + return RedirectResponse(url="/admin/login", status_code=302) + + stats = await get_usage_stats(db, days=30) + + return templates.TemplateResponse("dashboard.html", { + "request": request, + "stats": stats, + "model": settings.whisper_model, + "retention_days": settings.log_retention_days + }) + + +@router.get("/keys", response_class=HTMLResponse) +async def manage_keys(request: Request, db: Session = Depends(get_db)): + """API key management page""" + try: + check_admin_auth(request) + except HTTPException as e: + return RedirectResponse(url="/admin/login", status_code=302) + + keys = db.query(ApiKey).order_by(ApiKey.created_at.desc()).all() + + return templates.TemplateResponse("keys.html", { + "request": request, + "keys": keys + }) + + +@router.post("/keys/create") +async def create_key( + request: Request, + description: str = Form(""), + db: Session = Depends(get_db) +): + """Create new API key""" + try: + check_admin_auth(request) + except HTTPException as e: + return RedirectResponse(url="/admin/login", status_code=302) + + new_key = generate_api_key() + key_hash = hash_api_key(new_key) + + api_key = ApiKey( + key_hash=key_hash, + description=description or f"Created {datetime.now().strftime('%Y-%m-%d %H:%M')}" + ) + db.add(api_key) + db.commit() + + # Store the plain key temporarily to show to user (only once) + return templates.TemplateResponse("keys.html", { + "request": request, + "keys": db.query(ApiKey).order_by(ApiKey.created_at.desc()).all(), + "new_key": new_key, + "message": "API Key created successfully! Copy it now - it won't be shown again." + }) + + +@router.post("/keys/{key_id}/toggle") +async def toggle_key( + request: Request, + key_id: int, + db: Session = Depends(get_db) +): + """Toggle API key active status""" + try: + check_admin_auth(request) + except HTTPException as e: + return RedirectResponse(url="/admin/login", status_code=302) + + api_key = db.query(ApiKey).filter(ApiKey.id == key_id).first() + if api_key: + api_key.is_active = not api_key.is_active + db.commit() + + return RedirectResponse(url="/admin/keys", status_code=302) + + +@router.post("/keys/{key_id}/delete") +async def delete_key( + request: Request, + key_id: int, + db: Session = Depends(get_db) +): + """Delete API key""" + try: + check_admin_auth(request) + except HTTPException as e: + return RedirectResponse(url="/admin/login", status_code=302) + + api_key = db.query(ApiKey).filter(ApiKey.id == key_id).first() + if api_key: + db.delete(api_key) + db.commit() + + return RedirectResponse(url="/admin/keys", status_code=302) diff --git a/uploads/.gitkeep b/uploads/.gitkeep new file mode 100644 index 0000000..e69de29