2af0d8650b
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.
141 lines
6.0 KiB
C#
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;
|
|
}
|