Files
deepdrft/DeepDrftPublic.Client/Services/PlayTracker.cs
T
daniel-c-harvey 2af0d8650b fix(telemetry): first-party fetch for play/share, beacon only on unload
Route normal play closes (end/switch/stop) and all shares through a same-origin
HttpClient POST so privacy-hardened browsers stop blocking them; keep sendBeacon
for the tab-unload edge. Rename the JS module off telemetry/beacon to session/
lifecycle so the retained fallback isn't name-matched. No new data or identifiers.
2026-06-26 21:11:43 -04:00

141 lines
6.0 KiB
C#

using DeepDrftModels.Enums;
namespace DeepDrftPublic.Client.Services;
/// <summary>
/// Per-session play tracker (Phase 16 §2.1). Observes the player-service playback lifecycle — open on
/// playback start, advance the high-water mark on each progress tick, close on organic end / track-switch
/// / stop / page-unload — and emits at most one play event per session, classified into a completion
/// bucket, but only once the engagement floor is crossed (§1d / D2).
///
/// <para>
/// Deliberately free of any player, HTTP, or JS dependency: it takes an <see cref="IPlayEventSink"/> and
/// owns only session state and the floor/classification arithmetic, so its behaviour is unit-testable
/// against a fake sink with no interop (the spec's "testable behind one seam"). The production sink fires
/// the beacon. Instrumented at the player-service level ONLY — never the HTTP/media client — so a
/// seek-beyond-buffer re-fetch is the same play, not a new one (§1d).
/// </para>
///
/// <para>Not thread-safe: the WASM dispatcher is single-threaded, and every call originates there.</para>
/// </summary>
public sealed class PlayTracker
{
// Engagement floor (§1d / D2): a listen counts only once playback reaches at least 3 seconds OR
// 5% of duration, whichever is SMALLER — so a sub-60s clip floors on the percentage and anything
// longer floors on the 3-second wall. Single tunable constant pair; one place to retune.
private const double FloorSeconds = 3.0;
private const double FloorFraction = 0.05;
// Bucket thresholds (§1a / D1): partial [0, 30%), sampled [30%, 80%], complete (80%, 100%].
private const double SampledThreshold = 0.30;
private const double CompleteThreshold = 0.80;
private readonly IPlayEventSink _sink;
private string? _trackEntryKey;
private double? _duration;
private double _highWater;
private bool _closed;
public PlayTracker(IPlayEventSink sink)
{
_sink = sink;
}
/// <summary>True while a session is open (playback started, not yet closed). Drives the unload beacon.</summary>
public bool HasOpenSession => _trackEntryKey is not null && !_closed;
/// <summary>
/// Open a session for the track whose playback just started. Supersedes any still-open session by
/// closing it first — a track-switch that did not route through <see cref="Close"/> still records the
/// prior listen. Duration is unknown at open and arrives later via <see cref="SetDuration"/>.
/// </summary>
public void OnPlaybackStarted(string trackEntryKey)
{
if (HasOpenSession)
Close();
_trackEntryKey = trackEntryKey;
_duration = null;
_highWater = 0;
_closed = false;
}
/// <summary>
/// Record the duration once the WAV header has set it. Idempotent — only the first non-positive-guarded
/// value is taken, matching the player which sets <c>Duration</c> exactly once.
/// </summary>
public void SetDuration(double durationSeconds)
{
if (!HasOpenSession) return;
if (_duration is null && durationSeconds > 0)
_duration = durationSeconds;
}
/// <summary>
/// Advance the high-water mark from a progress tick. Monotonic — seeking backward never lowers it,
/// so a seek-to-end-then-back still classifies by the furthest point reached (§1d).
/// </summary>
public void OnProgress(double currentTime)
{
if (!HasOpenSession) return;
if (currentTime > _highWater)
_highWater = currentTime;
}
/// <summary>
/// Close the open session and emit a play event if the engagement floor was crossed; below the floor
/// nothing is sent (it was a preview/skip, §1d). Idempotent and safe to call when no session is open —
/// organic end, track-switch, stop, dispose, and the unload beacon may all race to close, and only the
/// first call emits.
///
/// <para><paramref name="viaUnload"/> selects the transport, not the classification (telemetry
/// transport-resilience). The default (false) is the normal close (organic end / track-switch / stop):
/// the page is alive, so the event goes over the first-party fetch arm. The unload handler passes true
/// so the rare tab-close mid-play uses <c>sendBeacon</c>, the only transport that survives the freeze.
/// The fetch arm is fire-and-forget here because the close paths are sync-shaped (a void JS callback,
/// or a teardown we must not block on a telemetry POST) — on a live page the task still completes.</para>
/// </summary>
public void Close(bool viaUnload = false)
{
if (!HasOpenSession)
{
// Mark closed even if never opened so a stray late callback cannot reopen-then-emit.
_closed = true;
return;
}
var key = _trackEntryKey!;
_closed = true;
// Without a known duration there is no fraction to classify and no floor to test — drop the
// session. In practice the WAV header sets duration well before any meaningful listen, so this
// only drops listens that ended before the header parsed (i.e. effectively no listen).
if (_duration is not { } duration || duration <= 0)
return;
var fraction = Math.Clamp(_highWater / duration, 0.0, 1.0);
if (!CrossesFloor(_highWater, duration))
return;
var bucket = Classify(fraction);
if (viaUnload)
_sink.EmitPlayOnUnload(key, bucket);
else
_ = _sink.EmitPlayAsync(key, bucket);
}
// The floor is the SMALLER of the absolute-seconds wall and the percentage of duration (§1d / D2).
private static bool CrossesFloor(double highWater, double duration)
{
var floor = Math.Min(FloorSeconds, FloorFraction * duration);
return highWater >= floor;
}
private static PlayBucket Classify(double fraction)
=> fraction < SampledThreshold ? PlayBucket.Partial
: fraction <= CompleteThreshold ? PlayBucket.Sampled
: PlayBucket.Complete;
}