8652d7124e
The events dispatcher closed every prior open row as play_skipped on any track-id change. Server-side RecordPlaySkipped force-sets was_skipped=true regardless of completion, so a queue played start to finish reported tracks 1..N-1 as skips — inflating recommendation skip-ratios (-skipRatio*SkipPenalty) and degrading For You / Discover quality for web listeners. Now: on track change, close the prior row as play_ended if that track reached ~its duration (3s tolerance, matching the Flutter PlayEventsReporter), else play_skipped at the real last position. Race fix: the store synchronously resets position/duration to 0 on track change, so reading lastPositionMs at change-time would see 0 and misclassify. Track per-open-row state (openReachedEnd, openLastPositionMs, openDurationMs) updated ONLY while the open track is current — a track change can't clobber them before the close branch runs. Brings web wire behavior back in line with Flutter. Test added: auto-advance after reaching duration → play_ended, never skipped; existing mid-track-skip and pause-at-end tests still hold. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>