d2a22e49e3
Audit of every Android↔server connection point (2026-06-11) cleared the silent-contract class that caused the events `type` bug, but surfaced a cluster of offline/playback-robustness defects. Fixes: 1. Playback double-count on background. PlayEventsReporter no longer enqueues a partial play_offline + leaves the live row open on every screen-lock. closeCurrent() now routes by whether the server has an open row: close-by-id (durable PLAY_ENDED on failure) when it does, offline only when no row exists, and a no-op while a play_started is in flight (the server auto-closes that orphan). onStop only durably closes a *paused* play — a still-playing one is left to the live path under the foreground service. Adds the PLAY_ENDED mutation kind. 2. Replayer poison rows. MutationReplayer now classifies each replay as SENT / DROP / RETRY: permanent 4xx (and corrupt payloads) are dropped instead of retried forever; 408/429/5xx/transport still retry. 3. Offline-play / close-by-id idempotency (server). RecordOfflinePlay dedups on (user, track, started_at); RecordPlayEnded skips a second skip_events insert when re-closing an already-ended row. Makes the at-least-once replay safe against lost-response duplicates. 4. Like-toggle collapse. Replayer drops like-toggles superseded by a later toggle for the same entity, so partial-failure + differential retry can't invert the final like state. 5. Connectivity-return trigger. MutationReplayer + SyncController now also drain/sync when NetworkStatusController recovers to Healthy, so an offline→online transition mid-session doesn't wait for a cold start. SyncController.syncSafe gains a single-in-flight mutex. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>