Merge p16-w5-t1-plays-card into dev (Phase 16 Wave 16.5: home Plays-card live)

This commit is contained in:
daniel-c-harvey
2026-06-19 15:31:37 -04:00
7 changed files with 123 additions and 13 deletions
+30 -7
View File
@@ -8,29 +8,52 @@ namespace DeepDrftAPI.Controllers;
public class StatsController : ControllerBase
{
private readonly ITrackService _sqlTrackService;
private readonly IEventService _eventService;
private readonly ILogger<StatsController> _logger;
public StatsController(ITrackService sqlTrackService, ILogger<StatsController> logger)
public StatsController(
ITrackService sqlTrackService, IEventService eventService, ILogger<StatsController> logger)
{
_sqlTrackService = sqlTrackService;
_eventService = eventService;
_logger = logger;
}
// GET api/stats/home (unauthenticated)
// Aggregate figures behind the public home hero stat row — one read for all three cards. Same auth
// posture as the other public browse reads (GET api/track/page). The aggregation lives in the SQL
// service/repository; this controller stays a thin HTTP boundary.
// posture as the other public browse reads (GET api/track/page). The figures span two domains:
// the track-domain aggregation (Cuts/Mixes cards) lives in the SQL track service; the play-domain
// figures (Phase 16 Plays card — total plays + unique listeners) live in the event service. This
// controller is the thin composition seam that assembles both into one HomeStatsDto — neither
// domain reaches into the other's tables. Play/listener figures are best-effort: a telemetry read
// failure (or the not-yet-applied migration) leaves them at zero rather than failing the whole card.
[HttpGet("home")]
public async Task<ActionResult> GetHome(CancellationToken ct = default)
{
var result = await _sqlTrackService.GetHomeStats(ct);
if (!result.Success || result.Value is null)
var trackResult = await _sqlTrackService.GetHomeStats(ct);
if (!trackResult.Success || trackResult.Value is null)
{
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
var error = trackResult.Messages.FirstOrDefault()?.Message ?? "Unknown error";
_logger.LogError("GetHome stats failed: {Error}", error);
return StatusCode(500, "Failed to load stats");
}
return Ok(result.Value);
var stats = trackResult.Value;
var playsResult = await _eventService.GetTotalPlayCount(ct);
if (playsResult is { Success: true })
stats.TotalPlays = playsResult.Value;
else
_logger.LogWarning("GetHome total-plays read failed; Plays card falls back to 0: {Error}",
playsResult.Messages.FirstOrDefault()?.Message);
var listenersResult = await _eventService.GetDistinctListenerCount(ct);
if (listenersResult is { Success: true })
stats.UniqueListeners = listenersResult.Value;
else
_logger.LogWarning("GetHome unique-listeners read failed; secondary line falls back to 0: {Error}",
listenersResult.Messages.FirstOrDefault()?.Message);
return Ok(stats);
}
}
+14
View File
@@ -54,6 +54,20 @@ public class EventManager : IEventService
}
}
public async Task<ResultContainer<long>> GetTotalPlayCount(CancellationToken cancellationToken = default)
{
try
{
var count = await _repository.CountTotalPlaysAsync(cancellationToken);
return ResultContainer<long>.CreatePassResult(count);
}
catch (Exception e)
{
_logger.LogError(e, "Failed to count total plays");
return ResultContainer<long>.CreateFailResult(e.Message);
}
}
public async Task<ResultContainer<int>> GetDistinctListenerCount(CancellationToken cancellationToken = default)
{
try
+7
View File
@@ -21,6 +21,13 @@ public interface IEventService
/// <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);
/// <summary>
/// Site-wide total play count (Phase 16 §5 — all-time): the sum of every <c>play_counter</c> row's
/// three bucket columns. Zero until the telemetry migration is applied. The home Plays card's primary
/// figure; the controller composes it onto <c>HomeStatsDto</c> alongside the track-domain figures.
/// </summary>
Task<ResultContainer<long>> GetTotalPlayCount(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
@@ -83,6 +83,16 @@ public class EventRepository
}
}
/// <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
+16 -2
View File
@@ -4,8 +4,8 @@ namespace DeepDrftModels.DTOs;
/// <summary>
/// Aggregate figures behind the public home hero stat row (NowPlayingStats). A single read returns
/// everything the three cards need so the client makes one round-trip. All counts exclude soft-deleted
/// rows. The Plays card is a static placeholder and has no field here.
/// everything the three cards need so the client makes one round-trip. The track-domain counts exclude
/// soft-deleted rows; the play-domain figures (Phase 16) come from the event domain.
/// </summary>
public class HomeStatsDto
{
@@ -27,6 +27,20 @@ public class HomeStatsDto
/// duration (not yet backfilled) contribute 0. The Mixes card's secondary figure, rendered hh:mm.
/// </summary>
public double MixRuntimeSeconds { get; set; }
/// <summary>
/// Site-wide total plays across all tracks — the sum of every play_counter's bucket columns
/// (partial + sampled + complete), all-time (Phase 16 §5). The Plays card's primary odometer figure.
/// Reads zero until the play-telemetry migration is applied; that is expected, not an error.
/// </summary>
public long TotalPlays { get; set; }
/// <summary>
/// Site-wide distinct anonymous listeners — distinct non-null anon_id across all play events,
/// all-time (Phase 16 §3 / D7). The Plays card's secondary line ("N listeners"). Over-counts by
/// design (one token per browser-install, honestly labelled "listeners").
/// </summary>
public int UniqueListeners { get; set; }
}
/// <summary>One row of the Cut release-type breakdown: a ReleaseType and how many Cut releases have it.</summary>
@@ -29,11 +29,14 @@
<div class="hero-stat-sub">@RuntimeFormat.ToHoursMinutes(_stats.MixRuntimeSeconds) runtime</div>
</div>
@* Plays — static placeholder (real play/share tracking is a future phase). Odometer treatment over
the existing card style; copy is placeholder pending sign-off. *@
@* Plays — live site-wide play total in the odometer (the "90s visitor counter" aesthetic is the
intended treatment). Secondary line is unique anonymous listeners (Phase 16 D7). Both read from
the same HomeStatsDto round-trip the other two cards use — no extra fetch. Reads zero until the
play-telemetry migration is applied. *@
<div class="hero-stat">
<div class="hero-stat-num hero-stat-odometer">XXX</div>
<div class="hero-stat-label">Plays (Coming Soon)</div>
<div class="hero-stat-num hero-stat-odometer">@_stats.TotalPlays</div>
<div class="hero-stat-label">Plays</div>
<div class="hero-stat-sub">@_stats.UniqueListeners listeners</div>
</div>
</div>
+39
View File
@@ -159,6 +159,45 @@ public class PlayEventQueryTests
Assert.That(await _context.PlayCounters.AnyAsync(c => c.TrackId == track.Id), Is.False);
}
// --- Site-wide total plays (§5 — the home Plays card's primary figure) ---
// An empty counter table sums to zero rather than throwing — the card's reading until the
// telemetry migration is applied and the first play lands.
[Test]
public async Task CountTotalPlaysAsync_NoCounters_IsZero()
{
Assert.That(await CreateRepository().CountTotalPlaysAsync(), Is.EqualTo(0L));
}
// Total plays sums all three bucket columns of a single track's counter.
[Test]
public async Task CountTotalPlaysAsync_SumsAllBucketsOfOneCounter()
{
await SeedTrackAsync("track-1");
var repo = CreateRepository();
await repo.RecordPlayAsync("track-1", PlayBucket.Partial, null);
await repo.RecordPlayAsync("track-1", PlayBucket.Sampled, null);
await repo.RecordPlayAsync("track-1", PlayBucket.Sampled, null);
await repo.RecordPlayAsync("track-1", PlayBucket.Complete, null);
Assert.That(await repo.CountTotalPlaysAsync(), Is.EqualTo(4L));
}
// Total plays is site-wide: it sums across every track's counter, not one track's.
[Test]
public async Task CountTotalPlaysAsync_SumsAcrossAllTracks()
{
await SeedTrackAsync("track-1");
await SeedTrackAsync("track-2");
var repo = CreateRepository();
await repo.RecordPlayAsync("track-1", PlayBucket.Complete, null);
await repo.RecordPlayAsync("track-1", PlayBucket.Partial, null);
await repo.RecordPlayAsync("track-2", PlayBucket.Sampled, null);
Assert.That(await repo.CountTotalPlaysAsync(), Is.EqualTo(3L),
"site-wide total spans every track's counter");
}
// A share append writes one row with the target, channel, and a null anonId.
[Test]
public async Task RecordShareAsync_AppendsRow()