diora-web/podcasts/audiobooks.py
mrwnslz 8f5be379fe Bibliothek neu: Ordner als Filter-Chips überall, Cover-Raster, Cover verschlüsselt und generiert (noch nicht veröffentlicht)
- Ordner wie Tags (docs/library.md): folders.js zeichnet die Chip-Reihe, das
  Blatt „In Ordner …“ (Häkchen, Neuer Ordner) und „Ordner verwalten“
  (umbenennen = zusammenlegen, löschen, Reihenfolge). E-Books: `folders` im
  verschlüsselten Meta, Reihenfolge in verschlüsselten Bibliotheks-Einstellungen
  (/books/prefs/). Podcasts/Hörbücher: PodcastFeed.folders (Migration 0021,
  übernimmt `folder`), /podcasts/folders/ (+rename, +delete). Blogs: Reihenfolge
  (/feeds/folders/order/).
- Bibliothek (53-library.js): Kopf mit Suche, Sortierung, Raster/Liste und +;
  Chips Angefangen · Ungelesen · Gelesen · Ordner; eine „Weiterlesen“-Karte;
  Mehrfachauswahl (lange drücken). E-Ink und diora Reader beginnen als Liste.
- Cover Ende-zu-Ende verschlüsselt (covers.js, /books/<id>/cover/): aus dem
  E-Book (EPUB-Coverbild, PDF Seite 1), 300 px, mit dem Nutzerschlüssel
  verschlüsselt, einmal hochgeladen; sparsam im Leerlauf, nie beim Lesen, nie
  auf E-Ink.
- Bücher ohne Cover bekommen eins (gencover.js): Stichwörter aus Titel, Autor,
  Regal und Ordnern wählen ein Gestaltungskonzept (Konstruktivismus, Geometrie,
  Psyche, Wellen, Karte, Kosmos, Ornament, Botanik, Berge, Zellen, Raster,
  Schatten, Typo, sonst Bauhaus); ein Hash aus Titel und Autor Palette, Aufbau
  und Details – einzigartig, überall gleich, nichts gespeichert.
- Hörbücher: Ordner-Chips statt Kacheln, „In Ordner …“ mit mehreren Ordnern.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 20:44:23 +02:00

213 lines
8.6 KiB
Python

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