"""Transcription settings and the test of the configured Whisper instance (docs/transcription.md). The job itself is podcasts/transcribe.py.""" import io import json import re import wave from urllib.parse import urlsplit import requests from django.conf import settings from django.http import JsonResponse from django.views.decorators.csrf import csrf_exempt from django.views.decorators.http import require_http_methods from feeds import logins from . import chapters from .models import TranscriptionSettings DEFAULT_MODEL = 'whisper-1' TEST_TIMEOUT = 60 # a cold CPU instance may load its model first LANGUAGE_RE = re.compile(r'^[a-z]{2,3}(-[A-Za-z]{2,4})?$') def _auth(request): if not request.user.is_authenticated: return JsonResponse({'error': 'authentication required'}, status=401) return None def _body(request): try: body = json.loads(request.body or b'{}') except (ValueError, UnicodeDecodeError): return None return body if isinstance(body, dict) else None def for_user(user): """The user's settings (unsaved defaults when there are none).""" return TranscriptionSettings.objects.filter(user=user).first() or TranscriptionSettings(user=user) def token_of(conf): """The plain API token ('' when there is none or it can't be read).""" return (logins.decrypt(conf.token_encrypted) or '') if conf.token_encrypted else '' def _data(conf): return { 'engine': conf.engine, 'url': conf.url, 'model': conf.model, 'language': conf.language, 'parallel': conf.parallel, 'has_token': bool(conf.token_encrypted), 'builtin_available': bool(settings.WHISPER_URL), } @csrf_exempt @require_http_methods(['GET', 'POST']) def transcription_settings(request): if (err := _auth(request)): return err conf = for_user(request.user) if request.method == 'GET': return JsonResponse(_data(conf)) body = _body(request) if body is None: return JsonResponse({'error': 'invalid JSON'}, status=400) if 'engine' in body: if body['engine'] not in dict(TranscriptionSettings.ENGINES): return JsonResponse({'error': 'engine must be builtin or custom'}, status=400) conf.engine = body['engine'] if 'url' in body: url = body['url'] if not isinstance(url, str): return JsonResponse({'error': 'url must be text'}, status=400) url = url.strip().rstrip('/') if url and (urlsplit(url).scheme not in ('http', 'https') or not urlsplit(url).hostname): return JsonResponse({'error': 'url must start with http:// or https://'}, status=400) if len(url) > 500: return JsonResponse({'error': 'url too long'}, status=400) conf.url = url if 'model' in body: if not isinstance(body['model'], str) or len(body['model'].strip()) > 100: return JsonResponse({'error': 'invalid model'}, status=400) conf.model = body['model'].strip() if 'language' in body: lang = body['language'] if not isinstance(lang, str) or (lang.strip() and not LANGUAGE_RE.match(lang.strip())): return JsonResponse({'error': 'language must be empty or an ISO code'}, status=400) conf.language = lang.strip() if 'parallel' in body: n = body['parallel'] if isinstance(n, bool) or not isinstance(n, int) or not 1 <= n <= 8: return JsonResponse({'error': 'parallel must be 1 to 8'}, status=400) conf.parallel = n if 'token' in body: if not isinstance(body['token'], str): return JsonResponse({'error': 'token must be text'}, status=400) conf.token_encrypted = logins.encrypt(body['token']) if body['token'] else '' if conf.engine == TranscriptionSettings.CUSTOM and not conf.url: return JsonResponse({'error': 'a custom instance needs a url'}, status=400) conf.save() return JsonResponse(_data(conf)) def silent_wav(seconds=1, rate=16000): """`seconds` of 16-bit mono silence as a WAV file, made in memory.""" buf = io.BytesIO() with wave.open(buf, 'wb') as w: w.setnchannels(1) w.setsampwidth(2) w.setframerate(rate) w.writeframes(b'\0\0' * rate * seconds) return buf.getvalue() @csrf_exempt @require_http_methods(['POST']) def transcription_test(request): """One second of silence to the configured instance: {ok, model, error}.""" if (err := _auth(request)): return err conf = for_user(request.user) builtin = conf.engine == TranscriptionSettings.BUILTIN base = settings.WHISPER_URL if builtin else conf.url model = conf.model or DEFAULT_MODEL if not base: error = 'Keine eingebaute Instanz eingerichtet' if builtin else 'Keine Adresse eingetragen' return JsonResponse({'ok': False, 'model': model, 'error': error}) url = base.rstrip('/') + '/audio/transcriptions' headers = {'User-Agent': 'diora transcription test'} if not builtin and conf.token_encrypted: headers['Authorization'] = 'Bearer ' + token_of(conf) files = {'file': ('silence.wav', silent_wav(), 'audio/wav')} data = {'model': model, 'response_format': 'json'} if conf.language: data['language'] = conf.language try: if builtin: # The operator's own instance (DIORA_WHISPER_URL) may sit on a private network. r = requests.post(url, headers=headers, files=files, data=data, timeout=TEST_TIMEOUT) if r.status_code >= 400: raise chapters.ChaptersInvalid(f'HTTP {r.status_code}') else: r = chapters._open(url, headers, method='POST', timeout=TEST_TIMEOUT, files=files, data=data) r.close() except (chapters.ChaptersInvalid, chapters.ChaptersUnavailable) as e: return JsonResponse({'ok': False, 'model': model, 'error': str(e)}) except requests.RequestException as e: return JsonResponse({'ok': False, 'model': model, 'error': e.__class__.__name__}) return JsonResponse({'ok': True, 'model': model, 'error': ''})