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 public class StatsController : ControllerBase
{ {
private readonly ITrackService _sqlTrackService; private readonly ITrackService _sqlTrackService;
private readonly IEventService _eventService;
private readonly ILogger<StatsController> _logger; private readonly ILogger<StatsController> _logger;
public StatsController(ITrackService sqlTrackService, ILogger<StatsController> logger) public StatsController(
ITrackService sqlTrackService, IEventService eventService, ILogger<StatsController> logger)
{ {
_sqlTrackService = sqlTrackService; _sqlTrackService = sqlTrackService;
_eventService = eventService;
_logger = logger; _logger = logger;
} }
// GET api/stats/home (unauthenticated) // GET api/stats/home (unauthenticated)
// Aggregate figures behind the public home hero stat row — one read for all three cards. Same auth // 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 // posture as the other public browse reads (GET api/track/page). The figures span two domains:
// service/repository; this controller stays a thin HTTP boundary. // 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")] [HttpGet("home")]
public async Task<ActionResult> GetHome(CancellationToken ct = default) public async Task<ActionResult> GetHome(CancellationToken ct = default)
{ {
var result = await _sqlTrackService.GetHomeStats(ct); var trackResult = await _sqlTrackService.GetHomeStats(ct);
if (!result.Success || result.Value is null) 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); _logger.LogError("GetHome stats failed: {Error}", error);
return StatusCode(500, "Failed to load stats"); 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) public async Task<ResultContainer<int>> GetDistinctListenerCount(CancellationToken cancellationToken = default)
{ {
try 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> /// <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 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> /// <summary>
/// Site-wide distinct-listener count (Phase 16 §3, D3 — all-time): distinct non-null <c>anon_id</c> /// 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 /// 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> /// <summary>
/// Count distinct non-null anon ids across every play event (Phase 16 §3 / §4.2 — the all-time /// 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 /// 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> /// <summary>
/// Aggregate figures behind the public home hero stat row (NowPlayingStats). A single read returns /// 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 /// everything the three cards need so the client makes one round-trip. The track-domain counts exclude
/// rows. The Plays card is a static placeholder and has no field here. /// soft-deleted rows; the play-domain figures (Phase 16) come from the event domain.
/// </summary> /// </summary>
public class HomeStatsDto 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. /// duration (not yet backfilled) contribute 0. The Mixes card's secondary figure, rendered hh:mm.
/// </summary> /// </summary>
public double MixRuntimeSeconds { get; set; } 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> /// <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 class="hero-stat-sub">@RuntimeFormat.ToHoursMinutes(_stats.MixRuntimeSeconds) runtime</div>
</div> </div>
@* Plays — static placeholder (real play/share tracking is a future phase). Odometer treatment over @* Plays — live site-wide play total in the odometer (the "90s visitor counter" aesthetic is the
the existing card style; copy is placeholder pending sign-off. *@ 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">
<div class="hero-stat-num hero-stat-odometer">XXX</div> <div class="hero-stat-num hero-stat-odometer">@_stats.TotalPlays</div>
<div class="hero-stat-label">Plays (Coming Soon)</div> <div class="hero-stat-label">Plays</div>
<div class="hero-stat-sub">@_stats.UniqueListeners listeners</div>
</div> </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); 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. // A share append writes one row with the target, channel, and a null anonId.
[Test] [Test]
public async Task RecordShareAsync_AppendsRow() public async Task RecordShareAsync_AppendsRow()