"""Audiobooks: podcast subscriptions marked as one (PodcastFeed.kind), listened to like a book in the library — chapters in order, one progress, bookmarks (docs/audiobooks.md).""" import json import re from datetime import datetime, timezone as dt_timezone from django.db.models import Max, Q from django.http import JsonResponse from django.views.decorators.csrf import csrf_exempt from django.views.decorators.http import require_http_methods from .models import EpisodeBookmark, EpisodeProgress, PodcastEpisode, PodcastFeed MAX_NOTE = 1000 _FAR = datetime.max.replace(tzinfo=dt_timezone.utc) _DIGITS = re.compile(r'(\d+)') CHAPTER_FIELDS = ('id', 'title', 'audio_url', 'duration_seconds', 'artwork_url', 'pub_date', 'episode_number') def _natural(title): """'Kapitel 2' before 'Kapitel 10'.""" return [int(part) if part.isdigit() else part.casefold() for part in _DIGITS.split(title or '')] def chapter_key(ep): number = ep['episode_number'] return (number is None, number or 0, ep['pub_date'] or _FAR, _natural(ep['title']), ep['id']) def _chapters(user, feed): """The feed's episodes in chapter order, with the user's place and played mark.""" eps = sorted(PodcastEpisode.objects.filter(feed=feed).values(*CHAPTER_FIELDS), key=chapter_key) progress = { p['episode_id']: p for p in EpisodeProgress.objects.filter(user=user, episode__feed=feed) .values('episode_id', 'position_seconds', 'played', 'updated_at') } out = [] for i, ep in enumerate(eps): p = progress.get(ep['id']) or {} out.append({ 'episode__id': ep['id'], 'episode__title': ep['title'], 'episode__audio_url': ep['audio_url'], 'episode__duration_seconds': ep['duration_seconds'], 'episode__artwork_url': ep['artwork_url'], 'episode__feed__id': feed.id, 'episode__feed__title': feed.title, 'episode__feed__artwork_url': feed.artwork_url, 'episode__pub_date': ep['pub_date'].isoformat() if ep['pub_date'] else None, 'position_seconds': p.get('position_seconds') or 0, 'played': bool(p.get('played')), 'index': i, # When it was last listened to (a place or played), for where the book goes on. '_heard_at': p.get('updated_at') if (p.get('played') or p.get('position_seconds')) else None, }) return out def _next(chapters): """Where the book goes on: in the chapter listened to last if it isn't finished, else in the first unfinished one after it (or before it, when the end was reached). Never listened: the first unfinished one. None: all finished.""" open_ = [ch for ch in chapters if not ch['played']] if not open_: return None heard = [ch for ch in chapters if ch['_heard_at']] if heard: last = max(heard, key=lambda ch: ch['_heard_at']) after = [ch for ch in open_ if ch['index'] >= last['index']] ch = after[0] if after else open_[0] else: ch = open_[0] return {'episode_id': ch['episode__id'], 'index': ch['index'], 'position_seconds': ch['position_seconds']} def _book(feed, chapters, last_played_at): total = heard = done = 0 for ch in chapters: dur = max(ch['episode__duration_seconds'] or 0, 0) total += dur if ch['played']: done += 1 heard += dur elif dur: heard += min(ch['position_seconds'], dur) nxt = _next(chapters) return { 'feed_id': feed.id, 'title': feed.title, 'author': feed.author, 'artwork_url': feed.artwork_url, 'folder': feed.folder, 'folders': feed.folders or [], 'chapters': len(chapters), 'done': done, 'total_seconds': total, 'heard_seconds': heard, 'next': nxt, 'last_played_at': last_played_at.isoformat() if last_played_at else None, } def _bookmark_json(bm): return {'id': bm.id, 'episode_id': bm.episode_id, 'position_ms': bm.position_ms, 'note': bm.note, 'created_at': bm.created_at.isoformat()} def _last_played(user, feed_ids): """When each book was last listened to: a place or a played mark, not the rows that only took its chapters out of "Neu" when it was marked.""" return dict( EpisodeProgress.objects.filter(user=user, episode__feed_id__in=feed_ids) .filter(Q(position_seconds__gt=0) | Q(played=True)) .values('episode__feed_id').annotate(at=Max('updated_at')).values_list('episode__feed_id', 'at') ) @csrf_exempt @require_http_methods(['GET']) def audiobook_list(request): """The user's audiobooks with their progress, the last listened first.""" if not request.user.is_authenticated: return JsonResponse({'error': 'authentication required'}, status=401) feeds = list(PodcastFeed.objects.filter(user=request.user, kind=PodcastFeed.AUDIOBOOK)) last = _last_played(request.user, [f.id for f in feeds]) books = [_book(f, _chapters(request.user, f), last.get(f.id)) for f in feeds] books.sort(key=lambda b: b['title'].casefold()) books.sort(key=lambda b: b['last_played_at'] or '', reverse=True) # never listened: last return JsonResponse({'books': books}) @csrf_exempt @require_http_methods(['GET']) def audiobook_detail(request, feed_id): """One audiobook: its progress, its chapters (shaped like queue entries, for the player) and its bookmarks in chapter order.""" if not request.user.is_authenticated: return JsonResponse({'error': 'authentication required'}, status=401) feed = PodcastFeed.objects.filter(pk=feed_id, user=request.user, kind=PodcastFeed.AUDIOBOOK).first() if feed is None: return JsonResponse({'error': 'not found'}, status=404) chapters = _chapters(request.user, feed) order = {ch['episode__id']: ch['index'] for ch in chapters} marks = sorted(EpisodeBookmark.objects.filter(user=request.user, episode__feed=feed), key=lambda bm: (order.get(bm.episode_id, 0), bm.position_ms, bm.id)) book = _book(feed, chapters, _last_played(request.user, [feed.id]).get(feed.id)) for ch in chapters: del ch['_heard_at'] return JsonResponse({ 'book': book, 'chapters': chapters, 'bookmarks': [_bookmark_json(bm) for bm in marks], }) def _body(request): try: body = json.loads(request.body or b'{}') except (json.JSONDecodeError, ValueError): return None return body if isinstance(body, dict) else None def _note(value): if value is None: return '' if not isinstance(value, str): raise ValueError return value.strip()[:MAX_NOTE] @csrf_exempt @require_http_methods(['POST']) def bookmark_add(request): if not request.user.is_authenticated: return JsonResponse({'error': 'authentication required'}, status=401) body = _body(request) if body is None: return JsonResponse({'error': 'invalid JSON'}, status=400) episode = PodcastEpisode.objects.filter(pk=body.get('episode_id'), feed__user=request.user).first() \ if isinstance(body.get('episode_id'), int) else None if episode is None: return JsonResponse({'error': 'not found'}, status=404) pos = body.get('position_ms') if not isinstance(pos, (int, float)) or isinstance(pos, bool) or not 0 <= pos < 2 ** 31: return JsonResponse({'error': 'invalid position_ms'}, status=400) try: note = _note(body.get('note')) except ValueError: return JsonResponse({'error': 'invalid note'}, status=400) bm = EpisodeBookmark.objects.create(user=request.user, episode=episode, position_ms=int(pos), note=note) return JsonResponse({'ok': True, 'bookmark': _bookmark_json(bm)}) @csrf_exempt @require_http_methods(['POST']) def bookmark_update(request, pk): if not request.user.is_authenticated: return JsonResponse({'error': 'authentication required'}, status=401) bm = EpisodeBookmark.objects.filter(pk=pk, user=request.user).first() body = _body(request) if bm is None: return JsonResponse({'error': 'not found'}, status=404) if body is None: return JsonResponse({'error': 'invalid JSON'}, status=400) try: bm.note = _note(body.get('note')) except ValueError: return JsonResponse({'error': 'invalid note'}, status=400) bm.save(update_fields=['note']) return JsonResponse({'ok': True, 'bookmark': _bookmark_json(bm)}) @csrf_exempt @require_http_methods(['POST']) def bookmark_delete(request, pk): if not request.user.is_authenticated: return JsonResponse({'error': 'authentication required'}, status=401) EpisodeBookmark.objects.filter(pk=pk, user=request.user).delete() return JsonResponse({'ok': True})