From 69442f4a4d6e803436742016b340d0f0b9a0145f Mon Sep 17 00:00:00 2001 From: mrwnslz Date: Mon, 9 Nov 2026 09:27:01 +0100 Subject: [PATCH] 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 --- diora/settings.py | 3 + docs/transcription.md | 5 +- .../management/commands/refresh_newsfeeds.py | 2 +- feeds/migrations/0007_transcription.py | 18 +++ feeds/models.py | 3 + feeds/refresh.py | 4 +- feeds/tests.py | 53 ++++++ feeds/views.py | 12 +- podcasts/chapters.py | 15 +- podcasts/migrations/0027_transcription.py | 59 +++++++ podcasts/models.py | 62 +++++++ podcasts/test_transcription.py | 153 ++++++++++++++++++ podcasts/transcription.py | 152 +++++++++++++++++ podcasts/urls.py | 4 +- 14 files changed, 530 insertions(+), 15 deletions(-) create mode 100644 feeds/migrations/0007_transcription.py create mode 100644 podcasts/migrations/0027_transcription.py create mode 100644 podcasts/test_transcription.py create mode 100644 podcasts/transcription.py diff --git a/diora/settings.py b/diora/settings.py index a171dd1..aa569d4 100644 --- a/diora/settings.py +++ b/diora/settings.py @@ -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')) AUTH_PASSWORD_VALIDATORS = [ diff --git a/docs/transcription.md b/docs/transcription.md index af270df..4ac2821 100644 --- a/docs/transcription.md +++ b/docs/transcription.md @@ -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 `/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 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 @@ -91,7 +94,7 @@ address removes the article and its item. `GET /feeds/items//` and `_item_da 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 -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 `[{episode_id, title, podcast, duration_seconds}]`: the user's episodes whose transcript is done, diff --git a/feeds/management/commands/refresh_newsfeeds.py b/feeds/management/commands/refresh_newsfeeds.py index 7705e0a..c1c74ce 100644 --- a/feeds/management/commands/refresh_newsfeeds.py +++ b/feeds/management/commands/refresh_newsfeeds.py @@ -13,7 +13,7 @@ class Command(BaseCommand): parser.add_argument('--source-id', type=int, help='Refresh only this source (even when backing off)') 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']: qs = qs.filter(pk=options['source_id']) else: diff --git a/feeds/migrations/0007_transcription.py b/feeds/migrations/0007_transcription.py new file mode 100644 index 0000000..5b185b7 --- /dev/null +++ b/feeds/migrations/0007_transcription.py @@ -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), + ), + ] diff --git a/feeds/models.py b/feeds/models.py index 309e790..dcfe30d 100644 --- a/feeds/models.py +++ b/feeds/models.py @@ -35,6 +35,9 @@ class Source(models.Model): fav = models.BooleanField(default=False) hidden = 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). last_refreshed_at = models.DateTimeField(null=True, blank=True) diff --git a/feeds/refresh.py b/feeds/refresh.py index ca9dc16..d1be736 100644 --- a/feeds/refresh.py +++ b/feeds/refresh.py @@ -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() Story.objects.filter(user=user).exclude(items__gone=False).delete() # 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:]) if ids: 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): """Refresh `sources` (downloads in parallel, writes one by one). 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() keep = {} refreshed = failed = 0 diff --git a/feeds/tests.py b/feeds/tests.py index 9be6e51..c8ff5dd 100644 --- a/feeds/tests.py +++ b/feeds/tests.py @@ -539,3 +539,56 @@ class NewsTextApiTest(TestCase): self.assertEqual(got['related'], []) single = self.client.get(f'/feeds/stories/{self.items[2].story_id}/overview/').json() 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) diff --git a/feeds/views.py b/feeds/views.py index ead5a4f..b51e476 100644 --- a/feeds/views.py +++ b/feeds/views.py @@ -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, 'kind': s.kind, 'category': s.category, 'folders': s.folders, 'fulltext': s.fulltext, '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. 'interval_days': interval, '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'), title=(x.get('title') or url)[:300])) 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) return JsonResponse({'added': added}) @@ -386,6 +386,8 @@ def edit_source(request, pk): if body.get('fulltext') in dict(Source.FULLTEXTS): source.fulltext = body['fulltext'] 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: # A section becomes a folder and back. if new_kind == Source.BLOG and source.category and not source.folders: @@ -416,7 +418,7 @@ def export_opml(request): if (err := _auth(request)): return err 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' place = s.category if s.kind == Source.NEWS else ((s.folders or [''])[0]) label = kind + (f' – {place}' if place else '') @@ -440,7 +442,7 @@ def export_opml(request): def refresh_now(request): if (err := _auth(request)): 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) return JsonResponse({'refreshed': refreshed, 'failed': failed, 'new': new}) @@ -770,6 +772,8 @@ def save_login(request): password = body.get('password') or '' if source is None or not username or not password: return JsonResponse({'error': 'Benutzername und Passwort nötig'}, status=400) + if source.synthetic: + return JsonResponse({'error': 'Diese Quelle hat keine Adresse'}, status=400) try: adapter = logins.check_and_store(request.user, source.url, username, password) except logins.LoginFailed as e: diff --git a/podcasts/chapters.py b/podcasts/chapters.py index d0632c5..705f119 100644 --- a/podcasts/chapters.py +++ b/podcasts/chapters.py @@ -128,25 +128,28 @@ FetchUnavailable = ChaptersUnavailable FetchInvalid = ChaptersInvalid -def _open(url, headers): - """GET `url` with every redirect hop checked; the streamed response of - the final hop, which the caller has to close. +def _open(url, headers, method='GET', timeout=CHAPTERS_TIMEOUT, **kwargs): + """`method` (GET or POST) `url` with every redirect hop checked; the streamed response of + 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 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): try: assert_safe_url(url) except WebDAVInputError as e: raise ChaptersInvalid(str(e)) 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: raise ChaptersUnavailable(e.__class__.__name__) try: _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') if not location: raise ChaptersInvalid('redirect without a location') @@ -155,7 +158,7 @@ def _open(url, headers): continue if r.status_code >= 500 or r.status_code == 429: 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}') except WebDAVInputError as e: r.close() diff --git a/podcasts/migrations/0027_transcription.py b/podcasts/migrations/0027_transcription.py new file mode 100644 index 0000000..4dce114 --- /dev/null +++ b/podcasts/migrations/0027_transcription.py @@ -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)), + ], + ), + ] diff --git a/podcasts/models.py b/podcasts/models.py index c24859c..010ce7b 100644 --- a/podcasts/models.py +++ b/podcasts/models.py @@ -217,6 +217,68 @@ class EpisodeTranscript(models.Model): 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), # the same rule as the apps' "als gehört zählen". PLAYED_BEFORE_END_SECONDS = 30 diff --git a/podcasts/test_transcription.py b/podcasts/test_transcription.py new file mode 100644 index 0000000..5171d4b --- /dev/null +++ b/podcasts/test_transcription.py @@ -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']) diff --git a/podcasts/transcription.py b/podcasts/transcription.py new file mode 100644 index 0000000..d39aac4 --- /dev/null +++ b/podcasts/transcription.py @@ -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': ''}) diff --git a/podcasts/urls.py b/podcasts/urls.py index aaaaa25..6ffc0d7 100644 --- a/podcasts/urls.py +++ b/podcasts/urls.py @@ -1,5 +1,5 @@ from django.urls import path -from . import audiobooks, discover, folders, radio, views +from . import audiobooks, discover, folders, radio, transcription, views urlpatterns = [ path('search/', views.podcast_search, name='podcast_search'), @@ -31,6 +31,8 @@ urlpatterns = [ path('episodes//silences/', views.episode_silences, name='podcast_episode_silences'), path('episodes//sponsor/', views.episode_sponsor, name='podcast_episode_sponsor'), path('episodes//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('queue/', views.queue_get, name='podcast_queue_get'), path('queue/add/', views.queue_add, name='podcast_queue_add'),