diff --git a/src/haunt_fm/api/history.py b/src/haunt_fm/api/history.py index c493851..2ce2a08 100644 --- a/src/haunt_fm/api/history.py +++ b/src/haunt_fm/api/history.py @@ -36,4 +36,6 @@ async def receive_webhook(payload: WebhookPayload, session: AsyncSession = Depen listened_at=payload.listened_at, raw_payload=payload.model_dump(mode="json"), ) + if event is None: + return {"ok": True, "duplicate": True} return {"ok": True, "track_id": event.track_id, "event_id": event.id} diff --git a/src/haunt_fm/services/history_ingest.py b/src/haunt_fm/services/history_ingest.py index f4f214c..3f66d89 100644 --- a/src/haunt_fm/services/history_ingest.py +++ b/src/haunt_fm/services/history_ingest.py @@ -1,10 +1,13 @@ -from datetime import datetime +import logging +from datetime import datetime, timedelta, timezone from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from haunt_fm.models.track import ListenEvent, Track +logger = logging.getLogger(__name__) + def make_fingerprint(artist: str, title: str) -> str: return f"{artist.lower().strip()}::{title.lower().strip()}" @@ -47,9 +50,22 @@ async def ingest_listen_event( source: str, listened_at: datetime, raw_payload: dict | None = None, -) -> ListenEvent: +) -> ListenEvent | None: track = await upsert_track(session, title, artist, album) + # Deduplicate: skip if this track was logged within the last 60 seconds. + # Multiple HA entities (Cast, WiFi, MA) fire simultaneously for the same play event. + cutoff = datetime.now(timezone.utc) - timedelta(seconds=60) + recent = await session.execute( + select(ListenEvent) + .where(ListenEvent.track_id == track.id) + .where(ListenEvent.listened_at > cutoff) + .limit(1) + ) + if recent.scalar_one_or_none() is not None: + logger.debug("Skipping duplicate listen event for %s - %s", artist, title) + return None + event = ListenEvent( track_id=track.id, source=source,