feat(phase-16.3): light up anonId unique-listener layer
Mint a first-party localStorage anonId, thread it onto play/share beacons, persist it via EventController, and add all-time distinct-listener counts (site/track/release). Storage columns + indexes already existed from 16.1.
This commit is contained in:
@@ -22,6 +22,12 @@ public class EventController : ControllerBase
|
|||||||
// payloads are a track key + an enum, well under 1 KB.
|
// payloads are a track key + an enum, well under 1 KB.
|
||||||
private const int MaxBodyBytes = 1024;
|
private const int MaxBodyBytes = 1024;
|
||||||
|
|
||||||
|
// The anonId is a client-minted GUID string (~36 chars); the anon_id column is varchar(64). Reject
|
||||||
|
// anything longer as malformed rather than silently truncating — an over-long token is either a bug
|
||||||
|
// or an inflation attempt, and a truncated id would corrupt the distinct-listener count by colliding
|
||||||
|
// distinct listeners onto one prefix. Whitespace-only is treated as absent.
|
||||||
|
private const int MaxAnonIdLength = 64;
|
||||||
|
|
||||||
private readonly IEventService _eventService;
|
private readonly IEventService _eventService;
|
||||||
private readonly ILogger<EventController> _logger;
|
private readonly ILogger<EventController> _logger;
|
||||||
|
|
||||||
@@ -43,9 +49,10 @@ public class EventController : ControllerBase
|
|||||||
return BadRequest("trackEntryKey is required");
|
return BadRequest("trackEntryKey is required");
|
||||||
if (!Enum.IsDefined(payload.Bucket))
|
if (!Enum.IsDefined(payload.Bucket))
|
||||||
return BadRequest("bucket is invalid");
|
return BadRequest("bucket is invalid");
|
||||||
|
if (!TryNormalizeAnonId(payload.AnonId, out var anonId))
|
||||||
|
return BadRequest("anonId is invalid");
|
||||||
|
|
||||||
// Wave 16.1 writes no anonId — defend the substrate by dropping any the client sends early.
|
var result = await _eventService.RecordPlay(payload.TrackEntryKey, payload.Bucket, anonId, ct);
|
||||||
var result = await _eventService.RecordPlay(payload.TrackEntryKey, payload.Bucket, anonId: null, ct);
|
|
||||||
if (!result.Success)
|
if (!result.Success)
|
||||||
{
|
{
|
||||||
// A telemetry failure must never surface to the listener as an error they can act on, but
|
// A telemetry failure must never surface to the listener as an error they can act on, but
|
||||||
@@ -69,8 +76,10 @@ public class EventController : ControllerBase
|
|||||||
return BadRequest("targetType is invalid");
|
return BadRequest("targetType is invalid");
|
||||||
if (!Enum.IsDefined(payload.Channel))
|
if (!Enum.IsDefined(payload.Channel))
|
||||||
return BadRequest("channel is invalid");
|
return BadRequest("channel is invalid");
|
||||||
|
if (!TryNormalizeAnonId(payload.AnonId, out var anonId))
|
||||||
|
return BadRequest("anonId is invalid");
|
||||||
|
|
||||||
var result = await _eventService.RecordShare(payload.TargetType, payload.TargetKey, payload.Channel, anonId: null, ct);
|
var result = await _eventService.RecordShare(payload.TargetType, payload.TargetKey, payload.Channel, anonId, ct);
|
||||||
if (!result.Success)
|
if (!result.Success)
|
||||||
{
|
{
|
||||||
_logger.LogWarning("RecordShare failed: {Error}", result.Messages.FirstOrDefault()?.Message);
|
_logger.LogWarning("RecordShare failed: {Error}", result.Messages.FirstOrDefault()?.Message);
|
||||||
@@ -79,4 +88,27 @@ public class EventController : ControllerBase
|
|||||||
|
|
||||||
return Accepted();
|
return Accepted();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Normalize an incoming anonId (wave 16.3): whitespace-only / empty / null collapses to a null token
|
||||||
|
// (the listener didn't send one, or storage was unavailable — a valid, anonId-less event). A token
|
||||||
|
// over the column width is rejected (400) rather than truncated, since truncation would collide
|
||||||
|
// distinct listeners. Returns false only on the over-long case; null and a valid token both pass.
|
||||||
|
private static bool TryNormalizeAnonId(string? raw, out string? anonId)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(raw))
|
||||||
|
{
|
||||||
|
anonId = null;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
var trimmed = raw.Trim();
|
||||||
|
if (trimmed.Length > MaxAnonIdLength)
|
||||||
|
{
|
||||||
|
anonId = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
anonId = trimmed;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,4 +53,48 @@ public class EventManager : IEventService
|
|||||||
return Result.CreateFailResult(e.Message);
|
return Result.CreateFailResult(e.Message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<ResultContainer<int>> GetDistinctListenerCount(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var count = await _repository.CountDistinctListenersAsync(cancellationToken);
|
||||||
|
return ResultContainer<int>.CreatePassResult(count);
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
_logger.LogError(e, "Failed to count distinct listeners");
|
||||||
|
return ResultContainer<int>.CreateFailResult(e.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ResultContainer<int>> GetDistinctListenerCountForTrack(
|
||||||
|
string trackEntryKey, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var count = await _repository.CountDistinctListenersForTrackAsync(trackEntryKey, cancellationToken);
|
||||||
|
return ResultContainer<int>.CreatePassResult(count);
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
_logger.LogError(e, "Failed to count distinct listeners for track {TrackEntryKey}", trackEntryKey);
|
||||||
|
return ResultContainer<int>.CreateFailResult(e.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ResultContainer<int>> GetDistinctListenerCountForRelease(
|
||||||
|
long releaseId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var count = await _repository.CountDistinctListenersForReleaseAsync(releaseId, cancellationToken);
|
||||||
|
return ResultContainer<int>.CreatePassResult(count);
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
_logger.LogError(e, "Failed to count distinct listeners for release {ReleaseId}", releaseId);
|
||||||
|
return ResultContainer<int>.CreateFailResult(e.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,4 +20,20 @@ public interface IEventService
|
|||||||
|
|
||||||
/// <summary>Record one share: append a <c>share_event</c> row. Target and channel come straight from the client.</summary>
|
/// <summary>Record one share: append a <c>share_event</c> row. Target and channel come straight from the client.</summary>
|
||||||
Task<Result> RecordShare(ShareTargetType targetType, string targetKey, ShareChannel channel, string? anonId = null, CancellationToken cancellationToken = default);
|
Task<Result> RecordShare(ShareTargetType targetType, string targetKey, ShareChannel channel, string? anonId = null, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Site-wide distinct-listener count (Phase 16 §3, D3 — all-time): distinct non-null <c>anon_id</c>
|
||||||
|
/// values across all play events. Null tokens are excluded (not a known listener). The capability for
|
||||||
|
/// wave 16.5's "N listeners" card; nothing surfaces it via API or UI in wave 16.3.
|
||||||
|
/// </summary>
|
||||||
|
Task<ResultContainer<int>> GetDistinctListenerCount(CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>Distinct listeners who played the given track (by vault entry key). Null tokens excluded.</summary>
|
||||||
|
Task<ResultContainer<int>> GetDistinctListenerCountForTrack(string trackEntryKey, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Distinct listeners across the release's tracks (derived, D4) — a listener who played any track in
|
||||||
|
/// the release counts once. Null tokens excluded.
|
||||||
|
/// </summary>
|
||||||
|
Task<ResultContainer<int>> GetDistinctListenerCountForRelease(long releaseId, CancellationToken cancellationToken = default);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,6 +83,44 @@ public class EventRepository
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Count distinct non-null anon ids across every play event (Phase 16 §3 / §4.2 — the all-time
|
||||||
|
/// unique-listener metric, D3). Null anon ids (events where the listener sent no token, or storage
|
||||||
|
/// was unavailable) are excluded — they are not a known listener and must not inflate the count. This
|
||||||
|
/// is the site-wide listener reach figure; the per-track / per-release overloads scope it.
|
||||||
|
/// </summary>
|
||||||
|
public Task<int> CountDistinctListenersAsync(CancellationToken ct = default)
|
||||||
|
=> _context.PlayEvents
|
||||||
|
.Where(e => e.AnonId != null)
|
||||||
|
.Select(e => e.AnonId)
|
||||||
|
.Distinct()
|
||||||
|
.CountAsync(ct);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Distinct listeners for one track, keyed by its vault entry key (the same key the play event
|
||||||
|
/// stamps). Null anon ids excluded. Per-track scope of <see cref="CountDistinctListenersAsync()"/>.
|
||||||
|
/// </summary>
|
||||||
|
public Task<int> CountDistinctListenersForTrackAsync(string trackEntryKey, CancellationToken ct = default)
|
||||||
|
=> _context.PlayEvents
|
||||||
|
.Where(e => e.TrackEntryKey == trackEntryKey && e.AnonId != null)
|
||||||
|
.Select(e => e.AnonId)
|
||||||
|
.Distinct()
|
||||||
|
.CountAsync(ct);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Distinct listeners for one release, derived across the release's tracks (D4): the play event
|
||||||
|
/// stamps the resolved release id at write time, so a distinct count over <c>anon_id</c> filtered by
|
||||||
|
/// <c>release_id</c> is exactly "distinct listeners who played any track in this release." Null anon
|
||||||
|
/// ids excluded. A listener who heard two tracks of the release counts once (it is a distinct count
|
||||||
|
/// over the union, not a sum of per-track counts).
|
||||||
|
/// </summary>
|
||||||
|
public Task<int> CountDistinctListenersForReleaseAsync(long releaseId, CancellationToken ct = default)
|
||||||
|
=> _context.PlayEvents
|
||||||
|
.Where(e => e.ReleaseId == releaseId && e.AnonId != null)
|
||||||
|
.Select(e => e.AnonId)
|
||||||
|
.Distinct()
|
||||||
|
.CountAsync(ct);
|
||||||
|
|
||||||
/// <summary>Append one share event. No rollup table for shares in wave 16.1 — a plain insert.</summary>
|
/// <summary>Append one share event. No rollup table for shares in wave 16.1 — a plain insert.</summary>
|
||||||
public async Task RecordShareAsync(
|
public async Task RecordShareAsync(
|
||||||
ShareTargetType targetType, string targetKey, ShareChannel channel, string? anonId,
|
ShareTargetType targetType, string targetKey, ShareChannel channel, string? anonId,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ public partial class AudioPlayerProvider : ComponentBase, IAsyncDisposable
|
|||||||
[Inject] public required ILogger<StreamingAudioPlayerService> Logger { get; set; }
|
[Inject] public required ILogger<StreamingAudioPlayerService> Logger { get; set; }
|
||||||
[Inject] public required BeaconInterop Beacon { get; set; }
|
[Inject] public required BeaconInterop Beacon { get; set; }
|
||||||
[Inject] public required IPlayEventSink PlayEventSink { get; set; }
|
[Inject] public required IPlayEventSink PlayEventSink { get; set; }
|
||||||
|
[Inject] public required IAnonIdProvider AnonId { get; set; }
|
||||||
|
|
||||||
private IStreamingPlayerService? _audioPlayerService;
|
private IStreamingPlayerService? _audioPlayerService;
|
||||||
private QueueService? _queueService;
|
private QueueService? _queueService;
|
||||||
@@ -50,6 +51,20 @@ public partial class AudioPlayerProvider : ComponentBase, IAsyncDisposable
|
|||||||
_queueService.Attach(_audioPlayerService);
|
_queueService.Attach(_audioPlayerService);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Warm the anon-id cache once the provider is interactive (Phase 16 wave 16.3). Done here, after the
|
||||||
|
/// first render, because the localStorage read is JS interop — not available during prerender. By the
|
||||||
|
/// time any play session closes and the sink reads <c>AnonId.Current</c>, the cache is populated; a
|
||||||
|
/// play that somehow closes before this completes simply sends no anonId (acceptable over-count). The
|
||||||
|
/// provider is the natural warm point: it is mounted in MainLayout, so it goes interactive on every
|
||||||
|
/// page the player can play from.
|
||||||
|
/// </summary>
|
||||||
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||||
|
{
|
||||||
|
if (firstRender)
|
||||||
|
await AnonId.EnsureLoadedAsync();
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Dispose the player on unmount so the JS setInterval driving progress
|
/// Dispose the player on unmount so the JS setInterval driving progress
|
||||||
/// callbacks no longer holds a DotNetObjectReference into a destroyed
|
/// callbacks no longer holds a DotNetObjectReference into a destroyed
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ public partial class SharePopover : ComponentBase, IDisposable
|
|||||||
[Inject] public required NavigationManager Navigation { get; set; }
|
[Inject] public required NavigationManager Navigation { get; set; }
|
||||||
[Inject] public required IJSRuntime JS { get; set; }
|
[Inject] public required IJSRuntime JS { get; set; }
|
||||||
[Inject] public required ShareTracker ShareTracker { get; set; }
|
[Inject] public required ShareTracker ShareTracker { get; set; }
|
||||||
|
[Inject] public required IAnonIdProvider AnonId { get; set; }
|
||||||
|
|
||||||
private bool IsReleaseMode => ReleaseEntryKey is not null;
|
private bool IsReleaseMode => ReleaseEntryKey is not null;
|
||||||
|
|
||||||
@@ -65,7 +66,15 @@ public partial class SharePopover : ComponentBase, IDisposable
|
|||||||
? EmbedSnippetBuilder.ForRelease(Navigation.BaseUri, ReleaseEntryKey!)
|
? EmbedSnippetBuilder.ForRelease(Navigation.BaseUri, ReleaseEntryKey!)
|
||||||
: EmbedSnippetBuilder.ForTrack(Navigation.BaseUri, EntryKey!);
|
: EmbedSnippetBuilder.ForTrack(Navigation.BaseUri, EntryKey!);
|
||||||
|
|
||||||
private void Toggle() => _open = !_open;
|
private async Task Toggle()
|
||||||
|
{
|
||||||
|
_open = !_open;
|
||||||
|
// Warm the anon-id cache when the popover opens (wave 16.3) so a copy-share fired moments later
|
||||||
|
// reads a populated AnonId.Current. Idempotent and best-effort — if it fails the share simply
|
||||||
|
// carries no anonId. Opening is interactive, so the localStorage interop is available here.
|
||||||
|
if (_open)
|
||||||
|
await AnonId.EnsureLoadedAsync();
|
||||||
|
}
|
||||||
|
|
||||||
private void Close() => _open = false;
|
private void Close() => _open = false;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
using Microsoft.JSInterop;
|
||||||
|
|
||||||
|
namespace DeepDrftPublic.Client.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Production <see cref="IAnonIdProvider"/> over the <c>window.DeepDrftAnonId</c> TS interop. Reads the
|
||||||
|
/// first-party <c>localStorage</c> GUID once and caches it for the session, so the synchronous emit paths
|
||||||
|
/// read it with no JS hop. Scoped (per-session) like the other telemetry collaborators; the underlying
|
||||||
|
/// token itself outlives the session in <c>localStorage</c> — the cache just avoids repeated interop.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AnonIdProvider : IAnonIdProvider
|
||||||
|
{
|
||||||
|
private readonly IJSRuntime _js;
|
||||||
|
private bool _loaded;
|
||||||
|
|
||||||
|
public AnonIdProvider(IJSRuntime js)
|
||||||
|
{
|
||||||
|
_js = js;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string? Current { get; private set; }
|
||||||
|
|
||||||
|
public async ValueTask EnsureLoadedAsync()
|
||||||
|
{
|
||||||
|
if (_loaded) return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// The module returns null when localStorage is unavailable; we store that null and still
|
||||||
|
// mark loaded so we don't retry every emit. A genuine interop failure (module not yet
|
||||||
|
// imported, prerender) is caught below and leaves _loaded false so a later warm can succeed.
|
||||||
|
Current = await _js.InvokeAsync<string?>("DeepDrftAnonId.get");
|
||||||
|
_loaded = true;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Interop unavailable (prerender / module not loaded). Leave Current null and unloaded so a
|
||||||
|
// subsequent warm retries — telemetry simply omits the id until then.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
using DeepDrftModels.DTOs;
|
using DeepDrftModels.DTOs;
|
||||||
using DeepDrftModels.Enums;
|
using DeepDrftModels.Enums;
|
||||||
using Microsoft.AspNetCore.Components;
|
using Microsoft.AspNetCore.Components;
|
||||||
@@ -10,16 +11,26 @@ namespace DeepDrftPublic.Client.Services;
|
|||||||
/// it via <c>navigator.sendBeacon</c> to the proxied <c>api/event/play</c> route. Fire-and-forget by
|
/// it via <c>navigator.sendBeacon</c> to the proxied <c>api/event/play</c> route. Fire-and-forget by
|
||||||
/// design — <see cref="IPlayEventSink.EmitPlay"/> is synchronous (it is called from the player's close
|
/// design — <see cref="IPlayEventSink.EmitPlay"/> is synchronous (it is called from the player's close
|
||||||
/// path and the unload handler, neither of which can await), so the beacon is dispatched without
|
/// path and the unload handler, neither of which can await), so the beacon is dispatched without
|
||||||
/// awaiting and its failure is irrelevant. No <c>anonId</c> is sent in wave 16.1.
|
/// awaiting and its failure is irrelevant. The current <c>anonId</c> (wave 16.3) is read synchronously
|
||||||
|
/// from the warmed <see cref="IAnonIdProvider"/> cache and omitted when null (storage unavailable / not
|
||||||
|
/// yet warmed) — an anonId-less play still counts, it just doesn't contribute to the listener tally.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class BeaconPlayEventSink : IPlayEventSink
|
public sealed class BeaconPlayEventSink : IPlayEventSink
|
||||||
{
|
{
|
||||||
|
// Omit a null anonId from the wire payload (§2.2 — "omitted entirely" when absent) rather than
|
||||||
|
// sending "anonId":null. The API treats absent and null identically, so this is cosmetic minimalism;
|
||||||
|
// it does not change the integer enum encoding the 16.1 contract already relies on.
|
||||||
|
private static readonly JsonSerializerOptions BeaconJson =
|
||||||
|
new() { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull };
|
||||||
|
|
||||||
private readonly BeaconInterop _beacon;
|
private readonly BeaconInterop _beacon;
|
||||||
|
private readonly IAnonIdProvider _anonId;
|
||||||
private readonly string _playUrl;
|
private readonly string _playUrl;
|
||||||
|
|
||||||
public BeaconPlayEventSink(BeaconInterop beacon, NavigationManager navigation)
|
public BeaconPlayEventSink(BeaconInterop beacon, IAnonIdProvider anonId, NavigationManager navigation)
|
||||||
{
|
{
|
||||||
_beacon = beacon;
|
_beacon = beacon;
|
||||||
|
_anonId = anonId;
|
||||||
// The WASM client posts to its own host, which proxies to DeepDrftAPI. BaseUri carries a
|
// The WASM client posts to its own host, which proxies to DeepDrftAPI. BaseUri carries a
|
||||||
// trailing slash; the route does not lead with one.
|
// trailing slash; the route does not lead with one.
|
||||||
_playUrl = $"{navigation.BaseUri}api/event/play";
|
_playUrl = $"{navigation.BaseUri}api/event/play";
|
||||||
@@ -31,7 +42,8 @@ public sealed class BeaconPlayEventSink : IPlayEventSink
|
|||||||
{
|
{
|
||||||
TrackEntryKey = trackEntryKey,
|
TrackEntryKey = trackEntryKey,
|
||||||
Bucket = bucket,
|
Bucket = bucket,
|
||||||
});
|
AnonId = _anonId.Current,
|
||||||
|
}, BeaconJson);
|
||||||
|
|
||||||
// Fire-and-forget: do not await. The beacon survives unload; the C# task may not, and we do not
|
// Fire-and-forget: do not await. The beacon survives unload; the C# task may not, and we do not
|
||||||
// act on the result either way.
|
// act on the result either way.
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
namespace DeepDrftPublic.Client.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Supplies the client-minted anonymous listener id (Phase 16 §3, wave 16.3, D5 Option A) to the
|
||||||
|
/// fire-and-forget telemetry sinks. The id is a random first-party <c>localStorage</c> GUID — opaque, no
|
||||||
|
/// PII, no fingerprinting, clearable. Split into an async warm (<see cref="EnsureLoadedAsync"/>) and a
|
||||||
|
/// synchronous read (<see cref="Current"/>) so the existing sync emit paths (the beacon sink, the share
|
||||||
|
/// tracker) need no async signature change: a caller warms the cache when it goes interactive, and the
|
||||||
|
/// emit then reads the cached value with no JS round-trip on the close/unload path.
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Degrades to null when <c>localStorage</c> is unavailable (private mode / blocked / partitioned
|
||||||
|
/// third-party iframe) — the sink then omits the id and sends an anonId-less event. Over-counting is the
|
||||||
|
/// accepted direction of error (§3); a missing id never throws.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public interface IAnonIdProvider
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The cached anon id, or null if not yet warmed or if storage is unavailable. Synchronous and safe
|
||||||
|
/// to read from the player close path and the page-unload handler, neither of which can await.
|
||||||
|
/// </summary>
|
||||||
|
string? Current { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Warm the cache from <c>localStorage</c> via JS interop (minting on first visit). Idempotent — only
|
||||||
|
/// the first successful read populates the cache; later calls are no-ops. Best-effort: a JS failure
|
||||||
|
/// (interop unavailable during prerender, storage blocked) leaves <see cref="Current"/> null and never
|
||||||
|
/// throws.
|
||||||
|
/// </summary>
|
||||||
|
ValueTask EnsureLoadedAsync();
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
using DeepDrftModels.DTOs;
|
using DeepDrftModels.DTOs;
|
||||||
using DeepDrftModels.Enums;
|
using DeepDrftModels.Enums;
|
||||||
using Microsoft.AspNetCore.Components;
|
using Microsoft.AspNetCore.Components;
|
||||||
@@ -14,7 +15,8 @@ namespace DeepDrftPublic.Client.Services;
|
|||||||
/// <para>
|
/// <para>
|
||||||
/// Scoped (per-session) so the debounce memory lives for the session and resets on a fresh load, matching
|
/// Scoped (per-session) so the debounce memory lives for the session and resets on a fresh load, matching
|
||||||
/// the "feels like one act" intent: copying the same link three times in a row is one share, not three.
|
/// the "feels like one act" intent: copying the same link three times in a row is one share, not three.
|
||||||
/// The beacon send is fire-and-forget; no <c>anonId</c> is sent in wave 16.1.
|
/// The beacon send is fire-and-forget; the current <c>anonId</c> (wave 16.3) is read synchronously from
|
||||||
|
/// the warmed <see cref="IAnonIdProvider"/> cache and omitted when null.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class ShareTracker
|
public sealed class ShareTracker
|
||||||
@@ -23,13 +25,20 @@ public sealed class ShareTracker
|
|||||||
// recommendation — long enough to fold a flurry of repeat copies into one intent.
|
// recommendation — long enough to fold a flurry of repeat copies into one intent.
|
||||||
private static readonly TimeSpan DebounceWindow = TimeSpan.FromSeconds(60);
|
private static readonly TimeSpan DebounceWindow = TimeSpan.FromSeconds(60);
|
||||||
|
|
||||||
|
// Omit a null anonId from the wire payload (§2.2). Cosmetic — the API tolerates null — and does not
|
||||||
|
// change the integer enum encoding the 16.1 contract relies on.
|
||||||
|
private static readonly JsonSerializerOptions BeaconJson =
|
||||||
|
new() { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull };
|
||||||
|
|
||||||
private readonly BeaconInterop _beacon;
|
private readonly BeaconInterop _beacon;
|
||||||
|
private readonly IAnonIdProvider _anonId;
|
||||||
private readonly string _shareUrl;
|
private readonly string _shareUrl;
|
||||||
private readonly Dictionary<string, DateTimeOffset> _lastSent = new();
|
private readonly Dictionary<string, DateTimeOffset> _lastSent = new();
|
||||||
|
|
||||||
public ShareTracker(BeaconInterop beacon, NavigationManager navigation)
|
public ShareTracker(BeaconInterop beacon, IAnonIdProvider anonId, NavigationManager navigation)
|
||||||
{
|
{
|
||||||
_beacon = beacon;
|
_beacon = beacon;
|
||||||
|
_anonId = anonId;
|
||||||
_shareUrl = $"{navigation.BaseUri}api/event/share";
|
_shareUrl = $"{navigation.BaseUri}api/event/share";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,7 +70,8 @@ public sealed class ShareTracker
|
|||||||
TargetType = targetType,
|
TargetType = targetType,
|
||||||
TargetKey = targetKey,
|
TargetKey = targetKey,
|
||||||
Channel = channel,
|
Channel = channel,
|
||||||
});
|
AnonId = _anonId.Current,
|
||||||
|
}, BeaconJson);
|
||||||
|
|
||||||
// Fire-and-forget — a dropped share telemetry event is acceptable.
|
// Fire-and-forget — a dropped share telemetry event is acceptable.
|
||||||
_ = _beacon.SendAsync(_shareUrl, json);
|
_ = _beacon.SendAsync(_shareUrl, json);
|
||||||
|
|||||||
@@ -38,8 +38,11 @@ public static class Startup
|
|||||||
// Phase 16 anonymous telemetry (client side). BeaconInterop wraps sendBeacon; the play sink and
|
// Phase 16 anonymous telemetry (client side). BeaconInterop wraps sendBeacon; the play sink and
|
||||||
// share tracker fire events through it. The play tracker itself is NOT registered — the player
|
// share tracker fire events through it. The play tracker itself is NOT registered — the player
|
||||||
// is not DI-registered, so AudioPlayerProvider constructs the tracker and attaches it. ShareTracker
|
// is not DI-registered, so AudioPlayerProvider constructs the tracker and attaches it. ShareTracker
|
||||||
// is scoped so its per-(target,channel) debounce memory lives for the session.
|
// is scoped so its per-(target,channel) debounce memory lives for the session. AnonIdProvider
|
||||||
|
// (wave 16.3) caches the first-party localStorage listener id; scoped so the cache lives for the
|
||||||
|
// session, warmed when a surface goes interactive (the player provider, the share popover).
|
||||||
services.AddScoped<BeaconInterop>();
|
services.AddScoped<BeaconInterop>();
|
||||||
|
services.AddScoped<IAnonIdProvider, AnonIdProvider>();
|
||||||
services.AddScoped<IPlayEventSink, BeaconPlayEventSink>();
|
services.AddScoped<IPlayEventSink, BeaconPlayEventSink>();
|
||||||
services.AddScoped<ShareTracker>();
|
services.AddScoped<ShareTracker>();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
<script type="module">
|
<script type="module">
|
||||||
import('./js/audio/index.js');
|
import('./js/audio/index.js');
|
||||||
import('./js/telemetry/beacon.js');
|
import('./js/telemetry/beacon.js');
|
||||||
|
import('./js/telemetry/anonid.js');
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
/**
|
||||||
|
* Anonymous listener id interop (Phase 16 §3, wave 16.3, D5 Option A). Mints a random first-party GUID
|
||||||
|
* on first visit, stores it in localStorage, and reads it back thereafter — one opaque token per
|
||||||
|
* browser-install-until-cleared. No PII, no fingerprinting, no cross-site use: it is a "this browser,
|
||||||
|
* until you clear it" token the server counts distinctly to estimate unique listeners.
|
||||||
|
*
|
||||||
|
* Degrades safely: if localStorage is unavailable (private mode, blocked, partitioned third-party
|
||||||
|
* iframe) it returns null rather than throwing, and the caller simply sends no anonId. Over-counting is
|
||||||
|
* the known, accepted direction of error (§3).
|
||||||
|
*
|
||||||
|
* Exposed on window.DeepDrftAnonId; imported once in App.razor alongside the audio engine and beacon.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'deepdrft.anonId';
|
||||||
|
|
||||||
|
// crypto.randomUUID is the standard, secure source. A guarded fallback covers older/insecure-context
|
||||||
|
// browsers where it is absent — still a random opaque token, not a fingerprint.
|
||||||
|
function mint(): string {
|
||||||
|
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||||
|
return crypto.randomUUID();
|
||||||
|
}
|
||||||
|
// RFC4122-ish fallback from getRandomValues; only reached on browsers lacking randomUUID.
|
||||||
|
const bytes = new Uint8Array(16);
|
||||||
|
crypto.getRandomValues(bytes);
|
||||||
|
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
||||||
|
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
||||||
|
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
|
||||||
|
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DeepDrftAnonId = {
|
||||||
|
/**
|
||||||
|
* Read the stored anon id, minting and persisting one on first call. Returns null if localStorage
|
||||||
|
* cannot be read or written (private mode / blocked / partitioned) — telemetry then omits the id.
|
||||||
|
* Both the read and the write are guarded independently: a readable-but-unwritable store still mints
|
||||||
|
* a fresh id each call (acceptable over-count) rather than throwing.
|
||||||
|
*/
|
||||||
|
get: (): string | null => {
|
||||||
|
try {
|
||||||
|
const existing = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (existing) return existing;
|
||||||
|
|
||||||
|
const minted = mint();
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, minted);
|
||||||
|
} catch {
|
||||||
|
// Read worked, write did not — return the minted value anyway; it just won't persist.
|
||||||
|
}
|
||||||
|
return minted;
|
||||||
|
} catch {
|
||||||
|
// localStorage is entirely unavailable — send no anonId.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
DeepDrftAnonId: typeof DeepDrftAnonId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.DeepDrftAnonId = DeepDrftAnonId;
|
||||||
|
|
||||||
|
export { DeepDrftAnonId };
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using DeepDrftModels.Enums;
|
||||||
|
using DeepDrftPublic.Client.Services;
|
||||||
|
using Microsoft.AspNetCore.Components;
|
||||||
|
using Microsoft.JSInterop;
|
||||||
|
|
||||||
|
namespace DeepDrftTests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tests that the Phase 16 wave-16.3 anon id is threaded onto the beacon payloads emitted by
|
||||||
|
/// <see cref="BeaconPlayEventSink"/> and <see cref="ShareTracker"/>, and omitted when the provider has
|
||||||
|
/// no token. Both sinks serialize internally and dispatch through <c>BeaconInterop</c> → the
|
||||||
|
/// <c>DeepDrftBeacon.send(url, json)</c> JS call, so the assertions capture that JSON string off a fake
|
||||||
|
/// JS runtime and inspect the <c>anonId</c> field — the same bytes the browser would POST.
|
||||||
|
/// </summary>
|
||||||
|
[TestFixture]
|
||||||
|
public class AnonIdPayloadTests
|
||||||
|
{
|
||||||
|
// Captures the JSON body of the most recent DeepDrftBeacon.send(url, json) invocation. The beacon is
|
||||||
|
// fire-and-forget (returns bool); other interop calls (unload registration) are tolerated and ignored.
|
||||||
|
private sealed class CapturingJsRuntime : IJSRuntime
|
||||||
|
{
|
||||||
|
public string? LastJson { get; private set; }
|
||||||
|
|
||||||
|
public ValueTask<TValue> InvokeAsync<TValue>(string identifier, object?[]? args)
|
||||||
|
{
|
||||||
|
if (identifier == "DeepDrftBeacon.send" && args is { Length: 2 } && args[1] is string json)
|
||||||
|
LastJson = json;
|
||||||
|
return ValueTask.FromResult<TValue>(default!);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ValueTask<TValue> InvokeAsync<TValue>(string identifier, CancellationToken cancellationToken, object?[]? args)
|
||||||
|
=> InvokeAsync<TValue>(identifier, args);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class StubAnonIdProvider : IAnonIdProvider
|
||||||
|
{
|
||||||
|
public StubAnonIdProvider(string? current) => Current = current;
|
||||||
|
public string? Current { get; }
|
||||||
|
public ValueTask EnsureLoadedAsync() => ValueTask.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class TestNavigationManager : NavigationManager
|
||||||
|
{
|
||||||
|
public TestNavigationManager() => Initialize("https://deepdrft.test/", "https://deepdrft.test/");
|
||||||
|
protected override void NavigateToCore(string uri, bool forceLoad) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
// The sinks serialize with default (PascalCase) property names; the API binds case-insensitively, so
|
||||||
|
// the wire field is "AnonId". Match it case-insensitively here so the test asserts the value, not the
|
||||||
|
// casing convention. Returns (present, value) while the document is still alive.
|
||||||
|
private static (bool Present, string? Value) FindAnonId(string json)
|
||||||
|
{
|
||||||
|
using var doc = JsonDocument.Parse(json);
|
||||||
|
foreach (var prop in doc.RootElement.EnumerateObject())
|
||||||
|
{
|
||||||
|
if (string.Equals(prop.Name, "anonId", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return (true, prop.Value.GetString());
|
||||||
|
}
|
||||||
|
return (false, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? ReadAnonId(string json) => FindAnonId(json).Value;
|
||||||
|
|
||||||
|
private static bool HasAnonIdProperty(string json) => FindAnonId(json).Present;
|
||||||
|
|
||||||
|
// A play emitted while the provider holds a token carries that token in the payload.
|
||||||
|
[Test]
|
||||||
|
public void PlaySink_WithAnonId_IncludesItInPayload()
|
||||||
|
{
|
||||||
|
var js = new CapturingJsRuntime();
|
||||||
|
var sink = new BeaconPlayEventSink(
|
||||||
|
new BeaconInterop(js), new StubAnonIdProvider("listener-42"), new TestNavigationManager());
|
||||||
|
|
||||||
|
sink.EmitPlay("track-key", PlayBucket.Complete);
|
||||||
|
|
||||||
|
Assert.That(js.LastJson, Is.Not.Null);
|
||||||
|
Assert.That(ReadAnonId(js.LastJson!), Is.EqualTo("listener-42"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// A play emitted when the provider has no token (storage unavailable / not warmed) omits anonId
|
||||||
|
// entirely rather than sending anonId:null.
|
||||||
|
[Test]
|
||||||
|
public void PlaySink_WithoutAnonId_OmitsItFromPayload()
|
||||||
|
{
|
||||||
|
var js = new CapturingJsRuntime();
|
||||||
|
var sink = new BeaconPlayEventSink(
|
||||||
|
new BeaconInterop(js), new StubAnonIdProvider(null), new TestNavigationManager());
|
||||||
|
|
||||||
|
sink.EmitPlay("track-key", PlayBucket.Partial);
|
||||||
|
|
||||||
|
Assert.That(js.LastJson, Is.Not.Null);
|
||||||
|
Assert.That(HasAnonIdProperty(js.LastJson!), Is.False, "null anonId is omitted from the wire payload");
|
||||||
|
}
|
||||||
|
|
||||||
|
// A share recorded while the provider holds a token carries it in the payload.
|
||||||
|
[Test]
|
||||||
|
public void ShareTracker_WithAnonId_IncludesItInPayload()
|
||||||
|
{
|
||||||
|
var js = new CapturingJsRuntime();
|
||||||
|
var tracker = new ShareTracker(
|
||||||
|
new BeaconInterop(js), new StubAnonIdProvider("listener-7"), new TestNavigationManager());
|
||||||
|
|
||||||
|
tracker.RecordShare(ShareTargetType.Track, "k", ShareChannel.Link);
|
||||||
|
|
||||||
|
Assert.That(js.LastJson, Is.Not.Null);
|
||||||
|
Assert.That(ReadAnonId(js.LastJson!), Is.EqualTo("listener-7"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// A share recorded with no token omits anonId from the payload.
|
||||||
|
[Test]
|
||||||
|
public void ShareTracker_WithoutAnonId_OmitsItFromPayload()
|
||||||
|
{
|
||||||
|
var js = new CapturingJsRuntime();
|
||||||
|
var tracker = new ShareTracker(
|
||||||
|
new BeaconInterop(js), new StubAnonIdProvider(null), new TestNavigationManager());
|
||||||
|
|
||||||
|
tracker.RecordShare(ShareTargetType.Track, "k", ShareChannel.Link);
|
||||||
|
|
||||||
|
Assert.That(js.LastJson, Is.Not.Null);
|
||||||
|
Assert.That(HasAnonIdProperty(js.LastJson!), Is.False);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A JS runtime that throws on every call — models localStorage interop being unavailable (private
|
||||||
|
// mode, blocked storage, or the module not yet imported during prerender).
|
||||||
|
private sealed class ThrowingJsRuntime : IJSRuntime
|
||||||
|
{
|
||||||
|
public ValueTask<TValue> InvokeAsync<TValue>(string identifier, object?[]? args)
|
||||||
|
=> throw new InvalidOperationException("interop unavailable");
|
||||||
|
|
||||||
|
public ValueTask<TValue> InvokeAsync<TValue>(string identifier, CancellationToken cancellationToken, object?[]? args)
|
||||||
|
=> throw new InvalidOperationException("interop unavailable");
|
||||||
|
}
|
||||||
|
|
||||||
|
// The real AnonIdProvider degrades to null (no throw) when the localStorage interop is unavailable —
|
||||||
|
// the acceptance-criterion "degrades safely if localStorage is unavailable". EnsureLoadedAsync
|
||||||
|
// swallows the interop failure and leaves Current null.
|
||||||
|
[Test]
|
||||||
|
public async Task AnonIdProvider_WhenInteropUnavailable_DegradesToNullWithoutThrowing()
|
||||||
|
{
|
||||||
|
var provider = new AnonIdProvider(new ThrowingJsRuntime());
|
||||||
|
|
||||||
|
await provider.EnsureLoadedAsync(); // must not throw
|
||||||
|
|
||||||
|
Assert.That(provider.Current, Is.Null);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
using DeepDrftData.Data;
|
||||||
|
using DeepDrftData.Repositories;
|
||||||
|
using DeepDrftModels.Entities;
|
||||||
|
using DeepDrftModels.Enums;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||||
|
|
||||||
|
namespace DeepDrftTests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Storage-layer tests for the Phase 16 wave-16.3 anon-id layer (<see cref="EventRepository"/>): the
|
||||||
|
/// anon id persists to the <c>anon_id</c> column on play and share writes (and a null persists null),
|
||||||
|
/// and the all-time distinct-listener aggregation (§3 / D3) is correct site-wide, per-track, and
|
||||||
|
/// per-release (derived), with null anon ids excluded from every distinct count. Runs on the EF
|
||||||
|
/// in-memory provider like <see cref="PlayEventQueryTests"/>; the transaction-ignored warning is
|
||||||
|
/// suppressed because in-memory has no real transactions (the play write wraps append + bump in one).
|
||||||
|
/// </summary>
|
||||||
|
[TestFixture]
|
||||||
|
public class AnonIdQueryTests
|
||||||
|
{
|
||||||
|
private DeepDrftContext _context = null!;
|
||||||
|
|
||||||
|
[SetUp]
|
||||||
|
public void SetUp()
|
||||||
|
{
|
||||||
|
var options = new DbContextOptionsBuilder<DeepDrftContext>()
|
||||||
|
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
|
||||||
|
.ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning))
|
||||||
|
.Options;
|
||||||
|
_context = new DeepDrftContext(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
[TearDown]
|
||||||
|
public void TearDown() => _context.Dispose();
|
||||||
|
|
||||||
|
private EventRepository CreateRepository() => new(_context);
|
||||||
|
|
||||||
|
private async Task<(ReleaseEntity Release, TrackEntity Track)> SeedTrackAsync(string trackKey)
|
||||||
|
{
|
||||||
|
var release = new ReleaseEntity
|
||||||
|
{
|
||||||
|
EntryKey = Guid.NewGuid().ToString("N"),
|
||||||
|
Title = "R",
|
||||||
|
Artist = "A",
|
||||||
|
Medium = ReleaseMedium.Cut,
|
||||||
|
};
|
||||||
|
var track = new TrackEntity { EntryKey = trackKey, TrackName = "T", Release = release };
|
||||||
|
_context.Releases.Add(release);
|
||||||
|
_context.Tracks.Add(track);
|
||||||
|
await _context.SaveChangesAsync();
|
||||||
|
return (release, track);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Persistence of the anon id ---
|
||||||
|
|
||||||
|
// A play carrying an anon id writes it to the column.
|
||||||
|
[Test]
|
||||||
|
public async Task RecordPlayAsync_WithAnonId_PersistsIt()
|
||||||
|
{
|
||||||
|
await SeedTrackAsync("track-1");
|
||||||
|
|
||||||
|
await CreateRepository().RecordPlayAsync("track-1", PlayBucket.Complete, anonId: "anon-abc");
|
||||||
|
|
||||||
|
var ev = await _context.PlayEvents.SingleAsync();
|
||||||
|
Assert.That(ev.AnonId, Is.EqualTo("anon-abc"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// A play with no anon id (the provider returned null) persists a null column — both paths covered.
|
||||||
|
[Test]
|
||||||
|
public async Task RecordPlayAsync_WithoutAnonId_PersistsNull()
|
||||||
|
{
|
||||||
|
await SeedTrackAsync("track-1");
|
||||||
|
|
||||||
|
await CreateRepository().RecordPlayAsync("track-1", PlayBucket.Complete, anonId: null);
|
||||||
|
|
||||||
|
var ev = await _context.PlayEvents.SingleAsync();
|
||||||
|
Assert.That(ev.AnonId, Is.Null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A share carrying an anon id writes it to the column; a null share persists null.
|
||||||
|
[Test]
|
||||||
|
public async Task RecordShareAsync_PersistsAnonId()
|
||||||
|
{
|
||||||
|
var repo = CreateRepository();
|
||||||
|
await repo.RecordShareAsync(ShareTargetType.Track, "k", ShareChannel.Link, anonId: "anon-xyz");
|
||||||
|
await repo.RecordShareAsync(ShareTargetType.Track, "k", ShareChannel.Embed, anonId: null);
|
||||||
|
|
||||||
|
var withId = await _context.ShareEvents.SingleAsync(e => e.Channel == ShareChannel.Link);
|
||||||
|
var without = await _context.ShareEvents.SingleAsync(e => e.Channel == ShareChannel.Embed);
|
||||||
|
Assert.That(withId.AnonId, Is.EqualTo("anon-xyz"));
|
||||||
|
Assert.That(without.AnonId, Is.Null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Site-wide distinct listeners (§3 / D3, all-time) ---
|
||||||
|
|
||||||
|
// Distinct anon ids are counted once each; a listener who plays many times counts once.
|
||||||
|
[Test]
|
||||||
|
public async Task CountDistinctListeners_CountsEachAnonOnce()
|
||||||
|
{
|
||||||
|
await SeedTrackAsync("track-1");
|
||||||
|
var repo = CreateRepository();
|
||||||
|
await repo.RecordPlayAsync("track-1", PlayBucket.Complete, "anon-1");
|
||||||
|
await repo.RecordPlayAsync("track-1", PlayBucket.Partial, "anon-1"); // same listener, replay
|
||||||
|
await repo.RecordPlayAsync("track-1", PlayBucket.Sampled, "anon-2");
|
||||||
|
|
||||||
|
Assert.That(await repo.CountDistinctListenersAsync(), Is.EqualTo(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Null anon ids are excluded from the distinct count — an anonId-less play is not a known listener.
|
||||||
|
[Test]
|
||||||
|
public async Task CountDistinctListeners_ExcludesNullAnonIds()
|
||||||
|
{
|
||||||
|
await SeedTrackAsync("track-1");
|
||||||
|
var repo = CreateRepository();
|
||||||
|
await repo.RecordPlayAsync("track-1", PlayBucket.Complete, "anon-1");
|
||||||
|
await repo.RecordPlayAsync("track-1", PlayBucket.Complete, null);
|
||||||
|
await repo.RecordPlayAsync("track-1", PlayBucket.Complete, null);
|
||||||
|
|
||||||
|
Assert.That(await repo.CountDistinctListenersAsync(), Is.EqualTo(1),
|
||||||
|
"null anonIds must not inflate the listener count");
|
||||||
|
}
|
||||||
|
|
||||||
|
// With no anon ids at all, the count is zero (not an error).
|
||||||
|
[Test]
|
||||||
|
public async Task CountDistinctListeners_AllNull_IsZero()
|
||||||
|
{
|
||||||
|
await SeedTrackAsync("track-1");
|
||||||
|
await CreateRepository().RecordPlayAsync("track-1", PlayBucket.Complete, null);
|
||||||
|
|
||||||
|
Assert.That(await CreateRepository().CountDistinctListenersAsync(), Is.EqualTo(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Per-track distinct listeners ---
|
||||||
|
|
||||||
|
// The per-track count scopes to the track key and counts distinct non-null anon ids.
|
||||||
|
[Test]
|
||||||
|
public async Task CountDistinctListenersForTrack_ScopesToTrack()
|
||||||
|
{
|
||||||
|
await SeedTrackAsync("track-1");
|
||||||
|
await SeedTrackAsync("track-2");
|
||||||
|
var repo = CreateRepository();
|
||||||
|
await repo.RecordPlayAsync("track-1", PlayBucket.Complete, "anon-1");
|
||||||
|
await repo.RecordPlayAsync("track-1", PlayBucket.Complete, "anon-2");
|
||||||
|
await repo.RecordPlayAsync("track-1", PlayBucket.Complete, null); // excluded
|
||||||
|
await repo.RecordPlayAsync("track-2", PlayBucket.Complete, "anon-3");
|
||||||
|
|
||||||
|
Assert.That(await repo.CountDistinctListenersForTrackAsync("track-1"), Is.EqualTo(2));
|
||||||
|
Assert.That(await repo.CountDistinctListenersForTrackAsync("track-2"), Is.EqualTo(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Per-release distinct listeners (derived, D4) ---
|
||||||
|
|
||||||
|
// A release's listener count is the distinct anon ids across all its tracks: a listener who heard two
|
||||||
|
// tracks of the release counts once (union, not a per-track sum), and null anon ids are excluded.
|
||||||
|
[Test]
|
||||||
|
public async Task CountDistinctListenersForRelease_DistinctAcrossTracks()
|
||||||
|
{
|
||||||
|
var release = new ReleaseEntity
|
||||||
|
{
|
||||||
|
EntryKey = Guid.NewGuid().ToString("N"), Title = "R", Artist = "A", Medium = ReleaseMedium.Cut,
|
||||||
|
};
|
||||||
|
var t1 = new TrackEntity { EntryKey = "t1", TrackName = "T1", Release = release };
|
||||||
|
var t2 = new TrackEntity { EntryKey = "t2", TrackName = "T2", Release = release };
|
||||||
|
_context.Releases.Add(release);
|
||||||
|
_context.Tracks.AddRange(t1, t2);
|
||||||
|
await _context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var repo = CreateRepository();
|
||||||
|
await repo.RecordPlayAsync("t1", PlayBucket.Complete, "anon-1");
|
||||||
|
await repo.RecordPlayAsync("t2", PlayBucket.Complete, "anon-1"); // same listener, second track
|
||||||
|
await repo.RecordPlayAsync("t2", PlayBucket.Complete, "anon-2");
|
||||||
|
await repo.RecordPlayAsync("t1", PlayBucket.Complete, null); // excluded
|
||||||
|
|
||||||
|
Assert.That(await repo.CountDistinctListenersForReleaseAsync(release.Id), Is.EqualTo(2),
|
||||||
|
"anon-1 heard two tracks but is one distinct listener of the release");
|
||||||
|
}
|
||||||
|
|
||||||
|
// A play of a track in another release does not bleed into this release's listener count.
|
||||||
|
[Test]
|
||||||
|
public async Task CountDistinctListenersForRelease_ExcludesOtherReleases()
|
||||||
|
{
|
||||||
|
var (releaseA, _) = await SeedTrackAsync("a-track");
|
||||||
|
var (releaseB, _) = await SeedTrackAsync("b-track");
|
||||||
|
var repo = CreateRepository();
|
||||||
|
await repo.RecordPlayAsync("a-track", PlayBucket.Complete, "anon-1");
|
||||||
|
await repo.RecordPlayAsync("b-track", PlayBucket.Complete, "anon-2");
|
||||||
|
|
||||||
|
Assert.That(await repo.CountDistinctListenersForReleaseAsync(releaseA.Id), Is.EqualTo(1));
|
||||||
|
Assert.That(await repo.CountDistinctListenersForReleaseAsync(releaseB.Id), Is.EqualTo(1));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,12 +34,24 @@ public class ShareTrackerTests
|
|||||||
protected override void NavigateToCore(string uri, bool forceLoad) { }
|
protected override void NavigateToCore(string uri, bool forceLoad) { }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fixed-value anon-id provider so the tracker can attach a token without JS interop. Treated as
|
||||||
|
// already warmed (Current returns the value); EnsureLoadedAsync is a no-op here.
|
||||||
|
private sealed class StubAnonIdProvider : IAnonIdProvider
|
||||||
|
{
|
||||||
|
public StubAnonIdProvider(string? current) => Current = current;
|
||||||
|
public string? Current { get; }
|
||||||
|
public ValueTask EnsureLoadedAsync() => ValueTask.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
private ShareTracker _tracker = null!;
|
private ShareTracker _tracker = null!;
|
||||||
private readonly DateTimeOffset _t0 = new(2026, 6, 19, 12, 0, 0, TimeSpan.Zero);
|
private readonly DateTimeOffset _t0 = new(2026, 6, 19, 12, 0, 0, TimeSpan.Zero);
|
||||||
|
|
||||||
[SetUp]
|
[SetUp]
|
||||||
public void SetUp()
|
public void SetUp()
|
||||||
=> _tracker = new ShareTracker(new BeaconInterop(new NoopJsRuntime()), new TestNavigationManager());
|
=> _tracker = new ShareTracker(
|
||||||
|
new BeaconInterop(new NoopJsRuntime()),
|
||||||
|
new StubAnonIdProvider("anon-1"),
|
||||||
|
new TestNavigationManager());
|
||||||
|
|
||||||
// A copy-link records one share with channel = link.
|
// A copy-link records one share with channel = link.
|
||||||
[Test]
|
[Test]
|
||||||
|
|||||||
Reference in New Issue
Block a user