be1a55fd37
Add TotalPlays + UniqueListeners to HomeStatsDto, composed at StatsController from IEventService (no migration). Card reads via existing persistent-state-bridged round-trip.
176 lines
7.9 KiB
C#
176 lines
7.9 KiB
C#
using DeepDrftData.Data;
|
|
using DeepDrftModels.Entities;
|
|
using DeepDrftModels.Enums;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
namespace DeepDrftData.Repositories;
|
|
|
|
/// <summary>
|
|
/// Data access for the Phase 16 anonymous telemetry tables (all SQL — the FileDatabase vault is not
|
|
/// involved). Owns the append-only writes to <c>play_event</c> / <c>share_event</c> and the
|
|
/// incremental-on-write bump of the <c>play_counter</c> rollup (D6). Server-side release resolution
|
|
/// (§2.3 / D4) lives here: a play event carries only the track key, and this repository joins
|
|
/// track→release at write time and stamps the release id on the row.
|
|
///
|
|
/// <para>
|
|
/// Unlike <see cref="TrackRepository"/> these entities are not <c>BaseEntity</c>/<c>IEntity</c> (no
|
|
/// soft-delete lifecycle), so this is a plain context-backed repository rather than an extension of the
|
|
/// BlazorBlocks <c>Repository<></c> base. It holds the same scoped <see cref="DeepDrftContext"/>
|
|
/// the rest of the SQL layer uses, never a service locator.
|
|
/// </para>
|
|
/// </summary>
|
|
public class EventRepository
|
|
{
|
|
private readonly DeepDrftContext _context;
|
|
|
|
public EventRepository(DeepDrftContext context)
|
|
{
|
|
_context = context;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Append one play event and bump the track's counter in a single transaction (D6). The release id
|
|
/// is resolved here from the track key (§2.3 / D4): a live track contributes its release id (null
|
|
/// for a loose track); an unknown key records the event with a null release and no counter bump
|
|
/// (there is no track to roll up against). Returns true when the event was written.
|
|
/// </summary>
|
|
public async Task<bool> RecordPlayAsync(
|
|
string trackEntryKey, PlayBucket bucket, string? anonId, CancellationToken ct = default)
|
|
{
|
|
// Resolve the track→release link server-side. Soft-deleted tracks resolve to null so a play of
|
|
// a since-removed track still logs (with no counter bump) rather than throwing.
|
|
var track = await _context.Tracks
|
|
.Where(t => t.EntryKey == trackEntryKey && !t.IsDeleted)
|
|
.Select(t => new { t.Id, t.ReleaseId })
|
|
.FirstOrDefaultAsync(ct);
|
|
|
|
// The append and the counter bump must commit together — wrap them in one transaction so a
|
|
// counter that drifts from the log is impossible. Reuse an ambient transaction if the caller
|
|
// already opened one.
|
|
var ownsTransaction = _context.Database.CurrentTransaction is null;
|
|
var transaction = ownsTransaction
|
|
? await _context.Database.BeginTransactionAsync(ct)
|
|
: null;
|
|
try
|
|
{
|
|
_context.PlayEvents.Add(new PlayEvent
|
|
{
|
|
TrackEntryKey = trackEntryKey,
|
|
ReleaseId = track?.ReleaseId,
|
|
Bucket = bucket,
|
|
AnonId = anonId,
|
|
CreatedAt = DateTime.UtcNow,
|
|
});
|
|
|
|
if (track is not null)
|
|
await BumpCounterAsync(track.Id, bucket, ct);
|
|
|
|
await _context.SaveChangesAsync(ct);
|
|
if (transaction is not null)
|
|
await transaction.CommitAsync(ct);
|
|
return true;
|
|
}
|
|
catch
|
|
{
|
|
if (transaction is not null)
|
|
await transaction.RollbackAsync(ct);
|
|
throw;
|
|
}
|
|
finally
|
|
{
|
|
if (transaction is not null)
|
|
await transaction.DisposeAsync();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Site-wide total plays: the sum of every counter's three bucket columns across all rows (Phase 16
|
|
/// §5). Sums the mapped columns directly rather than <see cref="PlayCounter.TotalPlays"/>, which is an
|
|
/// EF-ignored computed property and so not translatable. An empty counter table sums to 0 (the home
|
|
/// card's expected reading until the telemetry migration is applied).
|
|
/// </summary>
|
|
public Task<long> CountTotalPlaysAsync(CancellationToken ct = default)
|
|
=> _context.PlayCounters
|
|
.SumAsync(c => c.PartialCount + c.SampledCount + c.CompleteCount, ct);
|
|
|
|
/// <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,
|
|
CancellationToken ct = default)
|
|
{
|
|
_context.ShareEvents.Add(new ShareEvent
|
|
{
|
|
TargetType = targetType,
|
|
TargetKey = targetKey,
|
|
Channel = channel,
|
|
AnonId = anonId,
|
|
CreatedAt = DateTime.UtcNow,
|
|
});
|
|
await _context.SaveChangesAsync(ct);
|
|
}
|
|
|
|
// Bump the matching bucket column on the track's counter row, creating the row on first play. The
|
|
// row is added to the change tracker but not saved here — the caller's SaveChanges/commit persists
|
|
// it inside the same transaction as the event append.
|
|
//
|
|
// Race note: two concurrent first-plays of the same track can both reach this method, find no
|
|
// counter row, and both Add a new PlayCounter. The second SaveChanges will hit the unique index on
|
|
// (track_id) and throw, causing the outer transaction to roll back and the event to be dropped —
|
|
// no crash, no counter corruption. At the expected play volume this is an acceptable loss; the
|
|
// unique index is the integrity backstop.
|
|
private async Task BumpCounterAsync(long trackId, PlayBucket bucket, CancellationToken ct)
|
|
{
|
|
var counter = await _context.PlayCounters.FirstOrDefaultAsync(c => c.TrackId == trackId, ct);
|
|
if (counter is null)
|
|
{
|
|
counter = new PlayCounter { TrackId = trackId };
|
|
_context.PlayCounters.Add(counter);
|
|
}
|
|
|
|
switch (bucket)
|
|
{
|
|
case PlayBucket.Partial: counter.PartialCount++; break;
|
|
case PlayBucket.Sampled: counter.SampledCount++; break;
|
|
case PlayBucket.Complete: counter.CompleteCount++; break;
|
|
}
|
|
}
|
|
}
|