using DeepDrftData.Data; using DeepDrftModels.Entities; using DeepDrftModels.Enums; using Microsoft.EntityFrameworkCore; namespace DeepDrftData.Repositories; /// /// Data access for the Phase 16 anonymous telemetry tables (all SQL — the FileDatabase vault is not /// involved). Owns the append-only writes to play_event / share_event and the /// incremental-on-write bump of the play_counter 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. /// /// /// Unlike these entities are not BaseEntity/IEntity (no /// soft-delete lifecycle), so this is a plain context-backed repository rather than an extension of the /// BlazorBlocks Repository<> base. It holds the same scoped /// the rest of the SQL layer uses, never a service locator. /// /// public class EventRepository { private readonly DeepDrftContext _context; public EventRepository(DeepDrftContext context) { _context = context; } /// /// 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. /// public async Task 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(); } } /// /// 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. /// public Task CountDistinctListenersAsync(CancellationToken ct = default) => _context.PlayEvents .Where(e => e.AnonId != null) .Select(e => e.AnonId) .Distinct() .CountAsync(ct); /// /// 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 . /// public Task CountDistinctListenersForTrackAsync(string trackEntryKey, CancellationToken ct = default) => _context.PlayEvents .Where(e => e.TrackEntryKey == trackEntryKey && e.AnonId != null) .Select(e => e.AnonId) .Distinct() .CountAsync(ct); /// /// 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 anon_id filtered by /// release_id 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). /// public Task CountDistinctListenersForReleaseAsync(long releaseId, CancellationToken ct = default) => _context.PlayEvents .Where(e => e.ReleaseId == releaseId && e.AnonId != null) .Select(e => e.AnonId) .Distinct() .CountAsync(ct); /// Append one share event. No rollup table for shares in wave 16.1 — a plain insert. 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; } } }