dbd90ee52a
Player-service play-session tracker (floor + 3-bucket classify), SharePopover share tracker with debounce, sendBeacon interop, proxied rate-limited POST api/event/{play,share}, append-only event logs + incremental play_counter with server-side release resolution. Migration authored, not applied. No anonId, no read surface.
130 lines
5.2 KiB
C#
130 lines
5.2 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.
|
|
/// </summary>
|
|
public void Close()
|
|
{
|
|
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;
|
|
|
|
_sink.EmitPlay(key, Classify(fraction));
|
|
}
|
|
|
|
// 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;
|
|
}
|