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:
daniel-c-harvey
2026-06-19 14:37:55 -04:00
parent ebbaa3f84f
commit c084efa78e
16 changed files with 680 additions and 12 deletions
@@ -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>
public async Task RecordShareAsync(
ShareTargetType targetType, string targetKey, ShareChannel channel, string? anonId,