diora-web/accounts/sync.py
mrwnslz 60dd4926cf
All checks were successful
Build and push Docker image / build (push) Successful in 15s
Test / test (push) Successful in 8m21s
Lesestand: die zuletzt gelesene Stelle gewinnt, nicht mehr die weiteste; sw v100
docs/progress-sync.md ist der Vertrag für Server, Web, Android und Desktop.

- Server: read_at (Zeit, zu der dort gelesen wurde, Uhrfehler des Geräts per
  sent_at herausgerechnet, nie nach "jetzt"), Gerät, weiteste Stelle. Ältere
  Schreibvorgänge (offline nachgereicht, liegengelassener Tab) gewinnen nicht.
  Apps ohne read_at: nur strikt vorwärts. Antwort = Zustand nach dem Schreiben.
  /books/ mit X-Server-Now, /api/sync/ mit server_now.
- Web-Reader: Zeit einer Stelle ist der Moment, in dem man durch eigenes Tun
  hinkam (Scrollen, Blättern, Tasten, Vorlesen); unverändert Liegendes
  schickt nichts bzw. nur seine alte Zeit. Vor dem Wiederherstellen und
  während eines Blicks (Fußnote, "Weit gescrollt") wird nicht gespeichert.
  Eigene Stellen in einem Ausgang (localStorage) bis der Server sie hat.
  Angebote "Auf <Gerät> weitergelesen" und "Weiteste Stelle".
- Tests: books/tests.py (Reihenfolge, Uhrfehler, Zukunft, alte Apps, weiteste
  Stelle, Liste und Snapshot).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:31:36 +02:00

97 lines
3.9 KiB
Python

from django.db.models import Max
from django.http import JsonResponse
from django.utils import timezone
from django.views.decorators.http import require_http_methods
from books.models import EBook, EBookProgress, EBookHighlights, EBookBookmarks
from books.views import _now_ms, progress_state
from podcasts.models import PodcastFeed, EpisodeProgress, PodcastQueue
from radio.models import SavedStation
@require_http_methods(['GET'])
def sync_snapshot(request):
"""Aggregate read-only snapshot of a user's data for local/native clients
(see accounts/middleware.py for the Bearer-token auth that makes this
reachable without a browser session).
Deliberately excludes book file bytes and podcast audio — those stay
large/expensive and are fetched lazily via the existing per-resource
endpoints (e.g. GET /books/<id>/data/), which this same token also
unlocks. Writes (progress, highlights, bookmarks, ...) go through those
existing endpoints too, to reuse their merge semantics rather than
duplicating it here.
?only=books: just the books' part. The apps' book sync needs nothing else,
and the podcast part is by far the biggest (every episode ever played:
megabytes for an account that moved over from AntennaPod)."""
if not request.user.is_authenticated:
return JsonResponse({'error': 'authentication required'}, status=401)
user = request.user
books = list(EBook.objects.filter(user=user).values('id', 'meta_ct', 'meta_iv', 'uploaded_at', 'is_read'))
for b in books:
b['uploaded_at'] = b['uploaded_at'].isoformat()
book_progress = [
{'book_id': p.book_id, 'updated_at': p.updated_at.isoformat(), **progress_state(p)}
for p in EBookProgress.objects.filter(user=user)
]
book_highlights = list(EBookHighlights.objects.filter(user=user).values('book_id', 'ct', 'iv', 'updated_at'))
for h in book_highlights:
h['updated_at'] = h['updated_at'].isoformat()
book_bookmarks = list(EBookBookmarks.objects.filter(user=user).values('book_id', 'ct', 'iv', 'updated_at'))
for bm in book_bookmarks:
bm['updated_at'] = bm['updated_at'].isoformat()
if request.GET.get('only') == 'books':
return JsonResponse({
'server_time': timezone.now().isoformat(),
'server_now': _now_ms(),
'books': books,
'book_progress': book_progress,
'book_highlights': book_highlights,
'book_bookmarks': book_bookmarks,
})
podcast_feeds = list(
PodcastFeed.objects.filter(user=user)
.annotate(latest_episode_at=Max('episodes__pub_date'))
.values('id', 'title', 'artwork_url', 'rss_url', 'last_refreshed_at', 'author', 'added_at', 'auto_queue', 'latest_episode_at')
)
for f in podcast_feeds:
for key in ('last_refreshed_at', 'added_at', 'latest_episode_at'):
if f[key]:
f[key] = f[key].isoformat()
episode_progress = list(
EpisodeProgress.objects.filter(user=user)
.values('episode_id', 'position_seconds', 'played', 'dismissed', 'updated_at')
)
for ep in episode_progress:
ep['updated_at'] = ep['updated_at'].isoformat()
podcast_queue = list(
PodcastQueue.objects.filter(user=user).order_by('position').values('episode_id', 'position')
)
saved_stations = list(
SavedStation.objects.filter(user=user)
.values('id', 'name', 'url', 'bitrate', 'country', 'tags', 'favicon_url', 'is_favorite', 'notes')
)
return JsonResponse({
'server_time': timezone.now().isoformat(),
'server_now': _now_ms(),
'books': books,
'book_progress': book_progress,
'book_highlights': book_highlights,
'book_bookmarks': book_bookmarks,
'podcast_feeds': podcast_feeds,
'episode_progress': episode_progress,
'podcast_queue': podcast_queue,
'saved_stations': saved_stations,
})