"""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})