048854f92a
Implements the stubbed /sync endpoints: - GET /sync/changes — delta pull (likes, plays, changed playlists with their track ids, changed catalogue tracks) over the half-open window (since, cursor]; the cursor is the DB clock, so it's immune to app/DB skew. - POST /sync/push — idempotent append of client like/play events (ON CONFLICT DO NOTHING by client-supplied id); events for tracks the server doesn't have are skipped (graceful degradation). Adds a server-ingestion column `synced_at` to the likes + play_history event logs (migration) as the delta ordering key, so an event pushed with an older event time still surfaces for other devices. Repos gain list_since/add_event (likes, history), list_changed_since (playlists, tracks) and playlist.track_ids; wired via SyncService in deps. Also fixes test isolation exposed by the registry-backed /admin/sources endpoint: test_sources_api clears the process-cached source registry, and test_admin_api no longer hardcodes the environment name. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
104 lines
3.2 KiB
Python
104 lines
3.2 KiB
Python
"""Client sync endpoints (offline-first event log).
|
|
|
|
``GET /sync/changes`` pulls everything the caller changed since their cursor;
|
|
``POST /sync/push`` uploads the like/play events a client accumulated offline
|
|
(idempotent — replays are no-ops). See :mod:`app.application.sync_service`.
|
|
"""
|
|
|
|
import datetime as dt
|
|
|
|
from fastapi import APIRouter
|
|
|
|
from app.api.deps import AlbumRepoDep, ArtistRepoDep, CurrentUser, SyncServiceDep
|
|
from app.api.schemas.sync import (
|
|
LikeEventOut,
|
|
PlayEventOut,
|
|
PlaylistSyncOut,
|
|
SyncChangesOut,
|
|
SyncPushIn,
|
|
SyncPushOut,
|
|
)
|
|
from app.api.v1.tracks import _build_track_out
|
|
from app.application.sync_service import LikeEvent, PlayEvent
|
|
|
|
router = APIRouter(prefix="/sync", tags=["sync"])
|
|
|
|
|
|
@router.get("/changes")
|
|
async def get_changes(
|
|
service: SyncServiceDep,
|
|
artist_repo: ArtistRepoDep,
|
|
album_repo: AlbumRepoDep,
|
|
user: CurrentUser,
|
|
since: dt.datetime | None = None,
|
|
) -> SyncChangesOut:
|
|
"""Delta since ``since`` (omit for a full snapshot). Persist ``cursor`` from
|
|
the response and pass it back as ``?since=`` next time."""
|
|
changes = await service.get_changes(user.id, since=since)
|
|
|
|
artist_ids = list({t.artist_id for t in changes.tracks})
|
|
album_ids = list({t.album_id for t in changes.tracks if t.album_id is not None})
|
|
artists = {a.id: a for a in await artist_repo.get_many(artist_ids)}
|
|
albums = {a.id: a for a in await album_repo.get_many(album_ids)}
|
|
tracks_out = await _build_track_out(changes.tracks, artists, albums)
|
|
|
|
return SyncChangesOut(
|
|
cursor=changes.cursor,
|
|
likes=[
|
|
LikeEventOut(
|
|
id=lk.id, track_id=lk.track_id, value=lk.value, created_at=lk.created_at
|
|
)
|
|
for lk in changes.likes
|
|
],
|
|
plays=[
|
|
PlayEventOut(
|
|
id=p.id,
|
|
track_id=p.track_id,
|
|
played_at=p.played_at,
|
|
play_duration_seconds=p.play_duration_seconds,
|
|
completed=p.completed,
|
|
)
|
|
for p in changes.plays
|
|
],
|
|
playlists=[
|
|
PlaylistSyncOut(
|
|
id=d.playlist.id,
|
|
name=d.playlist.name,
|
|
description=d.playlist.description,
|
|
version=d.playlist.version,
|
|
updated_at=d.playlist.updated_at,
|
|
track_ids=d.track_ids,
|
|
)
|
|
for d in changes.playlists
|
|
],
|
|
tracks=tracks_out,
|
|
)
|
|
|
|
|
|
@router.post("/push")
|
|
async def push_changes(
|
|
body: SyncPushIn, service: SyncServiceDep, user: CurrentUser
|
|
) -> SyncPushOut:
|
|
result = await service.push(
|
|
user.id,
|
|
likes=[
|
|
LikeEvent(id=e.id, track_id=e.track_id, value=e.value, created_at=e.created_at)
|
|
for e in body.likes
|
|
],
|
|
plays=[
|
|
PlayEvent(
|
|
id=e.id,
|
|
track_id=e.track_id,
|
|
played_at=e.played_at,
|
|
play_duration_seconds=e.play_duration_seconds,
|
|
completed=e.completed,
|
|
)
|
|
for e in body.plays
|
|
],
|
|
)
|
|
return SyncPushOut(
|
|
cursor=result.cursor,
|
|
accepted_likes=result.accepted_likes,
|
|
accepted_plays=result.accepted_plays,
|
|
)
|