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>