feat(phase-16): anonymous play & share telemetry substrate (wave 16.1)
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.
This commit is contained in:
@@ -0,0 +1,129 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user