- 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>
213 lines
8.6 KiB
Python
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})
|