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>
97 lines
3.9 KiB
Python
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,
|
|
})
|