feat(api): offline-first sync layer
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>
This commit is contained in:
@@ -6,12 +6,14 @@ bound to the request-scoped DB session; stateless adapters (hasher, token
|
||||
service) are process-cached.
|
||||
"""
|
||||
|
||||
import datetime as dt
|
||||
from collections.abc import AsyncIterator
|
||||
from functools import lru_cache
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends, Query
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.application.auth_service import AuthService
|
||||
@@ -20,6 +22,7 @@ from app.application.metadata_service import MetadataEnrichmentService
|
||||
from app.application.remote_library_service import RemoteLibraryService
|
||||
from app.application.streaming_service import StreamingService
|
||||
from app.application.subsonic_auth_service import SubsonicAuthService
|
||||
from app.application.sync_service import SyncService
|
||||
from app.application.upload_service import UploadService
|
||||
from app.application.user_service import UserService
|
||||
from app.application.user_settings_service import UserSettingsService
|
||||
@@ -199,6 +202,24 @@ DownloadServiceDep = Annotated[DownloadService, Depends(get_download_service)]
|
||||
RemoteLibraryServiceDep = Annotated[RemoteLibraryService, Depends(get_remote_library_service)]
|
||||
|
||||
|
||||
async def _db_now(session: AsyncSession) -> dt.datetime:
|
||||
"""The database clock — the sync cursor watermark (avoids app/DB skew)."""
|
||||
return (await session.execute(select(func.now()))).scalar_one()
|
||||
|
||||
|
||||
def get_sync_service(session: SessionDep) -> SyncService:
|
||||
return SyncService(
|
||||
likes=SqlAlchemyLikeRepository(session),
|
||||
history=SqlAlchemyHistoryRepository(session),
|
||||
playlists=SqlAlchemyPlaylistRepository(session),
|
||||
tracks=SqlAlchemyTrackRepository(session),
|
||||
now=lambda: _db_now(session),
|
||||
)
|
||||
|
||||
|
||||
SyncServiceDep = Annotated[SyncService, Depends(get_sync_service)]
|
||||
|
||||
|
||||
# -- library repository deps ---------------------------------------------------
|
||||
def get_track_repository(session: SessionDep) -> SqlAlchemyTrackRepository:
|
||||
return SqlAlchemyTrackRepository(session)
|
||||
|
||||
Reference in New Issue
Block a user