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>
119 lines
4.6 KiB
Python
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})
|