Summary
The push-echo tokens (_recently_pushed_watched, _PUSHED_WATCHED_TTL = 10 min) are armed once, for every item, at the very start of a full push — but consumed only when each item's mark_watched echo comes back, which on a long push can be 10-20 minutes later. Every echo arriving after the TTL is treated as a genuine play: a phantom WatchEvent stamped at push time, plus a scrobble stop forwarded to Trakt/Simkl/MDBList (#369).
What happened (v2.11.0, Jellyfin, ~16 400 items, push duration 21 min)
Full push started 09:06:52 UTC. Two groups of items were legitimately marked on the server later in the run:
- 50 episodes marked at 09:21 (14 min after start) → 50 phantom WatchEvents (
provisional=true, progress_seconds=0, progress_percent=1) + 27 spurious Trakt plays
- 11 episodes marked at 09:26 (19 min after start) → 11 phantom WatchEvents + 11 spurious Trakt plays
Items marked in the first 10 minutes of the same push were swallowed correctly. Scrob history and Trakt cleaned by hand.
The next day's push on v2.13.0 (with #362 batching, 9 min total) produced zero phantom rows — only because every mark happened to land inside the window. Anyone with a large library or a slow server is still exposed.
Why
routers/sync.py, full push, before the push loop even starts:
if conn.push_watched:
for mid in watched_ids:
for sid in source_ids_map.get(mid, []):
if echoes_watched:
mark_pushed_watched(user_id, mid) # armed now…
watched_sid_to_mids.setdefault(sid, set()).add(mid)
…while _consume_recently_pushed_watched() in routers/webhooks.py compares the echo's arrival time against that arming timestamp:
pushed_at = queue.pop(0)
return (datetime.utcnow() - pushed_at) < _PUSHED_WATCHED_TTL # 10 min
The comment above _recently_pushed_watched says "Set right before each outbound mark-watched call", which is what the TTL was designed around — the up-front arming (introduced with the sid-grouping for #298) silently broke that assumption. Note that a stale token is also popped on expiry, so the item's real echo is not merely unprotected: the token is gone for good.
Suggested fix
Arm the token inside _push_watched_group / _push_known / _push_lookup, immediately before the mark_watched call (for every mid of the group), instead of in the pre-loop. Then the 10-minute TTL only has to cover webhook delivery latency, which is what it was meant for. Alternatively (belt and braces), don't expire tokens by wall-clock at all while the push job that armed them is still running.
Summary
The push-echo tokens (
_recently_pushed_watched,_PUSHED_WATCHED_TTL = 10 min) are armed once, for every item, at the very start of a full push — but consumed only when each item'smark_watchedecho comes back, which on a long push can be 10-20 minutes later. Every echo arriving after the TTL is treated as a genuine play: a phantom WatchEvent stamped at push time, plus a scrobble stop forwarded to Trakt/Simkl/MDBList (#369).What happened (v2.11.0, Jellyfin, ~16 400 items, push duration 21 min)
Full push started 09:06:52 UTC. Two groups of items were legitimately marked on the server later in the run:
provisional=true, progress_seconds=0, progress_percent=1) + 27 spurious Trakt playsItems marked in the first 10 minutes of the same push were swallowed correctly. Scrob history and Trakt cleaned by hand.
The next day's push on v2.13.0 (with #362 batching, 9 min total) produced zero phantom rows — only because every mark happened to land inside the window. Anyone with a large library or a slow server is still exposed.
Why
routers/sync.py, full push, before the push loop even starts:…while
_consume_recently_pushed_watched()inrouters/webhooks.pycompares the echo's arrival time against that arming timestamp:The comment above
_recently_pushed_watchedsays "Set right before each outbound mark-watched call", which is what the TTL was designed around — the up-front arming (introduced with the sid-grouping for #298) silently broke that assumption. Note that a stale token is also popped on expiry, so the item's real echo is not merely unprotected: the token is gone for good.Suggested fix
Arm the token inside
_push_watched_group/_push_known/_push_lookup, immediately before themark_watchedcall (for every mid of the group), instead of in the pre-loop. Then the 10-minute TTL only has to cover webhook delivery latency, which is what it was meant for. Alternatively (belt and braces), don't expire tokens by wall-clock at all while the push job that armed them is still running.