diora-web/podcasts/transcription.py
mrwnslz ae5341d441 Smarte Transkription C: Transkriptions-Pipeline (podcasts/transcribe.py), Endpunkte /transcribe/, /transcription/pending/, /article/claim/, Transkript-Rückfall auf Whisper, transcripts in /feeds/notifications/
Die Folge wird geladen, zu 16 kHz mono dekodiert und an Pausen in Stücke von etwa acht Minuten geschnitten (FLAC für die eingebaute, Opus für fremde Instanzen), parallel an Whisper geschickt (drei Wiederholungen mit wachsender Pause) und per transcripts.merge geglättet. Ein Daemon-Thread arbeitet die Warteschlange ab, ein Auftrag zugleich; hängengebliebene Aufträge starten nach einem Neustart beim ersten Aufruf neu. Abbrechen löscht die Zeile des Auftrags. Gerät der Reservierung: device im Body oder Header X-Diora-Device (docs/transcription.md).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-09 10:01:35 +01:00

297 lines
12 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.utils import timezone
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 EpisodeArticle, PodcastEpisode, TranscriptionJob, 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': ''})
# ---------------------------------------------------------------------------
# Jobs
# ---------------------------------------------------------------------------
CLAIM_MINUTES = 15
ACTIVE = (TranscriptionJob.QUEUED, TranscriptionJob.DOWNLOADING, TranscriptionJob.TRANSCRIBING)
def job_state(job):
"""The job as the API shows it; state `none` when there is no job."""
if job is None:
return {'state': TranscriptionJob.NONE, 'chunks_done': 0, 'chunks_total': 0, 'error': '',
'want_article': False, 'started_at': None, 'finished_at': None}
return {
'state': job.state, 'chunks_done': job.chunks_done, 'chunks_total': job.chunks_total,
'error': job.error, 'want_article': job.want_article,
'started_at': job.started_at.isoformat() if job.started_at else None,
'finished_at': job.finished_at.isoformat() if job.finished_at else None,
}
def job_of(episode):
return TranscriptionJob.objects.filter(episode=episode).first()
def _too_large(url):
"""Whether the server says the file at `url` is over the size limit (a failed look doesn't count)."""
from . import silence
try:
r = requests.head(url, timeout=5, allow_redirects=True, headers={'User-Agent': 'diora transcription'})
return int(r.headers.get('Content-Length') or 0) > silence.MAX_BYTES
except (requests.RequestException, ValueError):
return False
@csrf_exempt
@require_http_methods(['GET', 'POST', 'DELETE'])
def episode_transcribe(request, pk):
"""POST starts the episode's transcription (or just raises `want_article` on one that exists),
GET tells how far it is, DELETE cancels a queued or running one."""
from . import transcribe, views
from .youtube import is_video
if (err := _auth(request)):
return err
ep = PodcastEpisode.objects.select_related('feed').filter(pk=pk, feed__user=request.user).first()
if ep is None:
return JsonResponse({'error': 'not found'}, status=404)
job = job_of(ep)
if request.method == 'GET':
transcribe.kick() # after a restart the first request brings the work back
return JsonResponse(job_state(job))
if request.method == 'DELETE':
if job is not None and job.state != TranscriptionJob.DONE:
job.delete() # a running worker notices its row is gone and stops
job = None
return JsonResponse(job_state(job))
body = _body(request)
if body is None:
return JsonResponse({'error': 'invalid JSON'}, status=400)
article = body.get('article') is True
if is_video(ep.audio_url):
return JsonResponse({'error': 'youtube'}, status=400)
has_feed = views._has_transcript(ep)
if job is not None and (job.state in ACTIVE or (job.state == TranscriptionJob.DONE and (job.data or has_feed))):
if article and not job.want_article:
job.want_article = True
job.save(update_fields=['want_article'])
return JsonResponse(job_state(job))
if has_feed: # the feed's own transcript is there: only the article is wanted
if job is None:
job = TranscriptionJob(episode=ep)
job.state, job.want_article, job.finished_at = TranscriptionJob.DONE, article or job.want_article, timezone.now()
job.save()
return JsonResponse(job_state(job))
if not ep.audio_url:
return JsonResponse({'error': 'no audio'}, status=400)
if _too_large(ep.audio_url):
return JsonResponse({'error': 'too large'}, status=400)
if job is not None:
job.delete() # a failed or hollow earlier run: the new one starts at the back of the queue
job = TranscriptionJob.objects.create(episode=ep, state=TranscriptionJob.QUEUED, want_article=article)
transcribe.kick()
return JsonResponse(job_state(job))
def _wishes(user):
"""The user's jobs whose transcript is there and whose article is wanted but not written."""
return (TranscriptionJob.objects
.filter(episode__feed__user=user, state=TranscriptionJob.DONE, want_article=True)
.exclude(episode__in=EpisodeArticle.objects.values('episode')))
@csrf_exempt
@require_http_methods(['GET'])
def transcription_pending(request):
"""The open wishes for an article that no device holds right now."""
from . import transcribe
if (err := _auth(request)):
return err
transcribe.kick()
jobs = (_wishes(request.user).exclude(claimed_until__gt=timezone.now())
.select_related('episode__feed').order_by('id'))
return JsonResponse([{
'episode_id': j.episode_id, 'title': j.episode.title, 'podcast': j.episode.feed.title,
'duration_seconds': j.episode.duration_seconds,
} for j in jobs], safe=False)
@csrf_exempt
@require_http_methods(['POST', 'DELETE'])
def article_claim(request, pk):
"""Reserve the wish for an article for the calling device for CLAIM_MINUTES (POST), or give
it up (DELETE). The device names itself with `device` in the JSON body or the
X-Diora-Device header. 409 when another device holds it."""
from datetime import timedelta
if (err := _auth(request)):
return err
body = _body(request)
if body is None:
return JsonResponse({'error': 'invalid JSON'}, status=400)
device = body.get('device') or request.META.get('HTTP_X_DIORA_DEVICE') or ''
if not isinstance(device, str) or not device.strip():
return JsonResponse({'error': 'device missing'}, status=400)
device = device.strip()[:60]
job = _wishes(request.user).filter(episode_id=pk).first()
if job is None:
return JsonResponse({'error': 'no open wish'}, status=404)
now = timezone.now()
if job.claimed_until and job.claimed_until > now and job.claimed_by != device:
return JsonResponse({'error': 'claimed', 'claimed_until': job.claimed_until.isoformat()}, status=409)
if request.method == 'DELETE':
job.claimed_by, job.claimed_until = '', None
else:
job.claimed_by, job.claimed_until = device, now + timedelta(minutes=CLAIM_MINUTES)
job.save(update_fields=['claimed_by', 'claimed_until'])
return JsonResponse({'claimed_by': job.claimed_by,
'claimed_until': job.claimed_until.isoformat() if job.claimed_until else None})