Initial commit: Whisper API with FastAPI, GPU support and Admin Dashboard

This commit is contained in:
Dominic Ballenthin
2026-01-28 23:16:44 +01:00
commit 008ef63bfd
28 changed files with 1871 additions and 0 deletions

27
.env.example Normal file
View File

@@ -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

48
.gitignore vendored Normal file
View File

@@ -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/

335
README.md Normal file
View File

@@ -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

0
data/.gitkeep Normal file
View File

39
docker-compose.yml Normal file
View File

@@ -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

44
docker/Dockerfile Normal file
View File

@@ -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"]

0
models/.gitkeep Normal file
View File

35
requirements.txt Normal file
View File

@@ -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

0
src/__init__.py Normal file
View File

0
src/api/__init__.py Normal file
View File

43
src/api/health.py Normal file
View File

@@ -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)
}
}

160
src/api/transcriptions.py Normal file
View File

@@ -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

51
src/config.py Normal file
View File

@@ -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()

32
src/database/__init__.py Normal file
View File

@@ -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()

32
src/database/db.py Normal file
View File

@@ -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()

30
src/database/models.py Normal file
View File

@@ -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())

49
src/main.py Normal file
View File

@@ -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)

0
src/services/__init__.py Normal file
View File

View File

@@ -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()

View File

@@ -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

View File

@@ -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
)

229
src/templates/base.html Normal file
View File

@@ -0,0 +1,229 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Whisper API Admin{% endblock %}</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background-color: #f5f5f5;
color: #333;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 20px;
margin-bottom: 30px;
border-radius: 10px;
display: flex;
justify-content: space-between;
align-items: center;
}
.header h1 {
font-size: 24px;
}
.nav {
display: flex;
gap: 10px;
}
.nav a {
color: white;
text-decoration: none;
padding: 8px 16px;
border-radius: 5px;
transition: background 0.3s;
}
.nav a:hover, .nav a.active {
background: rgba(255,255,255,0.2);
}
.card {
background: white;
border-radius: 10px;
padding: 20px;
margin-bottom: 20px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.card h2 {
margin-bottom: 20px;
color: #667eea;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.stat-card {
background: white;
padding: 20px;
border-radius: 10px;
text-align: center;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.stat-value {
font-size: 36px;
font-weight: bold;
color: #667eea;
}
.stat-label {
color: #666;
margin-top: 5px;
}
.btn {
background: #667eea;
color: white;
border: none;
padding: 10px 20px;
border-radius: 5px;
cursor: pointer;
text-decoration: none;
display: inline-block;
transition: background 0.3s;
}
.btn:hover {
background: #5568d3;
}
.btn-success {
background: #48bb78;
}
.btn-success:hover {
background: #38a169;
}
.btn-danger {
background: #f56565;
}
.btn-danger:hover {
background: #e53e3e;
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 20px;
}
th, td {
padding: 12px;
text-align: left;
border-bottom: 1px solid #e2e8f0;
}
th {
background: #f7fafc;
font-weight: 600;
}
.badge {
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: bold;
}
.badge-success {
background: #c6f6d5;
color: #22543d;
}
.badge-danger {
background: #fed7d7;
color: #742a2a;
}
.alert {
padding: 15px;
border-radius: 5px;
margin-bottom: 20px;
}
.alert-success {
background: #c6f6d5;
color: #22543d;
border: 1px solid #9ae6b4;
}
.alert-danger {
background: #fed7d7;
color: #742a2a;
border: 1px solid #fc8181;
}
.form-group {
margin-bottom: 15px;
}
.form-group label {
display: block;
margin-bottom: 5px;
font-weight: 600;
}
.form-group input {
width: 100%;
padding: 10px;
border: 1px solid #e2e8f0;
border-radius: 5px;
font-size: 14px;
}
.api-key-display {
background: #f7fafc;
padding: 15px;
border-radius: 5px;
font-family: monospace;
font-size: 14px;
margin: 10px 0;
word-break: break-all;
}
@media (max-width: 768px) {
.stats-grid {
grid-template-columns: 1fr;
}
.header {
flex-direction: column;
text-align: center;
gap: 15px;
}
}
</style>
{% block extra_css %}{% endblock %}
</head>
<body>
{% block content %}{% endblock %}
{% block extra_js %}{% endblock %}
</body>
</html>

View File

@@ -0,0 +1,116 @@
{% extends "base.html" %}
{% block title %}Dashboard - Whisper API Admin{% endblock %}
{% block content %}
<div class="container">
<div class="header">
<h1>🎯 Whisper API Dashboard</h1>
<div class="nav">
<a href="/admin" class="active">Dashboard</a>
<a href="/admin/keys">API Keys</a>
<a href="/admin/logout">Logout</a>
</div>
</div>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-value">{{ stats.total_requests }}</div>
<div class="stat-label">Requests (30 Tage)</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ stats.success_rate }}%</div>
<div class="stat-label">Success Rate</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ stats.avg_processing_time_ms }}ms</div>
<div class="stat-label">Ø Processing Time</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ model }}</div>
<div class="stat-label">Model</div>
</div>
</div>
<div class="card">
<h2>📊 Usage Chart (Last 30 Days)</h2>
<canvas id="usageChart" height="100"></canvas>
</div>
<div class="card">
<h2>📝 Recent Activity</h2>
<table>
<thead>
<tr>
<th>Time</th>
<th>Endpoint</th>
<th>Duration</th>
<th>Status</th>
<th>Processing</th>
</tr>
</thead>
<tbody>
{% for log in stats.recent_logs[:10] %}
<tr>
<td>{{ log.created_at.strftime('%Y-%m-%d %H:%M:%S') }}</td>
<td>{{ log.endpoint }}</td>
<td>
{% if log.duration_seconds %}
{{ "%.1f"|format(log.duration_seconds) }}s
{% else %}
-
{% endif %}
</td>
<td>
<span class="badge badge-{{ 'success' if log.status == 'success' else 'danger' }}">
{{ log.status }}
</span>
</td>
<td>{{ log.processing_time_ms }}ms</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="card">
<h2> System Info</h2>
<p><strong>Model:</strong> {{ model }}</p>
<p><strong>Log Retention:</strong> {{ retention_days }} days</p>
<p><strong>API Version:</strong> v1.0.0</p>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
const ctx = document.getElementById('usageChart').getContext('2d');
const dailyStats = {{ stats.daily_stats | tojson }};
new Chart(ctx, {
type: 'line',
data: {
labels: dailyStats.map(s => s.date),
datasets: [{
label: 'Requests',
data: dailyStats.map(s => s.count),
backgroundColor: 'rgba(102, 126, 234, 0.2)',
borderColor: 'rgba(102, 126, 234, 1)',
borderWidth: 2,
tension: 0.4
}]
},
options: {
responsive: true,
scales: {
y: {
beginAtZero: true,
ticks: {
stepSize: 1
}
}
}
}
});
</script>
{% endblock %}

