fix: prevent duplicate teal.fm scrobbles from race condition (#472)

the $effect that calls incrementPlayCount() runs on every currentTime
update (~4x/second). when threshold is first crossed, svelte's batched
reactive updates meant the guard (playCountedForTrack === currentTrack.id)
could miss rapid-fire calls, causing 2 scrobbles ~50-125ms apart.

fix: add synchronous (non-reactive) guard that blocks immediately,
before the async fetch fires.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude <noreply@anthropic.com>

authored by zzstoatzz.io Claude and committed by GitHub d7c548ff 88fb5e46

Changed files
+17 -1
frontend
src
lib
components
player
+1 -1
frontend/src/lib/components/player/Player.svelte
··· 238 238 // only load new track if it actually changed 239 239 if (player.currentTrack.id !== previousTrackId) { 240 240 previousTrackId = player.currentTrack.id; 241 - player.playCountedForTrack = null; 241 + player.resetPlayCount(); 242 242 isLoadingTrack = true; 243 243 244 244 player.audioElement.src = `${API_URL}/audio/${player.currentTrack.file_id}`;
+16
frontend/src/lib/player.svelte.ts
··· 11 11 volume = $state(0.7); 12 12 playCountedForTrack = $state<number | null>(null); 13 13 14 + // synchronous guard to prevent duplicate play count requests 15 + // (reactive state updates are batched, so we need this to block rapid-fire calls) 16 + private _playCountPending: number | null = null; 17 + 14 18 playTrack(track: Track) { 15 19 if (this.currentTrack?.id === track.id) { 16 20 // toggle play/pause for same track ··· 31 35 return; 32 36 } 33 37 38 + // synchronous check to prevent race condition from batched reactive updates 39 + if (this._playCountPending === this.currentTrack.id) { 40 + return; 41 + } 42 + 34 43 // threshold: minimum of 30 seconds or 50% of track duration 35 44 const threshold = Math.min(30, this.duration * 0.5); 36 45 37 46 if (this.currentTime >= threshold) { 47 + // set synchronous guard immediately (before async fetch) 48 + this._playCountPending = this.currentTrack.id; 38 49 this.playCountedForTrack = this.currentTrack.id; 39 50 40 51 fetch(`${API_URL}/tracks/${this.currentTrack.id}/play`, { ··· 44 55 console.error('failed to increment play count:', err); 45 56 }); 46 57 } 58 + } 59 + 60 + resetPlayCount() { 61 + this.playCountedForTrack = null; 62 + this._playCountPending = null; 47 63 } 48 64 49 65 reset() {