TranscriptionSettings (Token Fernet-verschluesselt wie SiteLogin), TranscriptionJob und EpisodeArticle in podcasts; Source.synthetic in feeds, das Refresh, Aufraeumen, OPML-Export, Kommando und Umstellen auf Nachrichten ueberspringt bzw. verweigert. GET/POST /podcasts/transcription/settings/ und POST /podcasts/transcription/test/ (SSRF-Pruefung ueber chapters._open, jetzt auch fuer POST; eingebaute Instanz aus DIORA_WHISPER_URL ausgenommen). Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
152 lines
6 KiB
Python
152 lines
6 KiB
Python
"""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': ''})
|