diora-web/podcasts/article.py
mrwnslz bd09d81366 Smarte Transkription D: Artikel-Endpunkt und Blog-Anbindung
POST/DELETE /podcasts/episodes/<id>/article/ (podcasts/article.py): legt die Quelle Podcast-Artikel an und erzeugt oder ersetzt das Item der Folge; Sanitizer behält href=#t=<Sekunden>; _item_data und Item-Ansicht liefern episode_id; feeds.js: Folge anhören und Zeitmarken-Links.

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

119 lines
4.6 KiB
Python

"""The article written from an episode's transcript (docs/transcription.md): its text
is a feeds.Item in the user's favourite blog "Podcast-Artikel"."""
import hashlib
import json
from django.db import transaction
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 fetch
from feeds.models import Item, Source
from .models import EpisodeArticle, PodcastEpisode, TranscriptionJob
SOURCE_URL = 'diora://podcast-artikel'
SOURCE_TITLE = 'Podcast-Artikel'
TITLE_MAX = 300
SUMMARY_MAX = 2000
BODY_MAX = 1_000_000 # characters of body_html
MODEL_MAX = 100
KIND_MAX = 20
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 article_source(user):
"""The user's blog "Podcast-Artikel", made on first use."""
source, _ = Source.objects.get_or_create(user=user, url=SOURCE_URL, defaults={
'title': SOURCE_TITLE, 'kind': Source.BLOG, 'fav': True, 'synthetic': True})
return source
def _text(value, limit):
return value.strip() if isinstance(value, str) and len(value.strip()) <= limit else None
@csrf_exempt
@require_http_methods(['POST', 'DELETE'])
def episode_article(request, pk):
if (err := _auth(request)):
return err
ep = PodcastEpisode.objects.filter(pk=pk, feed__user=request.user).select_related('feed').first()
if ep is None:
return JsonResponse({'error': 'not found'}, status=404)
if request.method == 'DELETE':
art = EpisodeArticle.objects.filter(episode=ep).select_related('item').first()
if art is not None:
art.item.delete() # the article goes with it (cascade)
return JsonResponse({'ok': True})
body = _body(request)
if body is None:
return JsonResponse({'error': 'invalid JSON'}, status=400)
title = _text(body.get('title'), TITLE_MAX)
if not title:
return JsonResponse({'error': 'title needed (at most %d characters)' % TITLE_MAX}, status=400)
html_in = body.get('body_html')
if not isinstance(html_in, str) or not html_in.strip():
return JsonResponse({'error': 'body_html needed'}, status=400)
if len(html_in) > BODY_MAX:
return JsonResponse({'error': 'body_html too large'}, status=400)
summary = body.get('summary') or ''
if not isinstance(summary, str) or len(summary) > SUMMARY_MAX:
return JsonResponse({'error': 'summary too long'}, status=400)
model = body.get('model') or ''
kind = body.get('kind') or ''
if not isinstance(model, str) or len(model) > MODEL_MAX or not isinstance(kind, str) or len(kind) > KIND_MAX:
return JsonResponse({'error': 'model or kind too long'}, status=400)
url = getattr(ep, 'link', '') or ep.audio_url
content = fetch.clean_html(html_in, url)
if not fetch.plain(content):
return JsonResponse({'error': 'body_html empty'}, status=400)
fields = {
'url': url[:2000],
'title': title,
'author': (ep.feed.author or '')[:300],
'published': timezone.now(),
'summary': fetch.plain(summary),
'content': content,
'content_full': True, # nothing to fetch from a page
'words': fetch.word_count(content),
'image_url': (ep.artwork_url or ep.feed.artwork_url or '')[:2000],
'read_at': None, 'progress': 0, 'progress_at': None,
}
with transaction.atomic():
source = article_source(request.user)
art = EpisodeArticle.objects.filter(episode=ep).select_related('item').first()
if art is not None:
item = art.item
for k, v in fields.items():
setattr(item, k, v)
item.source = source
item.save()
art.model, art.kind = model, kind
art.save(update_fields=['model', 'kind'])
else:
guid = hashlib.sha256(f'diora-article:{ep.pk}'.encode()).hexdigest()
item = Item.objects.create(source=source, user=request.user, kind=Source.BLOG,
guid_hash=guid, **fields)
EpisodeArticle.objects.create(episode=ep, item=item, model=model, kind=kind)
# Saving fulfils the wish and frees the reservation.
TranscriptionJob.objects.filter(episode=ep).update(want_article=False, claimed_by='', claimed_until=None)
return JsonResponse({'item_id': item.id})