Smarte Transkription B: Datenmodell, Einstellungen und Test-Endpunkt

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>
This commit is contained in:
mrwnslz 2026-11-09 09:27:01 +01:00
parent f242605a97
commit 69442f4a4d
14 changed files with 530 additions and 15 deletions

View file

@ -84,6 +84,9 @@ DATABASES = {
} }
} }
# The built-in Whisper instance (OpenAI-compatible, base such as http://whisper:8000/v1);
# empty = none (docs/transcription.md).
WHISPER_URL = os.environ.get('DIORA_WHISPER_URL', '').strip().rstrip('/')
PODCAST_MAX_EPISODES_PER_FEED = int(os.environ.get('PODCAST_MAX_EPISODES_PER_FEED', '200')) PODCAST_MAX_EPISODES_PER_FEED = int(os.environ.get('PODCAST_MAX_EPISODES_PER_FEED', '200'))
AUTH_PASSWORD_VALIDATORS = [ AUTH_PASSWORD_VALIDATORS = [

View file

@ -57,6 +57,9 @@ code (`de`). `parallel` is 1 to 8, default 3. `builtin_available` is whether `DI
is set. `url` is the instance's base, e.g. `https://whisper.example.org/v1`; the server appends is set. `url` is the instance's base, e.g. `https://whisper.example.org/v1`; the server appends
`/audio/transcriptions`. `/audio/transcriptions`.
An empty `model` is sent as `whisper-1`. The built-in URL is read into `settings.WHISPER_URL` from
`DIORA_WHISPER_URL`. A custom engine needs a `url`; the token is only sent to custom instances.
`POST /podcasts/transcription/test/` sends one second of silence to the configured instance and `POST /podcasts/transcription/test/` sends one second of silence to the configured instance and
returns `{ok, model, error}`. A custom URL goes through the same SSRF check as chapters returns `{ok, model, error}`. A custom URL goes through the same SSRF check as chapters
(`podcasts/chapters.py` `_open`): no private, loopback or link-local addresses. Only the built-in (`podcasts/chapters.py` `_open`): no private, loopback or link-local addresses. Only the built-in
@ -91,7 +94,7 @@ address removes the article and its item. `GET /feeds/items/<id>/` and `_item_da
Synthetic sources are skipped by the feed refresh (`refresh_newsfeeds`, `refresh_now`) and by the Synthetic sources are skipped by the feed refresh (`refresh_newsfeeds`, `refresh_now`) and by the
pruning of old blog items (`feeds/refresh.py`). They can be hidden or have their bell turned off pruning of old blog items (`feeds/refresh.py`). They can be hidden or have their bell turned off
like any blog; they can't be refreshed, renamed to another URL or turned into news. like any blog; they can't be refreshed, renamed to another URL or turned into news. `refresh.refresh()` itself drops them, the OPML export leaves them out, and `_source_data` carries `synthetic`.
**Open wishes.** `GET /podcasts/transcription/pending/` returns **Open wishes.** `GET /podcasts/transcription/pending/` returns
`[{episode_id, title, podcast, duration_seconds}]`: the user's episodes whose transcript is done, `[{episode_id, title, podcast, duration_seconds}]`: the user's episodes whose transcript is done,

View file

@ -13,7 +13,7 @@ class Command(BaseCommand):
parser.add_argument('--source-id', type=int, help='Refresh only this source (even when backing off)') parser.add_argument('--source-id', type=int, help='Refresh only this source (even when backing off)')
def handle(self, *args, **options): def handle(self, *args, **options):
qs = Source.objects.select_related('user').order_by('last_refreshed_at') qs = Source.objects.filter(synthetic=False).select_related('user').order_by('last_refreshed_at')
if options['source_id']: if options['source_id']:
qs = qs.filter(pk=options['source_id']) qs = qs.filter(pk=options['source_id'])
else: else:

View file

@ -0,0 +1,18 @@
# Generated by Django 6.1 on 2026-11-09 08:20
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('feeds', '0006_folder_order'),
]
operations = [
migrations.AddField(
model_name='source',
name='synthetic',
field=models.BooleanField(default=False),
),
]

View file

@ -35,6 +35,9 @@ class Source(models.Model):
fav = models.BooleanField(default=False) fav = models.BooleanField(default=False)
hidden = models.BooleanField(default=False) hidden = models.BooleanField(default=False)
notify = models.BooleanField(default=False) notify = models.BooleanField(default=False)
# Filled by diora itself (the blog "Podcast-Artikel"), not by a feed: never
# fetched, pruned, given another URL or turned into news.
synthetic = models.BooleanField(default=False)
# Refresh bookkeeping, as for podcast feeds (see podcasts.views._fetch_feed). # Refresh bookkeeping, as for podcast feeds (see podcasts.views._fetch_feed).
last_refreshed_at = models.DateTimeField(null=True, blank=True) last_refreshed_at = models.DateTimeField(null=True, blank=True)

View file

@ -216,7 +216,7 @@ def expire(user, now=None):
Item.objects.filter(user=user, gone=True, published__lt=now - timedelta(days=GONE_KEEP_DAYS)).delete() Item.objects.filter(user=user, gone=True, published__lt=now - timedelta(days=GONE_KEEP_DAYS)).delete()
Story.objects.filter(user=user).exclude(items__gone=False).delete() Story.objects.filter(user=user).exclude(items__gone=False).delete()
# Blogs: the oldest read articles beyond BLOG_KEEP per blog. # Blogs: the oldest read articles beyond BLOG_KEEP per blog.
for source in Source.objects.filter(user=user, kind=Source.BLOG): for source in Source.objects.filter(user=user, kind=Source.BLOG, synthetic=False):
ids = list(source.items.order_by('-published').values_list('id', flat=True)[BLOG_KEEP:]) ids = list(source.items.order_by('-published').values_list('id', flat=True)[BLOG_KEEP:])
if ids: if ids:
Item.objects.filter(id__in=ids, starred=False).exclude(read_at=None).delete() Item.objects.filter(id__in=ids, starred=False).exclude(read_at=None).delete()
@ -225,7 +225,7 @@ def expire(user, now=None):
def refresh(sources, deadline=None): def refresh(sources, deadline=None):
"""Refresh `sources` (downloads in parallel, writes one by one). """Refresh `sources` (downloads in parallel, writes one by one).
Returns (refreshed, failed, new items).""" Returns (refreshed, failed, new items)."""
sources = list(sources) sources = [s for s in sources if not s.synthetic] # synthetic sources have no feed to fetch
now = timezone.now() now = timezone.now()
keep = {} keep = {}
refreshed = failed = 0 refreshed = failed = 0

View file

@ -539,3 +539,56 @@ class NewsTextApiTest(TestCase):
self.assertEqual(got['related'], []) self.assertEqual(got['related'], [])
single = self.client.get(f'/feeds/stories/{self.items[2].story_id}/overview/').json() single = self.client.get(f'/feeds/stories/{self.items[2].story_id}/overview/').json()
self.assertNotIn('summary', single) self.assertNotIn('summary', single)
class SyntheticSourceTest(TestCase):
"""A source diora fills itself ("Podcast-Artikel") is never fetched or pruned."""
def setUp(self):
self.u = User.objects.create_user('tester', password='pw12345678')
self.client.force_login(self.u)
self.synth = Source.objects.create(user=self.u, url='diora://podcast-artikel', title='Podcast-Artikel',
kind='blog', fav=True, synthetic=True)
self.real = Source.objects.create(user=self.u, url='https://real.example/feed', title='Real', kind='blog')
def post(self, url, data):
return self.client.post(url, json.dumps(data), content_type='application/json')
def test_refresh_skips_it(self):
with mock.patch('feeds.refresh._fetch_feed', return_value=fetched(rss([]), 'https://real.example/feed')) as get:
refresh.refresh([self.synth, self.real])
self.assertEqual([c.args[0] for c in get.call_args_list], ['https://real.example/feed'])
def test_refresh_now_and_command_skip_it(self):
with mock.patch('feeds.refresh._fetch_feed', return_value=fetched(rss([]), 'https://real.example/feed')) as get:
r = self.post('/feeds/refresh/', {})
from django.core.management import call_command
call_command('refresh_newsfeeds', stdout=mock.MagicMock())
self.assertEqual(r.status_code, 200)
self.assertTrue(get.call_args_list)
self.assertTrue(all(c.args[0] == 'https://real.example/feed' for c in get.call_args_list))
call_command('refresh_newsfeeds', '--source-id', str(self.synth.id), stdout=mock.MagicMock())
def test_old_items_are_not_pruned(self):
old = timezone.now() - timedelta(days=400)
for source in (self.synth, self.real):
for n in range(refresh.BLOG_KEEP + 5):
Item.objects.create(source=source, user=self.u, kind='blog', guid_hash=f'{source.pk}-{n}',
title='x', published=old - timedelta(minutes=n), read_at=old)
refresh.expire(self.u)
self.assertEqual(self.synth.items.count(), refresh.BLOG_KEEP + 5)
self.assertEqual(self.real.items.count(), refresh.BLOG_KEEP)
def test_cannot_become_news_but_can_be_hidden(self):
r = self.post(f'/feeds/sources/{self.synth.id}/', {'kind': 'news'})
self.assertEqual(r.status_code, 400)
r = self.post(f'/feeds/sources/{self.synth.id}/', {'hidden': True, 'notify': True})
self.assertEqual(r.status_code, 200)
self.synth.refresh_from_db()
self.assertEqual((self.synth.kind, self.synth.hidden, self.synth.notify), ('blog', True, True))
self.assertTrue(r.json()['source']['synthetic'])
def test_not_in_opml(self):
body = self.client.get('/feeds/export.opml').content.decode()
self.assertIn('real.example', body)
self.assertNotIn('diora://', body)

View file

@ -93,7 +93,7 @@ def _source_data(s, unread=0, new=0, interval=None):
'id': s.id, 'url': s.url, 'title': s.title, 'site_url': s.site_url, 'icon_url': s.icon_url, 'id': s.id, 'url': s.url, 'title': s.title, 'site_url': s.site_url, 'icon_url': s.icon_url,
'kind': s.kind, 'category': s.category, 'folders': s.folders, 'fulltext': s.fulltext, 'kind': s.kind, 'category': s.category, 'folders': s.folders, 'fulltext': s.fulltext,
'unread': unread, 'new': new, 'unread': unread, 'new': new,
'fav': s.fav, 'hidden': s.hidden, 'notify': s.notify, 'fav': s.fav, 'hidden': s.hidden, 'notify': s.notify, 'synthetic': s.synthetic,
# Days between posts (blogs, for "Seltene zuerst"); a blog with one post counts as rare. # Days between posts (blogs, for "Seltene zuerst"); a blog with one post counts as rare.
'interval_days': interval, 'interval_days': interval,
'last_refreshed_at': _iso(s.last_refreshed_at), 'last_error': s.last_error, 'error_count': s.error_count, 'last_refreshed_at': _iso(s.last_refreshed_at), 'last_error': s.last_error, 'error_count': s.error_count,
@ -356,7 +356,7 @@ def add_sources(request):
defaults=_place(request.user, kind, x.get('category'), x.get('folders'), defaults=_place(request.user, kind, x.get('category'), x.get('folders'),
title=(x.get('title') or url)[:300])) title=(x.get('title') or url)[:300]))
added += created added += created
sources = Source.objects.filter(user=request.user, last_refreshed_at=None) sources = Source.objects.filter(user=request.user, last_refreshed_at=None, synthetic=False)
refresh.refresh(sources, deadline=time.monotonic() + REFRESH_WAIT) refresh.refresh(sources, deadline=time.monotonic() + REFRESH_WAIT)
return JsonResponse({'added': added}) return JsonResponse({'added': added})
@ -386,6 +386,8 @@ def edit_source(request, pk):
if body.get('fulltext') in dict(Source.FULLTEXTS): if body.get('fulltext') in dict(Source.FULLTEXTS):
source.fulltext = body['fulltext'] source.fulltext = body['fulltext']
new_kind = _kind(body.get('kind')) new_kind = _kind(body.get('kind'))
if source.synthetic and new_kind == Source.NEWS:
return JsonResponse({'error': 'Diese Quelle bleibt ein Blog'}, status=400)
if new_kind and new_kind != source.kind: if new_kind and new_kind != source.kind:
# A section becomes a folder and back. # A section becomes a folder and back.
if new_kind == Source.BLOG and source.category and not source.folders: if new_kind == Source.BLOG and source.category and not source.folders:
@ -416,7 +418,7 @@ def export_opml(request):
if (err := _auth(request)): if (err := _auth(request)):
return err return err
groups = {} groups = {}
for s in Source.objects.filter(user=request.user).order_by('kind', 'category', 'title'): for s in Source.objects.filter(user=request.user, synthetic=False).order_by('kind', 'category', 'title'):
kind = 'Nachrichten' if s.kind == Source.NEWS else 'Blogs' kind = 'Nachrichten' if s.kind == Source.NEWS else 'Blogs'
place = s.category if s.kind == Source.NEWS else ((s.folders or [''])[0]) place = s.category if s.kind == Source.NEWS else ((s.folders or [''])[0])
label = kind + (f' – {place}' if place else '') label = kind + (f' – {place}' if place else '')
@ -440,7 +442,7 @@ def export_opml(request):
def refresh_now(request): def refresh_now(request):
if (err := _auth(request)): if (err := _auth(request)):
return err return err
refreshed, failed, new = refresh.refresh(Source.objects.filter(user=request.user), refreshed, failed, new = refresh.refresh(Source.objects.filter(user=request.user, synthetic=False),
deadline=time.monotonic() + REFRESH_WAIT) deadline=time.monotonic() + REFRESH_WAIT)
return JsonResponse({'refreshed': refreshed, 'failed': failed, 'new': new}) return JsonResponse({'refreshed': refreshed, 'failed': failed, 'new': new})
@ -770,6 +772,8 @@ def save_login(request):
password = body.get('password') or '' password = body.get('password') or ''
if source is None or not username or not password: if source is None or not username or not password:
return JsonResponse({'error': 'Benutzername und Passwort nötig'}, status=400) return JsonResponse({'error': 'Benutzername und Passwort nötig'}, status=400)
if source.synthetic:
return JsonResponse({'error': 'Diese Quelle hat keine Adresse'}, status=400)
try: try:
adapter = logins.check_and_store(request.user, source.url, username, password) adapter = logins.check_and_store(request.user, source.url, username, password)
except logins.LoginFailed as e: except logins.LoginFailed as e:

View file

@ -128,25 +128,28 @@ FetchUnavailable = ChaptersUnavailable
FetchInvalid = ChaptersInvalid FetchInvalid = ChaptersInvalid
def _open(url, headers): def _open(url, headers, method='GET', timeout=CHAPTERS_TIMEOUT, **kwargs):
"""GET `url` with every redirect hop checked; the streamed response of """`method` (GET or POST) `url` with every redirect hop checked; the streamed response of
the final hop, which the caller has to close. the final hop, which the caller has to close. `kwargs` (data, files) go to
requests; a POST only follows 307/308, which keep the method and body.
Raises ChaptersInvalid for permanent problems and ChaptersUnavailable for Raises ChaptersInvalid for permanent problems and ChaptersUnavailable for
ones that may go away (network trouble, server errors). ones that may go away (network trouble, server errors).
""" """
redirects = (301, 302, 303, 307, 308) if method == 'GET' else (307, 308)
for _ in range(CHAPTERS_MAX_REDIRECTS + 1): for _ in range(CHAPTERS_MAX_REDIRECTS + 1):
try: try:
assert_safe_url(url) assert_safe_url(url)
except WebDAVInputError as e: except WebDAVInputError as e:
raise ChaptersInvalid(str(e)) raise ChaptersInvalid(str(e))
try: try:
r = requests.get(url, timeout=CHAPTERS_TIMEOUT, allow_redirects=False, stream=True, headers=headers) send = requests.get if method == 'GET' else requests.post
r = send(url, timeout=timeout, allow_redirects=False, stream=True, headers=headers, **kwargs)
except requests.RequestException as e: except requests.RequestException as e:
raise ChaptersUnavailable(e.__class__.__name__) raise ChaptersUnavailable(e.__class__.__name__)
try: try:
_assert_peer_is_safe(r) _assert_peer_is_safe(r)
if r.status_code in (301, 302, 303, 307, 308): if r.status_code in redirects:
location = r.headers.get('Location') location = r.headers.get('Location')
if not location: if not location:
raise ChaptersInvalid('redirect without a location') raise ChaptersInvalid('redirect without a location')
@ -155,7 +158,7 @@ def _open(url, headers):
continue continue
if r.status_code >= 500 or r.status_code == 429: if r.status_code >= 500 or r.status_code == 429:
raise ChaptersUnavailable(f'HTTP {r.status_code}') raise ChaptersUnavailable(f'HTTP {r.status_code}')
if r.status_code >= 400: if r.status_code >= 400 or r.status_code in (301, 302, 303):
raise ChaptersInvalid(f'HTTP {r.status_code}') raise ChaptersInvalid(f'HTTP {r.status_code}')
except WebDAVInputError as e: except WebDAVInputError as e:
r.close() r.close()

View file

@ -0,0 +1,59 @@
# Generated by Django 6.1 on 2026-11-09 08:20
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('feeds', '0007_transcription'),
('podcasts', '0026_podcastepisode_sponsor'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='EpisodeArticle',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('model', models.CharField(blank=True, max_length=100)),
('kind', models.CharField(blank=True, max_length=20)),
('created_at', models.DateTimeField(auto_now_add=True)),
('episode', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='article', to='podcasts.podcastepisode')),
('item', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='episode_article', to='feeds.item')),
],
),
migrations.CreateModel(
name='TranscriptionJob',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('state', models.CharField(choices=[('none', 'none'), ('queued', 'queued'), ('downloading', 'downloading'), ('transcribing', 'transcribing'), ('done', 'done'), ('error', 'error')], default='none', max_length=15)),
('chunks_done', models.PositiveIntegerField(default=0)),
('chunks_total', models.PositiveIntegerField(default=0)),
('error', models.CharField(blank=True, max_length=500)),
('want_article', models.BooleanField(default=False)),
('claimed_by', models.CharField(blank=True, max_length=60)),
('claimed_until', models.DateTimeField(blank=True, null=True)),
('data', models.JSONField(blank=True, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('started_at', models.DateTimeField(blank=True, null=True)),
('finished_at', models.DateTimeField(blank=True, null=True)),
('episode', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='transcription_job', to='podcasts.podcastepisode')),
],
),
migrations.CreateModel(
name='TranscriptionSettings',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('engine', models.CharField(choices=[('builtin', 'Eingebaut'), ('custom', 'Eigene Instanz')], default='builtin', max_length=10)),
('url', models.CharField(blank=True, max_length=500)),
('token_encrypted', models.TextField(blank=True)),
('model', models.CharField(blank=True, max_length=100)),
('language', models.CharField(blank=True, max_length=10)),
('parallel', models.PositiveSmallIntegerField(default=3)),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='transcription_settings', to=settings.AUTH_USER_MODEL)),
],
),
]

View file

@ -217,6 +217,68 @@ class EpisodeTranscript(models.Model):
return f'Transcript of {self.episode}' return f'Transcript of {self.episode}'
class TranscriptionSettings(models.Model):
"""Where a user's episodes are transcribed (docs/transcription.md): the
built-in Whisper instance (DIORA_WHISPER_URL) or their own."""
BUILTIN = 'builtin'
CUSTOM = 'custom'
ENGINES = [(BUILTIN, 'Eingebaut'), (CUSTOM, 'Eigene Instanz')]
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='transcription_settings')
engine = models.CharField(max_length=10, choices=ENGINES, default=BUILTIN)
# The instance's base, e.g. https://whisper.example.org/v1 (custom only).
url = models.CharField(max_length=500, blank=True)
# The API token, Fernet-encrypted like feeds.SiteLogin.secret; never handed out.
token_encrypted = models.TextField(blank=True)
model = models.CharField(max_length=100, blank=True)
# '' = automatic, else an ISO code ("de").
language = models.CharField(max_length=10, blank=True)
parallel = models.PositiveSmallIntegerField(default=3) # 1 to 8 pieces at once
def __str__(self):
return f'Transcription settings of {self.user}'
class TranscriptionJob(models.Model):
"""One episode's transcription run, its Whisper result and the wish for an
article. Kept apart from EpisodeTranscript, which caches the feed's
transcripts and is rewritten when the feed changes them."""
NONE, QUEUED, DOWNLOADING, TRANSCRIBING, DONE, ERROR = (
'none', 'queued', 'downloading', 'transcribing', 'done', 'error')
STATES = [(s, s) for s in (NONE, QUEUED, DOWNLOADING, TRANSCRIBING, DONE, ERROR)]
episode = models.OneToOneField(PodcastEpisode, on_delete=models.CASCADE, related_name='transcription_job')
state = models.CharField(max_length=15, choices=STATES, default=NONE)
chunks_done = models.PositiveIntegerField(default=0)
chunks_total = models.PositiveIntegerField(default=0)
error = models.CharField(max_length=500, blank=True)
want_article = models.BooleanField(default=False)
# The device writing the article (a short name) and until when it holds the wish.
claimed_by = models.CharField(max_length=60, blank=True)
claimed_until = models.DateTimeField(null=True, blank=True)
# {"segments": [{start, end, speaker, text}], "source": {type: "whisper", model, language}}
data = models.JSONField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
started_at = models.DateTimeField(null=True, blank=True)
finished_at = models.DateTimeField(null=True, blank=True)
def __str__(self):
return f'Transcription of {self.episode} ({self.state})'
class EpisodeArticle(models.Model):
"""The article written from an episode's transcript; its text is the
feeds.Item (in the blog "Podcast-Artikel")."""
episode = models.OneToOneField(PodcastEpisode, on_delete=models.CASCADE, related_name='article')
item = models.OneToOneField('feeds.Item', on_delete=models.CASCADE, related_name='episode_article')
model = models.CharField(max_length=100, blank=True)
kind = models.CharField(max_length=20, blank=True) # Interview, Gespräch, Monolog, Diskussion
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return f'Article on {self.episode}'
# An episode counts as played this close to its end (AntennaPod's default), # An episode counts as played this close to its end (AntennaPod's default),
# the same rule as the apps' "als gehört zählen". # the same rule as the apps' "als gehört zählen".
PLAYED_BEFORE_END_SECONDS = 30 PLAYED_BEFORE_END_SECONDS = 30

View file

@ -0,0 +1,153 @@
import io
import json
import wave
from unittest.mock import MagicMock, patch
from django.contrib.auth.models import User
from django.test import TestCase, override_settings
from feeds import logins
from . import chapters, transcription
from .models import TranscriptionSettings
SETTINGS = '/podcasts/transcription/settings/'
TEST = '/podcasts/transcription/test/'
def fake_response(status=200):
r = MagicMock()
r.status_code = status
r.headers = {}
r.raw = None # nothing to introspect for the peer check
return r
class SettingsTests(TestCase):
def setUp(self):
self.user = User.objects.create_user(username='alice', password='pw12345678')
self.client.force_login(self.user)
def post(self, data, url=SETTINGS):
return self.client.post(url, json.dumps(data), content_type='application/json')
def test_requires_login(self):
self.client.logout()
self.assertEqual(self.client.get(SETTINGS).status_code, 401)
self.assertEqual(self.post({}, TEST).status_code, 401)
def test_defaults(self):
self.assertEqual(self.client.get(SETTINGS).json(), {
'engine': 'builtin', 'url': '', 'model': '', 'language': '', 'parallel': 3,
'has_token': False, 'builtin_available': False})
self.assertFalse(TranscriptionSettings.objects.exists())
@override_settings(WHISPER_URL='http://whisper:8000/v1')
def test_builtin_available_follows_the_environment_setting(self):
self.assertTrue(self.client.get(SETTINGS).json()['builtin_available'])
def test_roundtrip(self):
r = self.post({'engine': 'custom', 'url': 'https://whisper.example.org/v1/', 'model': 'large-v3',
'language': 'de', 'parallel': 5, 'token': 'sk-secret'})
self.assertEqual(r.status_code, 200)
want = {'engine': 'custom', 'url': 'https://whisper.example.org/v1', 'model': 'large-v3',
'language': 'de', 'parallel': 5, 'has_token': True, 'builtin_available': False}
self.assertEqual(r.json(), want)
self.assertEqual(self.client.get(SETTINGS).json(), want)
def test_token_is_write_only_and_stored_encrypted(self):
self.post({'engine': 'custom', 'url': 'https://w.example.org/v1', 'token': 'sk-secret'})
conf = TranscriptionSettings.objects.get(user=self.user)
self.assertTrue(conf.token_encrypted)
self.assertNotIn('sk-secret', conf.token_encrypted)
self.assertEqual(transcription.token_of(conf), 'sk-secret')
for body in (self.client.get(SETTINGS).content, self.post({'model': 'm'}).content):
self.assertNotIn(b'sk-secret', body)
self.assertNotIn(conf.token_encrypted.encode(), body)
# Not sending a token keeps it; an empty one removes it.
conf.refresh_from_db()
self.assertTrue(conf.token_encrypted)
self.assertFalse(self.post({'token': ''}).json()['has_token'])
conf.refresh_from_db()
self.assertEqual(conf.token_encrypted, '')
def test_validation(self):
bad = [
{'engine': 'cloud'}, {'parallel': 0}, {'parallel': 9}, {'parallel': '3'}, {'parallel': True},
{'url': 'ftp://x.org/v1'}, {'url': 'javascript:alert(1)'}, {'url': 'whisper.local/v1'},
{'language': 'Deutsch'}, {'model': 5}, {'token': 5},
{'engine': 'custom'}, # a custom instance needs a url
]
for body in bad:
self.assertEqual(self.post(body).status_code, 400, body)
self.assertEqual(self.client.post(SETTINGS, 'nope', content_type='application/json').status_code, 400)
self.assertFalse(TranscriptionSettings.objects.exists())
for body in ({'parallel': 1}, {'parallel': 8}, {'language': ''}, {'language': 'pt-BR'}):
self.assertEqual(self.post(body).status_code, 200, body)
def test_settings_are_per_user(self):
self.post({'parallel': 7})
other = User.objects.create_user(username='bob', password='pw12345678')
self.client.force_login(other)
self.assertEqual(self.client.get(SETTINGS).json()['parallel'], 3)
class TestEndpointTests(TestCase):
def setUp(self):
self.user = User.objects.create_user(username='alice', password='pw12345678')
self.client.force_login(self.user)
def configure(self, **kw):
TranscriptionSettings.objects.update_or_create(user=self.user, defaults=kw)
def test_custom_sends_a_second_of_silence(self):
self.configure(engine='custom', url='https://whisper.example.org/v1', model='large-v3', language='de',
token_encrypted=logins.encrypt('sk-secret'))
calls = []
def post(url, **kw):
calls.append((url, kw))
return fake_response()
with patch('podcasts.chapters.assert_safe_url'), patch('podcasts.chapters.requests.post', post):
r = self.client.post(TEST)
self.assertEqual(r.json(), {'ok': True, 'model': 'large-v3', 'error': ''})
url, kw = calls[0]
self.assertEqual(url, 'https://whisper.example.org/v1/audio/transcriptions')
self.assertEqual(kw['headers']['Authorization'], 'Bearer sk-secret')
self.assertEqual(kw['data']['model'], 'large-v3')
self.assertEqual(kw['data']['language'], 'de')
name, wav, mime = kw['files']['file']
self.assertEqual(mime, 'audio/wav')
with wave.open(io.BytesIO(wav)) as w:
self.assertEqual((w.getframerate(), w.getnchannels(), w.getnframes()), (16000, 1, 16000))
def test_custom_private_address_is_refused(self):
self.configure(engine='custom', url='http://127.0.0.1:9000/v1')
with patch('podcasts.chapters.requests.post') as post:
r = self.client.post(TEST)
self.assertFalse(r.json()['ok'])
self.assertTrue(r.json()['error'])
post.assert_not_called()
def test_custom_http_error_is_reported(self):
self.configure(engine='custom', url='https://whisper.example.org/v1')
with patch('podcasts.chapters.assert_safe_url'), \
patch('podcasts.chapters.requests.post', return_value=fake_response(401)):
r = self.client.post(TEST)
self.assertEqual((r.json()['ok'], r.json()['error']), (False, 'HTTP 401'))
@override_settings(WHISPER_URL='http://whisper:8000/v1')
def test_builtin_is_exempt_from_the_address_check(self):
self.configure(engine='builtin')
with patch('podcasts.transcription.requests.post', return_value=fake_response()) as post, \
patch('podcasts.chapters.assert_safe_url', side_effect=chapters.WebDAVInputError('private')):
r = self.client.post(TEST)
self.assertTrue(r.json()['ok'])
self.assertEqual(post.call_args.args[0], 'http://whisper:8000/v1/audio/transcriptions')
self.assertNotIn('Authorization', post.call_args.kwargs['headers'])
def test_builtin_without_instance(self):
r = self.client.post(TEST)
self.assertFalse(r.json()['ok'])
self.assertTrue(r.json()['error'])

152
podcasts/transcription.py Normal file
View file

@ -0,0 +1,152 @@
"""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': ''})

View file

@ -1,5 +1,5 @@
from django.urls import path from django.urls import path
from . import audiobooks, discover, folders, radio, views from . import audiobooks, discover, folders, radio, transcription, views
urlpatterns = [ urlpatterns = [
path('search/', views.podcast_search, name='podcast_search'), path('search/', views.podcast_search, name='podcast_search'),
@ -31,6 +31,8 @@ urlpatterns = [
path('episodes/<int:pk>/silences/', views.episode_silences, name='podcast_episode_silences'), path('episodes/<int:pk>/silences/', views.episode_silences, name='podcast_episode_silences'),
path('episodes/<int:pk>/sponsor/', views.episode_sponsor, name='podcast_episode_sponsor'), path('episodes/<int:pk>/sponsor/', views.episode_sponsor, name='podcast_episode_sponsor'),
path('episodes/<int:pk>/favorite/', views.set_favorite, name='podcast_set_favorite'), path('episodes/<int:pk>/favorite/', views.set_favorite, name='podcast_set_favorite'),
path('transcription/settings/', transcription.transcription_settings, name='podcast_transcription_settings'),
path('transcription/test/', transcription.transcription_test, name='podcast_transcription_test'),
path('stats/', views.podcast_stats, name='podcast_stats'), path('stats/', views.podcast_stats, name='podcast_stats'),
path('queue/', views.queue_get, name='podcast_queue_get'), path('queue/', views.queue_get, name='podcast_queue_get'),
path('queue/add/', views.queue_add, name='podcast_queue_add'), path('queue/add/', views.queue_add, name='podcast_queue_add'),