110
src/templates/keys.html Normal file
View File

@@ -0,0 +1,110 @@
{% extends "base.html" %}
{% block title %}API Keys - Whisper API Admin{% endblock %}
{% block content %}
<div class="container">
<div class="header">
<h1>🔐 API Key Management</h1>
<div class="nav">
<a href="/admin">Dashboard</a>
<a href="/admin/keys" class="active">API Keys</a>
<a href="/admin/logout">Logout</a>
</div>
</div>
{% if message %}
<div class="alert alert-success">
{{ message }}
</div>
{% endif %}
{% if new_key %}
<div class="card" style="background: #c6f6d5; border: 2px solid #48bb78;">
<h2>✨ New API Key Generated</h2>
<p>Copy this key now - it will not be shown again!</p>
<div class="api-key-display">{{ new_key }}</div>
<button onclick="copyToClipboard('{{ new_key }}')" class="btn">Copy to Clipboard</button>
</div>
{% endif %}
<div class="card">
<h2>Create New API Key</h2>
<form method="POST" action="/admin/keys/create">
<div class="form-group">
<label for="description">Description (optional)</label>
<input type="text" id="description" name="description" placeholder="e.g., Clawdbot Integration">
</div>
<button type="submit" class="btn btn-success">Generate New API Key</button>
</form>
</div>
<div class="card">
<h2>Active API Keys</h2>
<table>
<thead>
<tr>
<th>ID</th>
<th>Description</th>
<th>Created</th>
<th>Last Used</th>
<th>Usage Count</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for key in keys %}
<tr>
<td>{{ key.id }}</td>
<td>{{ key.description or '-' }}</td>
<td>{{ key.created_at.strftime('%Y-%m-%d %H:%M') }}</td>
<td>
{% if key.last_used_at %}
{{ key.last_used_at.strftime('%Y-%m-%d %H:%M') }}
{% else %}
Never
{% endif %}
</td>
<td>{{ key.usage_count }}</td>
<td>
<span class="badge badge-{{ 'success' if key.is_active else 'danger' }}">
{{ 'Active' if key.is_active else 'Inactive' }}
</span>
</td>
<td>
<form method="POST" action="/admin/keys/{{ key.id }}/toggle" style="display: inline;">
<button type="submit" class="btn" style="padding: 5px 10px; font-size: 12px;">
{{ 'Deactivate' if key.is_active else 'Activate' }}
</button>
</form>
<form method="POST" action="/admin/keys/{{ key.id }}/delete" style="display: inline; margin-left: 5px;"
onsubmit="return confirm('Are you sure you want to delete this API key?');">
<button type="submit" class="btn btn-danger" style="padding: 5px 10px; font-size: 12px;">
Delete
</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if not keys %}
<p style="text-align: center; color: #666; margin-top: 20px;">
No API keys found. Create one above.
</p>
{% endif %}
</div>
</div>
<script>
function copyToClipboard(text) {
navigator.clipboard.writeText(text).then(function() {
alert('API Key copied to clipboard!');
}, function(err) {
console.error('Could not copy text: ', err);
});
}
</script>
{% endblock %}

39
src/templates/login.html Normal file
View File

@@ -0,0 +1,39 @@
{% extends "base.html" %}
{% block title %}Login - Whisper API Admin{% endblock %}
{% block content %}
<div class="container">
<div style="max-width: 400px; margin: 100px auto;">
<div class="card">
<h2 style="text-align: center; margin-bottom: 30px;">🔑 Admin Login</h2>
{% if error %}
<div class="alert alert-danger">
{{ error }}
</div>
{% endif %}
<form method="POST" action="/admin/login">
<div class="form-group">
<label for="username">Benutzername</label>
<input type="text" id="username" name="username" required autofocus>
</div>
<div class="form-group">
<label for="password">Passwort</label>
<input type="password" id="password" name="password" required>
</div>
<button type="submit" class="btn" style="width: 100%; margin-top: 10px;">
Anmelden
</button>
</form>
<p style="text-align: center; margin-top: 20px; color: #666; font-size: 12px;">
Default: admin / -whisper12510-
</p>
</div>
</div>
</div>
{% endblock %}

0
src/web/__init__.py Normal file
View File

187
src/web/routes.py Normal file
View File

@@ -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)

0
uploads/.gitkeep Normal file
View File