diff --git a/DeepDrftAPI/Controllers/EventController.cs b/DeepDrftAPI/Controllers/EventController.cs
new file mode 100644
index 0000000..f403081
--- /dev/null
+++ b/DeepDrftAPI/Controllers/EventController.cs
@@ -0,0 +1,82 @@
+using DeepDrftData;
+using DeepDrftModels.DTOs;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.RateLimiting;
+
+namespace DeepDrftAPI.Controllers;
+
+///
+/// Anonymous play/share telemetry intake (Phase 16 §2.2 / §4.3). Unauthenticated — same posture as the
+/// public reads — but IP rate-limited (the "events" limiter, registered in Program.cs) and payload-
+/// validated to make casual inflation annoying (§2.5). Both endpoints return 202 Accepted: these
+/// are fire-and-forget telemetry, not transactions, and the client (a sendBeacon) never reads the
+/// response. The release dimension on a play is resolved server-side from the track key (§2.3 / D4).
+/// The controller is a thin HTTP boundary; all write logic lives in .
+///
+[ApiController]
+[Route("api/event")]
+[EnableRateLimiting("events")]
+public class EventController : ControllerBase
+{
+ // Reject oversized bodies before deserialization — a coarse abuse guard (§2.5). The legitimate
+ // payloads are a track key + an enum, well under 1 KB.
+ private const int MaxBodyBytes = 1024;
+
+ private readonly IEventService _eventService;
+ private readonly ILogger _logger;
+
+ public EventController(IEventService eventService, ILogger logger)
+ {
+ _eventService = eventService;
+ _logger = logger;
+ }
+
+ // POST api/event/play (unauthenticated, rate-limited)
+ [HttpPost("play")]
+ [RequestSizeLimit(MaxBodyBytes)]
+ public async Task RecordPlay([FromBody] PlayEventDto payload, CancellationToken ct = default)
+ {
+ // Reject a missing track key and an out-of-range bucket (§2.5). [ApiController] model binding
+ // already 400s a malformed/oversized body and an undefined enum value, but the explicit guards
+ // keep the contract obvious and cover the empty-string key the model binder lets through.
+ if (string.IsNullOrWhiteSpace(payload.TrackEntryKey))
+ return BadRequest("trackEntryKey is required");
+ if (!Enum.IsDefined(payload.Bucket))
+ return BadRequest("bucket is invalid");
+
+ // Wave 16.1 writes no anonId — defend the substrate by dropping any the client sends early.
+ var result = await _eventService.RecordPlay(payload.TrackEntryKey, payload.Bucket, anonId: null, ct);
+ if (!result.Success)
+ {
+ // A telemetry failure must never surface to the listener as an error they can act on, but
+ // we still log it and answer 5xx so a monitor can see the substrate is unhealthy. The
+ // beacon ignores the status either way.
+ _logger.LogWarning("RecordPlay failed: {Error}", result.Messages.FirstOrDefault()?.Message);
+ return StatusCode(500);
+ }
+
+ return Accepted();
+ }
+
+ // POST api/event/share (unauthenticated, rate-limited)
+ [HttpPost("share")]
+ [RequestSizeLimit(MaxBodyBytes)]
+ public async Task RecordShare([FromBody] ShareEventDto payload, CancellationToken ct = default)
+ {
+ if (string.IsNullOrWhiteSpace(payload.TargetKey))
+ return BadRequest("targetKey is required");
+ if (!Enum.IsDefined(payload.TargetType))
+ return BadRequest("targetType is invalid");
+ if (!Enum.IsDefined(payload.Channel))
+ return BadRequest("channel is invalid");
+
+ var result = await _eventService.RecordShare(payload.TargetType, payload.TargetKey, payload.Channel, anonId: null, ct);
+ if (!result.Success)
+ {
+ _logger.LogWarning("RecordShare failed: {Error}", result.Messages.FirstOrDefault()?.Message);
+ return StatusCode(500);
+ }
+
+ return Accepted();
+ }
+}
diff --git a/DeepDrftAPI/Program.cs b/DeepDrftAPI/Program.cs
index c6e1704..2f6c936 100644
--- a/DeepDrftAPI/Program.cs
+++ b/DeepDrftAPI/Program.cs
@@ -8,8 +8,10 @@ using DeepDrftData;
using DeepDrftData.Data;
using DeepDrftData.Repositories;
using Microsoft.AspNetCore.HttpOverrides;
+using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore;
using NetBlocks.Utilities.Environment;
+using System.Threading.RateLimiting;
// Required credential files — must exist before the app will start.
// Production secrets stay gitignored; the *.example.json templates at the project root show the shape.
@@ -64,6 +66,14 @@ builder.Services
.AddScoped(sp => sp.GetRequiredService());
builder.Services.AddScoped();
+// Phase 16 anonymous telemetry — append-only event logs + incremental play-counter rollup (all SQL).
+// EventManager is the IEventService boundary; EventRepository owns the EF writes and the
+// release-resolution + counter-bump transaction.
+builder.Services
+ .AddScoped()
+ .AddScoped()
+ .AddScoped(sp => sp.GetRequiredService());
+
// Release domain — medium-aware read projection + satellite metadata writes. ReleaseManager is the
// IReleaseService implementation; UnifiedReleaseService orchestrates the vault + SQL satellite writes.
builder.Services
@@ -118,6 +128,25 @@ builder.Services.Configure(options =>
options.KnownProxies.Clear();
});
+// Per-IP rate limiting for the anonymous telemetry intake (Phase 16 §2.5). Coarse and stateless —
+// a fixed window keyed by the (forwarded) remote IP. The substrate sits behind nginx, so the real
+// client IP is the X-Forwarded-For value UseForwardedHeaders resolves into Connection.RemoteIpAddress.
+// On limit, reject with 429 (the beacon ignores it; this only blunts casual inflation). The 30-window
+// budget is generous for a real listening session and only bites on scripted spam.
+builder.Services.AddRateLimiter(options =>
+{
+ options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
+ options.AddPolicy("events", httpContext =>
+ RateLimitPartition.GetFixedWindowLimiter(
+ partitionKey: httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown",
+ factory: _ => new FixedWindowRateLimiterOptions
+ {
+ PermitLimit = 30,
+ Window = TimeSpan.FromMinutes(1),
+ QueueLimit = 0,
+ }));
+});
+
var app = builder.Build();
// Apply AuthBlocks EF migrations, seed system roles, seed admin user on first boot.
@@ -136,6 +165,11 @@ if (app.Environment.IsDevelopment())
app.UseCors("ContentApiPolicy");
+// Rate limiter must sit in the pipeline for the [EnableRateLimiting("events")] attribute on
+// EventController to take effect. Only the telemetry endpoints carry the policy; everything else is
+// unaffected (no global limiter is set).
+app.UseRateLimiter();
+
// ApiKey middleware only enforces on endpoints tagged [ApiKeyAuthorize] (the track surface); it
// passes all other endpoints through. JWT auth/authorization gate the AuthBlocks endpoints, which
// carry no [ApiKeyAuthorize] metadata — the two schemes are orthogonal and do not interfere.
diff --git a/DeepDrftData/Data/Configurations/PlayCounterConfiguration.cs b/DeepDrftData/Data/Configurations/PlayCounterConfiguration.cs
new file mode 100644
index 0000000..d8394bc
--- /dev/null
+++ b/DeepDrftData/Data/Configurations/PlayCounterConfiguration.cs
@@ -0,0 +1,47 @@
+using DeepDrftModels.Entities;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+namespace DeepDrftData.Data.Configurations;
+
+///
+/// EF configuration for the play_counter rollup (Phase 16 §4.1 / D6). One row per track, unique
+/// on track_id so the incremental-on-write bump is an upsert against a single row. TotalPlays is
+/// a computed C# property (sum of the three bucket columns) and is not mapped — it is derived on read.
+///
+public class PlayCounterConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("play_counter");
+
+ builder.HasKey(e => e.Id);
+ builder.Property(e => e.Id).HasColumnName("id");
+
+ builder.Property(e => e.TrackId)
+ .IsRequired()
+ .HasColumnName("track_id");
+
+ builder.Property(e => e.PartialCount)
+ .IsRequired()
+ .HasDefaultValue(0L)
+ .HasColumnName("partial_count");
+
+ builder.Property(e => e.SampledCount)
+ .IsRequired()
+ .HasDefaultValue(0L)
+ .HasColumnName("sampled_count");
+
+ builder.Property(e => e.CompleteCount)
+ .IsRequired()
+ .HasDefaultValue(0L)
+ .HasColumnName("complete_count");
+
+ // Derived headline figure — never a column.
+ builder.Ignore(e => e.TotalPlays);
+
+ builder.HasIndex(e => e.TrackId)
+ .IsUnique()
+ .HasDatabaseName("IX_play_counter_track_id");
+ }
+}
diff --git a/DeepDrftData/Data/Configurations/PlayEventConfiguration.cs b/DeepDrftData/Data/Configurations/PlayEventConfiguration.cs
new file mode 100644
index 0000000..9108361
--- /dev/null
+++ b/DeepDrftData/Data/Configurations/PlayEventConfiguration.cs
@@ -0,0 +1,49 @@
+using DeepDrftModels.Entities;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+namespace DeepDrftData.Data.Configurations;
+
+///
+/// EF configuration for the append-only play_event log (Phase 16 §4.2). Plain entity, not a
+/// BaseEntity — no soft-delete or updated_at, just an immutable fact with a created_at stamp.
+/// Indexed on track key, release id, and anon id (the last reserved for the wave-16.3 distinct-listener
+/// query) so the aggregation paths stay cheap as the log grows.
+///
+public class PlayEventConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("play_event");
+
+ builder.HasKey(e => e.Id);
+ builder.Property(e => e.Id).HasColumnName("id");
+
+ builder.Property(e => e.TrackEntryKey)
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnName("track_entry_key");
+
+ builder.Property(e => e.ReleaseId)
+ .HasColumnName("release_id");
+
+ builder.Property(e => e.Bucket)
+ .IsRequired()
+ .HasConversion() // Store the readable bucket name, mirroring ReleaseMedium.
+ .HasMaxLength(20)
+ .HasColumnName("bucket");
+
+ // Reserved nullable token (wave 16.3). Same width as a stringified GUID.
+ builder.Property(e => e.AnonId)
+ .HasMaxLength(64)
+ .HasColumnName("anon_id");
+
+ builder.Property(e => e.CreatedAt)
+ .IsRequired()
+ .HasColumnName("created_at");
+
+ builder.HasIndex(e => e.TrackEntryKey).HasDatabaseName("IX_play_event_track_entry_key");
+ builder.HasIndex(e => e.ReleaseId).HasDatabaseName("IX_play_event_release_id");
+ builder.HasIndex(e => e.AnonId).HasDatabaseName("IX_play_event_anon_id");
+ }
+}
diff --git a/DeepDrftData/Data/Configurations/ShareEventConfiguration.cs b/DeepDrftData/Data/Configurations/ShareEventConfiguration.cs
new file mode 100644
index 0000000..517bf73
--- /dev/null
+++ b/DeepDrftData/Data/Configurations/ShareEventConfiguration.cs
@@ -0,0 +1,48 @@
+using DeepDrftModels.Entities;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+namespace DeepDrftData.Data.Configurations;
+
+///
+/// EF configuration for the append-only share_event log (Phase 16 §4.2). Plain immutable-fact
+/// entity. Indexed on the target key so per-target share tallies stay cheap.
+///
+public class ShareEventConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("share_event");
+
+ builder.HasKey(e => e.Id);
+ builder.Property(e => e.Id).HasColumnName("id");
+
+ builder.Property(e => e.TargetType)
+ .IsRequired()
+ .HasConversion()
+ .HasMaxLength(20)
+ .HasColumnName("target_type");
+
+ builder.Property(e => e.TargetKey)
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnName("target_key");
+
+ builder.Property(e => e.Channel)
+ .IsRequired()
+ .HasConversion()
+ .HasMaxLength(20)
+ .HasColumnName("channel");
+
+ builder.Property(e => e.AnonId)
+ .HasMaxLength(64)
+ .HasColumnName("anon_id");
+
+ builder.Property(e => e.CreatedAt)
+ .IsRequired()
+ .HasColumnName("created_at");
+
+ builder.HasIndex(e => e.TargetKey).HasDatabaseName("IX_share_event_target_key");
+ builder.HasIndex(e => e.AnonId).HasDatabaseName("IX_share_event_anon_id");
+ }
+}
diff --git a/DeepDrftData/Data/DeepDrftContext.cs b/DeepDrftData/Data/DeepDrftContext.cs
index 4cfd9b9..4c51fe9 100644
--- a/DeepDrftData/Data/DeepDrftContext.cs
+++ b/DeepDrftData/Data/DeepDrftContext.cs
@@ -15,6 +15,12 @@ public class DeepDrftContext : DbContext
public DbSet SessionMetadata { get; set; }
public DbSet MixMetadata { get; set; }
+ // Phase 16 anonymous telemetry: append-only event logs + incremental play rollup. All SQL — the
+ // FileDatabase vault is not involved.
+ public DbSet PlayEvents { get; set; }
+ public DbSet ShareEvents { get; set; }
+ public DbSet PlayCounters { get; set; }
+
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
@@ -23,5 +29,8 @@ public class DeepDrftContext : DbContext
modelBuilder.ApplyConfiguration(new ReleaseConfiguration());
modelBuilder.ApplyConfiguration(new SessionMetadataConfiguration());
modelBuilder.ApplyConfiguration(new MixMetadataConfiguration());
+ modelBuilder.ApplyConfiguration(new PlayEventConfiguration());
+ modelBuilder.ApplyConfiguration(new ShareEventConfiguration());
+ modelBuilder.ApplyConfiguration(new PlayCounterConfiguration());
}
}
diff --git a/DeepDrftData/EventManager.cs b/DeepDrftData/EventManager.cs
new file mode 100644
index 0000000..5753858
--- /dev/null
+++ b/DeepDrftData/EventManager.cs
@@ -0,0 +1,56 @@
+using DeepDrftData.Repositories;
+using DeepDrftModels.Enums;
+using Microsoft.Extensions.Logging;
+using NetBlocks.Models;
+
+namespace DeepDrftData;
+
+///
+/// implementation over . The layer boundary
+/// matches the rest of DeepDrftData: the repository owns the EF constructs and the write transaction;
+/// this service catches at the boundary and returns a NetBlocks . Telemetry is
+/// best-effort by design (§2.2) — a failed write is logged and surfaced as a fail result, never thrown
+/// at the caller, so a telemetry hiccup can never reach a listener.
+///
+public class EventManager : IEventService
+{
+ private readonly EventRepository _repository;
+ private readonly ILogger _logger;
+
+ public EventManager(EventRepository repository, ILogger logger)
+ {
+ _repository = repository;
+ _logger = logger;
+ }
+
+ public async Task RecordPlay(
+ string trackEntryKey, PlayBucket bucket, string? anonId = null, CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ await _repository.RecordPlayAsync(trackEntryKey, bucket, anonId, cancellationToken);
+ return Result.CreatePassResult();
+ }
+ catch (Exception e)
+ {
+ _logger.LogError(e, "Failed to record play event for track {TrackEntryKey}", trackEntryKey);
+ return Result.CreateFailResult(e.Message);
+ }
+ }
+
+ public async Task RecordShare(
+ ShareTargetType targetType, string targetKey, ShareChannel channel, string? anonId = null,
+ CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ await _repository.RecordShareAsync(targetType, targetKey, channel, anonId, cancellationToken);
+ return Result.CreatePassResult();
+ }
+ catch (Exception e)
+ {
+ _logger.LogError(e, "Failed to record share event for {TargetType} {TargetKey}", targetType, targetKey);
+ return Result.CreateFailResult(e.Message);
+ }
+ }
+}
diff --git a/DeepDrftData/IEventService.cs b/DeepDrftData/IEventService.cs
new file mode 100644
index 0000000..42a963b
--- /dev/null
+++ b/DeepDrftData/IEventService.cs
@@ -0,0 +1,23 @@
+using DeepDrftModels.Enums;
+using NetBlocks.Models;
+
+namespace DeepDrftData;
+
+///
+/// SQL-side anonymous telemetry service (Phase 16). Records play and share events to the append-only
+/// logs and maintains the incremental play-counter rollup. The release dimension on a play is resolved
+/// server-side from the track key (§2.3 / D4) — callers pass only what the client cheaply knows.
+/// Returns NetBlocks at the boundary; the controller maps that to 202/4xx/5xx.
+///
+public interface IEventService
+{
+ ///
+ /// Record one play: append a play_event row (release resolved from the track key) and bump
+ /// the track's play_counter in the same transaction. A play of an unknown/removed track key
+ /// still logs (with a null release and no counter bump) rather than failing.
+ ///
+ Task RecordPlay(string trackEntryKey, PlayBucket bucket, string? anonId = null, CancellationToken cancellationToken = default);
+
+ /// Record one share: append a share_event row. Target and channel come straight from the client.
+ Task RecordShare(ShareTargetType targetType, string targetKey, ShareChannel channel, string? anonId = null, CancellationToken cancellationToken = default);
+}
diff --git a/DeepDrftData/Migrations/20260619155610_AddPlayShareTelemetry.Designer.cs b/DeepDrftData/Migrations/20260619155610_AddPlayShareTelemetry.Designer.cs
new file mode 100644
index 0000000..8332294
--- /dev/null
+++ b/DeepDrftData/Migrations/20260619155610_AddPlayShareTelemetry.Designer.cs
@@ -0,0 +1,457 @@
+//
+using System;
+using DeepDrftData.Data;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace DeepDrftData.Migrations
+{
+ [DbContext(typeof(DeepDrftContext))]
+ [Migration("20260619155610_AddPlayShareTelemetry")]
+ partial class AddPlayShareTelemetry
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "10.0.7")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("DeepDrftModels.Entities.MixMetadata", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property("IsDeleted")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("boolean")
+ .HasDefaultValue(false)
+ .HasColumnName("is_deleted");
+
+ b.Property("ReleaseId")
+ .HasColumnType("bigint")
+ .HasColumnName("release_id");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at");
+
+ b.Property("WaveformEntryKey")
+ .IsRequired()
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)")
+ .HasColumnName("waveform_entry_key");
+
+ b.HasKey("Id");
+
+ b.HasIndex("IsDeleted")
+ .HasDatabaseName("IX_mix_metadata_is_deleted");
+
+ b.HasIndex("ReleaseId")
+ .IsUnique()
+ .HasDatabaseName("IX_mix_metadata_release_id");
+
+ b.ToTable("mix_metadata", (string)null);
+ });
+
+ modelBuilder.Entity("DeepDrftModels.Entities.PlayCounter", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("CompleteCount")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint")
+ .HasDefaultValue(0L)
+ .HasColumnName("complete_count");
+
+ b.Property("PartialCount")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint")
+ .HasDefaultValue(0L)
+ .HasColumnName("partial_count");
+
+ b.Property("SampledCount")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint")
+ .HasDefaultValue(0L)
+ .HasColumnName("sampled_count");
+
+ b.Property("TrackId")
+ .HasColumnType("bigint")
+ .HasColumnName("track_id");
+
+ b.HasKey("Id");
+
+ b.HasIndex("TrackId")
+ .IsUnique()
+ .HasDatabaseName("IX_play_counter_track_id");
+
+ b.ToTable("play_counter", (string)null);
+ });
+
+ modelBuilder.Entity("DeepDrftModels.Entities.PlayEvent", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("AnonId")
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("anon_id");
+
+ b.Property("Bucket")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)")
+ .HasColumnName("bucket");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property("ReleaseId")
+ .HasColumnType("bigint")
+ .HasColumnName("release_id");
+
+ b.Property("TrackEntryKey")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)")
+ .HasColumnName("track_entry_key");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AnonId")
+ .HasDatabaseName("IX_play_event_anon_id");
+
+ b.HasIndex("ReleaseId")
+ .HasDatabaseName("IX_play_event_release_id");
+
+ b.HasIndex("TrackEntryKey")
+ .HasDatabaseName("IX_play_event_track_entry_key");
+
+ b.ToTable("play_event", (string)null);
+ });
+
+ modelBuilder.Entity("DeepDrftModels.Entities.ReleaseEntity", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("Artist")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)")
+ .HasColumnName("artist");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property("CreatedByUserId")
+ .HasColumnType("bigint")
+ .HasColumnName("created_by_user_id");
+
+ b.Property("Description")
+ .HasMaxLength(4000)
+ .HasColumnType("character varying(4000)")
+ .HasColumnName("description");
+
+ b.Property("EntryKey")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)")
+ .HasColumnName("entry_key");
+
+ b.Property("Genre")
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)")
+ .HasColumnName("genre");
+
+ b.Property("ImagePath")
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)")
+ .HasColumnName("image_path");
+
+ b.Property("IsDeleted")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("boolean")
+ .HasDefaultValue(false)
+ .HasColumnName("is_deleted");
+
+ b.Property("Medium")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)")
+ .HasDefaultValue("Cut")
+ .HasColumnName("medium");
+
+ b.Property("ReleaseDate")
+ .HasColumnType("date")
+ .HasColumnName("release_date");
+
+ b.Property("ReleaseType")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)")
+ .HasDefaultValue("Single")
+ .HasColumnName("release_type");
+
+ b.Property("Title")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)")
+ .HasColumnName("title");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at");
+
+ b.HasKey("Id");
+
+ b.HasIndex("EntryKey")
+ .IsUnique()
+ .HasDatabaseName("IX_release_entry_key");
+
+ b.HasIndex("IsDeleted")
+ .HasDatabaseName("IX_release_is_deleted");
+
+ b.HasIndex("Title", "Artist")
+ .IsUnique()
+ .HasDatabaseName("IX_release_title_artist")
+ .HasFilter("\"is_deleted\" = false");
+
+ b.ToTable("release", (string)null);
+ });
+
+ modelBuilder.Entity("DeepDrftModels.Entities.SessionMetadata", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property("HeroImageEntryKey")
+ .IsRequired()
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)")
+ .HasColumnName("hero_image_entry_key");
+
+ b.Property("IsDeleted")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("boolean")
+ .HasDefaultValue(false)
+ .HasColumnName("is_deleted");
+
+ b.Property("ReleaseId")
+ .HasColumnType("bigint")
+ .HasColumnName("release_id");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at");
+
+ b.HasKey("Id");
+
+ b.HasIndex("IsDeleted")
+ .HasDatabaseName("IX_session_metadata_is_deleted");
+
+ b.HasIndex("ReleaseId")
+ .IsUnique()
+ .HasDatabaseName("IX_session_metadata_release_id");
+
+ b.ToTable("session_metadata", (string)null);
+ });
+
+ modelBuilder.Entity("DeepDrftModels.Entities.ShareEvent", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("AnonId")
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("anon_id");
+
+ b.Property("Channel")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)")
+ .HasColumnName("channel");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property("TargetKey")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)")
+ .HasColumnName("target_key");
+
+ b.Property("TargetType")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)")
+ .HasColumnName("target_type");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AnonId")
+ .HasDatabaseName("IX_share_event_anon_id");
+
+ b.HasIndex("TargetKey")
+ .HasDatabaseName("IX_share_event_target_key");
+
+ b.ToTable("share_event", (string)null);
+ });
+
+ modelBuilder.Entity("DeepDrftModels.Entities.TrackEntity", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property("DurationSeconds")
+ .HasColumnType("double precision")
+ .HasColumnName("duration_seconds");
+
+ b.Property("EntryKey")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)")
+ .HasColumnName("entry_key");
+
+ b.Property("IsDeleted")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("boolean")
+ .HasDefaultValue(false)
+ .HasColumnName("is_deleted");
+
+ b.Property("OriginalFileName")
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)")
+ .HasColumnName("original_file_name");
+
+ b.Property("ReleaseId")
+ .HasColumnType("bigint")
+ .HasColumnName("release_id");
+
+ b.Property("TrackName")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)")
+ .HasColumnName("track_name");
+
+ b.Property("TrackNumber")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasDefaultValue(1)
+ .HasColumnName("track_number");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at");
+
+ b.HasKey("Id");
+
+ b.HasIndex("IsDeleted")
+ .HasDatabaseName("IX_track_is_deleted");
+
+ b.HasIndex("ReleaseId");
+
+ b.ToTable("track", (string)null);
+ });
+
+ modelBuilder.Entity("DeepDrftModels.Entities.MixMetadata", b =>
+ {
+ b.HasOne("DeepDrftModels.Entities.ReleaseEntity", "Release")
+ .WithOne("MixMetadata")
+ .HasForeignKey("DeepDrftModels.Entities.MixMetadata", "ReleaseId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Release");
+ });
+
+ modelBuilder.Entity("DeepDrftModels.Entities.SessionMetadata", b =>
+ {
+ b.HasOne("DeepDrftModels.Entities.ReleaseEntity", "Release")
+ .WithOne("SessionMetadata")
+ .HasForeignKey("DeepDrftModels.Entities.SessionMetadata", "ReleaseId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Release");
+ });
+
+ modelBuilder.Entity("DeepDrftModels.Entities.TrackEntity", b =>
+ {
+ b.HasOne("DeepDrftModels.Entities.ReleaseEntity", "Release")
+ .WithMany("Tracks")
+ .HasForeignKey("ReleaseId")
+ .OnDelete(DeleteBehavior.SetNull);
+
+ b.Navigation("Release");
+ });
+
+ modelBuilder.Entity("DeepDrftModels.Entities.ReleaseEntity", b =>
+ {
+ b.Navigation("MixMetadata");
+
+ b.Navigation("SessionMetadata");
+
+ b.Navigation("Tracks");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/DeepDrftData/Migrations/20260619155610_AddPlayShareTelemetry.cs b/DeepDrftData/Migrations/20260619155610_AddPlayShareTelemetry.cs
new file mode 100644
index 0000000..abcc6c8
--- /dev/null
+++ b/DeepDrftData/Migrations/20260619155610_AddPlayShareTelemetry.cs
@@ -0,0 +1,110 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace DeepDrftData.Migrations
+{
+ ///
+ public partial class AddPlayShareTelemetry : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.CreateTable(
+ name: "play_counter",
+ columns: table => new
+ {
+ id = table.Column(type: "bigint", nullable: false)
+ .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
+ track_id = table.Column(type: "bigint", nullable: false),
+ partial_count = table.Column(type: "bigint", nullable: false, defaultValue: 0L),
+ sampled_count = table.Column(type: "bigint", nullable: false, defaultValue: 0L),
+ complete_count = table.Column(type: "bigint", nullable: false, defaultValue: 0L)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_play_counter", x => x.id);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "play_event",
+ columns: table => new
+ {
+ id = table.Column(type: "bigint", nullable: false)
+ .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
+ track_entry_key = table.Column(type: "character varying(100)", maxLength: 100, nullable: false),
+ release_id = table.Column(type: "bigint", nullable: true),
+ bucket = table.Column(type: "character varying(20)", maxLength: 20, nullable: false),
+ anon_id = table.Column(type: "character varying(64)", maxLength: 64, nullable: true),
+ created_at = table.Column(type: "timestamp with time zone", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_play_event", x => x.id);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "share_event",
+ columns: table => new
+ {
+ id = table.Column(type: "bigint", nullable: false)
+ .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
+ target_type = table.Column(type: "character varying(20)", maxLength: 20, nullable: false),
+ target_key = table.Column(type: "character varying(100)", maxLength: 100, nullable: false),
+ channel = table.Column(type: "character varying(20)", maxLength: 20, nullable: false),
+ anon_id = table.Column(type: "character varying(64)", maxLength: 64, nullable: true),
+ created_at = table.Column(type: "timestamp with time zone", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_share_event", x => x.id);
+ });
+
+ migrationBuilder.CreateIndex(
+ name: "IX_play_counter_track_id",
+ table: "play_counter",
+ column: "track_id",
+ unique: true);
+
+ migrationBuilder.CreateIndex(
+ name: "IX_play_event_anon_id",
+ table: "play_event",
+ column: "anon_id");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_play_event_release_id",
+ table: "play_event",
+ column: "release_id");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_play_event_track_entry_key",
+ table: "play_event",
+ column: "track_entry_key");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_share_event_anon_id",
+ table: "share_event",
+ column: "anon_id");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_share_event_target_key",
+ table: "share_event",
+ column: "target_key");
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "play_counter");
+
+ migrationBuilder.DropTable(
+ name: "play_event");
+
+ migrationBuilder.DropTable(
+ name: "share_event");
+ }
+ }
+}
diff --git a/DeepDrftData/Migrations/DeepDrftContextModelSnapshot.cs b/DeepDrftData/Migrations/DeepDrftContextModelSnapshot.cs
index f602484..9951dce 100644
--- a/DeepDrftData/Migrations/DeepDrftContextModelSnapshot.cs
+++ b/DeepDrftData/Migrations/DeepDrftContextModelSnapshot.cs
@@ -67,6 +67,94 @@ namespace DeepDrftData.Migrations
b.ToTable("mix_metadata", (string)null);
});
+ modelBuilder.Entity("DeepDrftModels.Entities.PlayCounter", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("CompleteCount")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint")
+ .HasDefaultValue(0L)
+ .HasColumnName("complete_count");
+
+ b.Property("PartialCount")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint")
+ .HasDefaultValue(0L)
+ .HasColumnName("partial_count");
+
+ b.Property("SampledCount")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint")
+ .HasDefaultValue(0L)
+ .HasColumnName("sampled_count");
+
+ b.Property("TrackId")
+ .HasColumnType("bigint")
+ .HasColumnName("track_id");
+
+ b.HasKey("Id");
+
+ b.HasIndex("TrackId")
+ .IsUnique()
+ .HasDatabaseName("IX_play_counter_track_id");
+
+ b.ToTable("play_counter", (string)null);
+ });
+
+ modelBuilder.Entity("DeepDrftModels.Entities.PlayEvent", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("AnonId")
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("anon_id");
+
+ b.Property("Bucket")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)")
+ .HasColumnName("bucket");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property("ReleaseId")
+ .HasColumnType("bigint")
+ .HasColumnName("release_id");
+
+ b.Property("TrackEntryKey")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)")
+ .HasColumnName("track_entry_key");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AnonId")
+ .HasDatabaseName("IX_play_event_anon_id");
+
+ b.HasIndex("ReleaseId")
+ .HasDatabaseName("IX_play_event_release_id");
+
+ b.HasIndex("TrackEntryKey")
+ .HasDatabaseName("IX_play_event_track_entry_key");
+
+ b.ToTable("play_event", (string)null);
+ });
+
modelBuilder.Entity("DeepDrftModels.Entities.ReleaseEntity", b =>
{
b.Property("Id")
@@ -209,6 +297,53 @@ namespace DeepDrftData.Migrations
b.ToTable("session_metadata", (string)null);
});
+ modelBuilder.Entity("DeepDrftModels.Entities.ShareEvent", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("AnonId")
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("anon_id");
+
+ b.Property("Channel")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)")
+ .HasColumnName("channel");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property("TargetKey")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)")
+ .HasColumnName("target_key");
+
+ b.Property("TargetType")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)")
+ .HasColumnName("target_type");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AnonId")
+ .HasDatabaseName("IX_share_event_anon_id");
+
+ b.HasIndex("TargetKey")
+ .HasDatabaseName("IX_share_event_target_key");
+
+ b.ToTable("share_event", (string)null);
+ });
+
modelBuilder.Entity("DeepDrftModels.Entities.TrackEntity", b =>
{
b.Property("Id")
diff --git a/DeepDrftData/Repositories/EventRepository.cs b/DeepDrftData/Repositories/EventRepository.cs
new file mode 100644
index 0000000..e61ef99
--- /dev/null
+++ b/DeepDrftData/Repositories/EventRepository.cs
@@ -0,0 +1,127 @@
+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();
+ }
+ }
+
+ /// 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;
+ }
+ }
+}
diff --git a/DeepDrftModels/DTOs/PlayEventDto.cs b/DeepDrftModels/DTOs/PlayEventDto.cs
new file mode 100644
index 0000000..33599ff
--- /dev/null
+++ b/DeepDrftModels/DTOs/PlayEventDto.cs
@@ -0,0 +1,18 @@
+using DeepDrftModels.Enums;
+
+namespace DeepDrftModels.DTOs;
+
+///
+/// Wire payload for POST api/event/play (Phase 16 §2.2 / §4.3). The client sends only what it
+/// cheaply knows — the track key and the client-computed completion bucket; the server resolves the
+/// release. No duration or raw position is transmitted (a privacy plus — only a coarse bucket leaves
+/// the browser). is reserved for wave 16.3 and stays null in wave 16.1.
+///
+public class PlayEventDto
+{
+ public string? TrackEntryKey { get; set; }
+
+ public PlayBucket Bucket { get; set; }
+
+ public string? AnonId { get; set; }
+}
diff --git a/DeepDrftModels/DTOs/ShareEventDto.cs b/DeepDrftModels/DTOs/ShareEventDto.cs
new file mode 100644
index 0000000..3e37fd4
--- /dev/null
+++ b/DeepDrftModels/DTOs/ShareEventDto.cs
@@ -0,0 +1,19 @@
+using DeepDrftModels.Enums;
+
+namespace DeepDrftModels.DTOs;
+
+///
+/// Wire payload for POST api/event/share (Phase 16 §2.2 / §4.3). The popover knows the target
+/// and channel at the point of the action, so the payload is self-describing — no server-side resolution.
+/// is reserved for wave 16.3 and stays null in wave 16.1.
+///
+public class ShareEventDto
+{
+ public ShareTargetType TargetType { get; set; }
+
+ public string? TargetKey { get; set; }
+
+ public ShareChannel Channel { get; set; }
+
+ public string? AnonId { get; set; }
+}
diff --git a/DeepDrftModels/Entities/PlayCounter.cs b/DeepDrftModels/Entities/PlayCounter.cs
new file mode 100644
index 0000000..79a4592
--- /dev/null
+++ b/DeepDrftModels/Entities/PlayCounter.cs
@@ -0,0 +1,28 @@
+namespace DeepDrftModels.Entities;
+
+///
+/// Incremental rollup of play counts per track (Phase 16 §4.1 / D6). One row per track, bumped inside
+/// the same transaction that appends the — no background aggregation job. The
+/// home card and per-target reads sum these instead of COUNT(*)-ing the event log on every
+/// landing. Release totals are derived (D4) by summing the counters of the release's tracks,
+/// so there is no separate release-counter row — this keeps the rollup normalized at one row per track.
+///
+public class PlayCounter
+{
+ public long Id { get; set; }
+
+ /// The track these counts belong to (SQL id). Unique — one counter row per track.
+ public long TrackId { get; set; }
+
+ /// Count of plays that ended in the Partial bucket (< 30%).
+ public long PartialCount { get; set; }
+
+ /// Count of plays that ended in the Sampled bucket (30%–80%).
+ public long SampledCount { get; set; }
+
+ /// Count of plays that ended in the Complete bucket (> 80%).
+ public long CompleteCount { get; set; }
+
+ /// Total plays for the track — the sum of the three bucket counts (headline figure).
+ public long TotalPlays => PartialCount + SampledCount + CompleteCount;
+}
diff --git a/DeepDrftModels/Entities/PlayEvent.cs b/DeepDrftModels/Entities/PlayEvent.cs
new file mode 100644
index 0000000..b174711
--- /dev/null
+++ b/DeepDrftModels/Entities/PlayEvent.cs
@@ -0,0 +1,36 @@
+using DeepDrftModels.Enums;
+
+namespace DeepDrftModels.Entities;
+
+///
+/// Append-only log row for one recorded play (Phase 16 §4.2). Written once at session close, after the
+/// engagement floor is crossed; never updated or deleted. Deliberately NOT a BaseEntity: events
+/// have no soft-delete lifecycle, no UpdatedAt — they are immutable facts. The release link is
+/// resolved server-side from the track key at write time (§2.3 / D4) and stored here so release-total
+/// plays are a cheap sum over this column.
+///
+public class PlayEvent
+{
+ public long Id { get; set; }
+
+ /// The played track's vault entry key (the only target the client sends).
+ public required string TrackEntryKey { get; set; }
+
+ ///
+ /// The owning release's SQL id, resolved from at write time. Null when
+ /// the track is loose (no release) or the key did not resolve to a live track at write time.
+ ///
+ public long? ReleaseId { get; set; }
+
+ /// The completion bucket computed client-side from the high-water fraction (§1a / D1).
+ public PlayBucket Bucket { get; set; }
+
+ ///
+ /// Anonymous listener token (Phase 16 §3, wave 16.3). Reserved nullable; nothing writes it in wave
+ /// 16.1 — the client sends none and the column stays NULL. Wave 16.3 lights it up for the
+ /// distinct-listener count.
+ ///
+ public string? AnonId { get; set; }
+
+ public DateTime CreatedAt { get; set; }
+}
diff --git a/DeepDrftModels/Entities/ShareEvent.cs b/DeepDrftModels/Entities/ShareEvent.cs
new file mode 100644
index 0000000..1a23a0e
--- /dev/null
+++ b/DeepDrftModels/Entities/ShareEvent.cs
@@ -0,0 +1,34 @@
+using DeepDrftModels.Enums;
+
+namespace DeepDrftModels.Entities;
+
+///
+/// Append-only log row for one recorded share (Phase 16 §4.2). Written once per share action that
+/// survives the per-(target,channel) client debounce; never updated or deleted. Like
+/// it is deliberately NOT a BaseEntity — an immutable fact with no soft-delete lifecycle. Shares
+/// carry their target directly (the popover knows track vs. release), so no server-side resolution step.
+///
+public class ShareEvent
+{
+ public long Id { get; set; }
+
+ /// Whether the share targets a track or a release.
+ public ShareTargetType TargetType { get; set; }
+
+ ///
+ /// The shared target's key: a track's vault EntryKey or a release's public EntryKey,
+ /// selected by . Stored as the opaque key, not resolved to a SQL id — the
+ /// share metric is a simple per-target tally and needs no join in wave 16.1.
+ ///
+ public required string TargetKey { get; set; }
+
+ /// The channel the share was performed through (link vs. embed).
+ public ShareChannel Channel { get; set; }
+
+ ///
+ /// Anonymous listener token (Phase 16 §3, wave 16.3). Reserved nullable; unused in wave 16.1.
+ ///
+ public string? AnonId { get; set; }
+
+ public DateTime CreatedAt { get; set; }
+}
diff --git a/DeepDrftModels/Enums/PlayBucket.cs b/DeepDrftModels/Enums/PlayBucket.cs
new file mode 100644
index 0000000..714c049
--- /dev/null
+++ b/DeepDrftModels/Enums/PlayBucket.cs
@@ -0,0 +1,26 @@
+using System.Text.Json.Serialization;
+
+namespace DeepDrftModels.Enums;
+
+///
+/// Completion bucket for a recorded play (Phase 16 §1a / D1). The three buckets are exhaustive and
+/// non-overlapping, classified by the high-water playback fraction reached before the session closed:
+/// Partial [0, 30%), Sampled [30%, 80%], Complete (80%, 100%]. The headline
+/// "Plays" figure is the sum of all three — every started listen that crosses the engagement floor
+/// is a play; the buckets are the texture beneath it.
+///
+/// Serialized as its string name on the wire — the converter on the type makes the
+/// client to proxy to API JSON contract string-based regardless of host serializer config.
+///
+[JsonConverter(typeof(JsonStringEnumConverter))]
+public enum PlayBucket
+{
+ /// Reached < 30% of duration — a skip or a brief partial listen (still past the floor).
+ Partial,
+
+ /// Reached 30%–80% of duration — a real listen that was neither a skip nor a finish.
+ Sampled,
+
+ /// Reached > 80% of duration — effectively a finished listen.
+ Complete
+}
diff --git a/DeepDrftModels/Enums/ShareChannel.cs b/DeepDrftModels/Enums/ShareChannel.cs
new file mode 100644
index 0000000..64a6f87
--- /dev/null
+++ b/DeepDrftModels/Enums/ShareChannel.cs
@@ -0,0 +1,33 @@
+using System.Text.Json.Serialization;
+
+namespace DeepDrftModels.Enums;
+
+///
+/// The channel a share was performed through (Phase 16 §1b). Today both originate from
+/// SharePopover's clipboard actions; a future native/Web-Share button would add a channel
+/// without reshaping the metric. Serialized as its string name on the wire.
+///
+[JsonConverter(typeof(JsonStringEnumConverter))]
+public enum ShareChannel
+{
+ /// Copy-link — the canonical track or release URL placed on the clipboard.
+ Link,
+
+ /// Copy-embed — the <iframe> snippet for the single-track FramePlayer.
+ Embed
+}
+
+///
+/// What a share targets (Phase 16 §1b). Tracks and releases are both shareable; the popover knows
+/// which it is at the point of the action, so no server-side resolution is needed for shares.
+/// Serialized as its string name on the wire.
+///
+[JsonConverter(typeof(JsonStringEnumConverter))]
+public enum ShareTargetType
+{
+ /// The share targets a single track, addressed by its vault EntryKey.
+ Track,
+
+ /// The share targets a release, addressed by its public EntryKey.
+ Release
+}
diff --git a/DeepDrftPublic.Client/Controls/AudioPlayerProvider.razor.cs b/DeepDrftPublic.Client/Controls/AudioPlayerProvider.razor.cs
index e8c01fc..a105e20 100644
--- a/DeepDrftPublic.Client/Controls/AudioPlayerProvider.razor.cs
+++ b/DeepDrftPublic.Client/Controls/AudioPlayerProvider.razor.cs
@@ -10,6 +10,8 @@ public partial class AudioPlayerProvider : ComponentBase, IAsyncDisposable
[Inject] public required AudioInteropService AudioInterop { get; set; }
[Inject] public required TrackMediaClient TrackMediaClient { get; set; }
[Inject] public required ILogger Logger { get; set; }
+ [Inject] public required BeaconInterop Beacon { get; set; }
+ [Inject] public required IPlayEventSink PlayEventSink { get; set; }
private IStreamingPlayerService? _audioPlayerService;
private QueueService? _queueService;
@@ -23,7 +25,16 @@ public partial class AudioPlayerProvider : ComponentBase, IAsyncDisposable
// EnsureInitializedAsync — that path is correct because audio contexts
// require a user gesture anyway. Initializing eagerly here causes 4+
// SignalR round-trips before any content is stable.
- _audioPlayerService = new StreamingAudioPlayerService(AudioInterop, TrackMediaClient, Logger);
+ var player = new StreamingAudioPlayerService(AudioInterop, TrackMediaClient, Logger);
+
+ // Phase 16: bind the play-session tracker to the player after construction, the same way the
+ // queue binds — the player is built with `new`, not DI, so threading telemetry through its
+ // constructor would force the provider to over-resolve. The tracker owns the floor/bucket logic
+ // and emits via the injected sink (the beacon in production); the beacon also drives the
+ // page-unload close so a mid-play tab-close still records the listen. Attached on the concrete
+ // type before it is exposed through the IStreamingPlayerService field.
+ player.AttachTracker(new PlayTracker(PlayEventSink), Beacon);
+ _audioPlayerService = player;
// Provider is the SOLE owner of OnStateChanged. When the service fires,
// the provider re-renders, which cascades to its children automatically.
diff --git a/DeepDrftPublic.Client/Controls/SharePopover.razor.cs b/DeepDrftPublic.Client/Controls/SharePopover.razor.cs
index 85b24f3..c73ff50 100644
--- a/DeepDrftPublic.Client/Controls/SharePopover.razor.cs
+++ b/DeepDrftPublic.Client/Controls/SharePopover.razor.cs
@@ -1,6 +1,7 @@
using DeepDrftModels.Enums;
using DeepDrftPublic.Client.Common;
using DeepDrftPublic.Client.Helpers;
+using DeepDrftPublic.Client.Services;
using Microsoft.AspNetCore.Components;
using Microsoft.JSInterop;
@@ -28,6 +29,7 @@ public partial class SharePopover : ComponentBase, IDisposable
[Inject] public required NavigationManager Navigation { get; set; }
[Inject] public required IJSRuntime JS { get; set; }
+ [Inject] public required ShareTracker ShareTracker { get; set; }
private bool IsReleaseMode => ReleaseEntryKey is not null;
@@ -71,6 +73,14 @@ public partial class SharePopover : ComponentBase, IDisposable
{
if (await CopyToClipboard(LinkUrl))
{
+ // Record a share only after the clipboard write succeeds (§1b). Release mode targets the
+ // release EntryKey; track mode targets the track EntryKey. The tracker debounces repeat
+ // copies of the same (target, channel) into one event.
+ if (IsReleaseMode)
+ ShareTracker.RecordShare(ShareTargetType.Release, ReleaseEntryKey!, ShareChannel.Link);
+ else if (!string.IsNullOrWhiteSpace(EntryKey))
+ ShareTracker.RecordShare(ShareTargetType.Track, EntryKey, ShareChannel.Link);
+
_linkCopied = true;
await ResetAfterDelay(() => _linkCopied = false);
}
@@ -80,6 +90,11 @@ public partial class SharePopover : ComponentBase, IDisposable
{
if (await CopyToClipboard(EmbedSnippet))
{
+ // Embed is a single-track affordance only (release mode hides it), so this always targets a
+ // track with channel = embed.
+ if (!string.IsNullOrWhiteSpace(EntryKey))
+ ShareTracker.RecordShare(ShareTargetType.Track, EntryKey, ShareChannel.Embed);
+
_embedCopied = true;
await ResetAfterDelay(() => _embedCopied = false);
}
diff --git a/DeepDrftPublic.Client/Services/AudioPlayerService.cs b/DeepDrftPublic.Client/Services/AudioPlayerService.cs
index e2f8a14..778cc1d 100644
--- a/DeepDrftPublic.Client/Services/AudioPlayerService.cs
+++ b/DeepDrftPublic.Client/Services/AudioPlayerService.cs
@@ -260,6 +260,9 @@ public abstract class AudioPlayerService : IPlayerService, IAsyncDisposable
private async Task OnProgressCallback(double currentTime)
{
CurrentTime = currentTime;
+ // Telemetry hook (Phase 16 §2.1): a subclass advances the play-session high-water mark here, on
+ // the same throttled tick the UI already consumes. Base implementation is a no-op.
+ OnProgressTick(currentTime);
await NotifyStateChanged();
}
@@ -270,6 +273,10 @@ public abstract class AudioPlayerService : IPlayerService, IAsyncDisposable
IsLoaded = false;
CurrentTime = 0;
Duration = null;
+ // Telemetry hook: organic end closes the play session (the bucket reflects how far they got)
+ // BEFORE the state notification and TrackEnded fan-out, so the session that just ended is the
+ // one recorded — not whatever a queue auto-advance opens next. Base implementation is a no-op.
+ OnPlaybackEnded();
await NotifyStateChanged();
// Fire AFTER the state notification so any queue orchestrator that advances on this
@@ -279,6 +286,18 @@ public abstract class AudioPlayerService : IPlayerService, IAsyncDisposable
TrackEnded?.Invoke();
}
+ ///
+ /// Telemetry seam (Phase 16): called on each progress tick with the current playback position. The
+ /// streaming subclass overrides this to advance the play-session high-water mark. No-op in the base.
+ ///
+ protected virtual void OnProgressTick(double currentTime) { }
+
+ ///
+ /// Telemetry seam (Phase 16): called on organic end-of-stream, before fires.
+ /// The streaming subclass overrides this to close the play session. No-op in the base.
+ ///
+ protected virtual void OnPlaybackEnded() { }
+
protected async Task EnsureInitializedAsync()
{
diff --git a/DeepDrftPublic.Client/Services/BeaconInterop.cs b/DeepDrftPublic.Client/Services/BeaconInterop.cs
new file mode 100644
index 0000000..b7173a9
--- /dev/null
+++ b/DeepDrftPublic.Client/Services/BeaconInterop.cs
@@ -0,0 +1,60 @@
+using Microsoft.JSInterop;
+
+namespace DeepDrftPublic.Client.Services;
+
+///
+/// Thin C# wrapper over the window.DeepDrftBeacon TS interop (Phase 16 §2.2). Wraps the
+/// navigator.sendBeacon POST and the page-unload registration so the rest of the client never
+/// touches string identifiers directly. All calls are best-effort: a JS
+/// failure (module not yet loaded, interop unavailable during prerender) is swallowed — telemetry must
+/// never throw into the UI or the playback path.
+///
+public sealed class BeaconInterop
+{
+ private readonly IJSRuntime _js;
+
+ public BeaconInterop(IJSRuntime js)
+ {
+ _js = js;
+ }
+
+ /// Queue a fire-and-forget POST of a JSON body to the given absolute URL.
+ public async Task SendAsync(string url, string json)
+ {
+ try
+ {
+ await _js.InvokeAsync("DeepDrftBeacon.send", url, json);
+ }
+ catch
+ {
+ // Module not loaded / not interactive yet — drop the event silently.
+ }
+ }
+
+ /// Register a .NET unload callback (fires on pagehide / visibility→hidden) under a key.
+ public async Task RegisterUnloadAsync(string key, DotNetObjectReference dotNetRef, string methodName)
+ where T : class
+ {
+ try
+ {
+ await _js.InvokeVoidAsync("DeepDrftBeacon.registerUnload", key, dotNetRef, methodName);
+ }
+ catch
+ {
+ // Best-effort — without the unload handler, mid-play tab-close simply isn't recorded.
+ }
+ }
+
+ /// Detach a previously-registered unload callback.
+ public async Task UnregisterUnloadAsync(string key)
+ {
+ try
+ {
+ await _js.InvokeVoidAsync("DeepDrftBeacon.unregisterUnload", key);
+ }
+ catch
+ {
+ // Disposal best-effort.
+ }
+ }
+}
diff --git a/DeepDrftPublic.Client/Services/BeaconPlayEventSink.cs b/DeepDrftPublic.Client/Services/BeaconPlayEventSink.cs
new file mode 100644
index 0000000..fb6a333
--- /dev/null
+++ b/DeepDrftPublic.Client/Services/BeaconPlayEventSink.cs
@@ -0,0 +1,40 @@
+using System.Text.Json;
+using DeepDrftModels.DTOs;
+using DeepDrftModels.Enums;
+using Microsoft.AspNetCore.Components;
+
+namespace DeepDrftPublic.Client.Services;
+
+///
+/// Production (Phase 16 §2.2): serializes the play classification and fires
+/// it via navigator.sendBeacon to the proxied api/event/play route. Fire-and-forget by
+/// design — is synchronous (it is called from the player's close
+/// path and the unload handler, neither of which can await), so the beacon is dispatched without
+/// awaiting and its failure is irrelevant. No anonId is sent in wave 16.1.
+///
+public sealed class BeaconPlayEventSink : IPlayEventSink
+{
+ private readonly BeaconInterop _beacon;
+ private readonly string _playUrl;
+
+ public BeaconPlayEventSink(BeaconInterop beacon, NavigationManager navigation)
+ {
+ _beacon = beacon;
+ // The WASM client posts to its own host, which proxies to DeepDrftAPI. BaseUri carries a
+ // trailing slash; the route does not lead with one.
+ _playUrl = $"{navigation.BaseUri}api/event/play";
+ }
+
+ public void EmitPlay(string trackEntryKey, PlayBucket bucket)
+ {
+ var json = JsonSerializer.Serialize(new PlayEventDto
+ {
+ TrackEntryKey = trackEntryKey,
+ Bucket = bucket,
+ });
+
+ // Fire-and-forget: do not await. The beacon survives unload; the C# task may not, and we do not
+ // act on the result either way.
+ _ = _beacon.SendAsync(_playUrl, json);
+ }
+}
diff --git a/DeepDrftPublic.Client/Services/IPlayEventSink.cs b/DeepDrftPublic.Client/Services/IPlayEventSink.cs
new file mode 100644
index 0000000..ef9200b
--- /dev/null
+++ b/DeepDrftPublic.Client/Services/IPlayEventSink.cs
@@ -0,0 +1,16 @@
+using DeepDrftModels.Enums;
+
+namespace DeepDrftPublic.Client.Services;
+
+///
+/// The emit seam for the (Phase 16 §2.1). The tracker owns the session
+/// lifecycle, the engagement floor, and the bucket classification but knows nothing about transport —
+/// it hands a finished classification to a sink. The production sink fires a sendBeacon POST to
+/// api/event/play; tests substitute a fake sink to assert floor and bucket behaviour with no
+/// JS interop. This keeps the tracker's logic testable behind one seam, as the spec calls for.
+///
+public interface IPlayEventSink
+{
+ /// Emit one recorded play. Called at most once per session, only when the floor is crossed.
+ void EmitPlay(string trackEntryKey, PlayBucket bucket);
+}
diff --git a/DeepDrftPublic.Client/Services/PlayTracker.cs b/DeepDrftPublic.Client/Services/PlayTracker.cs
new file mode 100644
index 0000000..12032cd
--- /dev/null
+++ b/DeepDrftPublic.Client/Services/PlayTracker.cs
@@ -0,0 +1,129 @@
+using DeepDrftModels.Enums;
+
+namespace DeepDrftPublic.Client.Services;
+
+///
+/// Per-session play tracker (Phase 16 §2.1). Observes the player-service playback lifecycle — open on
+/// playback start, advance the high-water mark on each progress tick, close on organic end / track-switch
+/// / stop / page-unload — and emits at most one play event per session, classified into a completion
+/// bucket, but only once the engagement floor is crossed (§1d / D2).
+///
+///
+/// Deliberately free of any player, HTTP, or JS dependency: it takes an and
+/// owns only session state and the floor/classification arithmetic, so its behaviour is unit-testable
+/// against a fake sink with no interop (the spec's "testable behind one seam"). The production sink fires
+/// the beacon. Instrumented at the player-service level ONLY — never the HTTP/media client — so a
+/// seek-beyond-buffer re-fetch is the same play, not a new one (§1d).
+///
+///
+/// Not thread-safe: the WASM dispatcher is single-threaded, and every call originates there.
+///
+public sealed class PlayTracker
+{
+ // Engagement floor (§1d / D2): a listen counts only once playback reaches at least 3 seconds OR
+ // 5% of duration, whichever is SMALLER — so a sub-60s clip floors on the percentage and anything
+ // longer floors on the 3-second wall. Single tunable constant pair; one place to retune.
+ private const double FloorSeconds = 3.0;
+ private const double FloorFraction = 0.05;
+
+ // Bucket thresholds (§1a / D1): partial [0, 30%), sampled [30%, 80%], complete (80%, 100%].
+ private const double SampledThreshold = 0.30;
+ private const double CompleteThreshold = 0.80;
+
+ private readonly IPlayEventSink _sink;
+
+ private string? _trackEntryKey;
+ private double? _duration;
+ private double _highWater;
+ private bool _closed;
+
+ public PlayTracker(IPlayEventSink sink)
+ {
+ _sink = sink;
+ }
+
+ /// True while a session is open (playback started, not yet closed). Drives the unload beacon.
+ public bool HasOpenSession => _trackEntryKey is not null && !_closed;
+
+ ///
+ /// Open a session for the track whose playback just started. Supersedes any still-open session by
+ /// closing it first — a track-switch that did not route through still records the
+ /// prior listen. Duration is unknown at open and arrives later via .
+ ///
+ public void OnPlaybackStarted(string trackEntryKey)
+ {
+ if (HasOpenSession)
+ Close();
+
+ _trackEntryKey = trackEntryKey;
+ _duration = null;
+ _highWater = 0;
+ _closed = false;
+ }
+
+ ///
+ /// Record the duration once the WAV header has set it. Idempotent — only the first non-positive-guarded
+ /// value is taken, matching the player which sets Duration exactly once.
+ ///
+ public void SetDuration(double durationSeconds)
+ {
+ if (!HasOpenSession) return;
+ if (_duration is null && durationSeconds > 0)
+ _duration = durationSeconds;
+ }
+
+ ///
+ /// Advance the high-water mark from a progress tick. Monotonic — seeking backward never lowers it,
+ /// so a seek-to-end-then-back still classifies by the furthest point reached (§1d).
+ ///
+ public void OnProgress(double currentTime)
+ {
+ if (!HasOpenSession) return;
+ if (currentTime > _highWater)
+ _highWater = currentTime;
+ }
+
+ ///
+ /// Close the open session and emit a play event if the engagement floor was crossed; below the floor
+ /// nothing is sent (it was a preview/skip, §1d). Idempotent and safe to call when no session is open —
+ /// organic end, track-switch, stop, dispose, and the unload beacon may all race to close, and only the
+ /// first call emits.
+ ///
+ public void Close()
+ {
+ if (!HasOpenSession)
+ {
+ // Mark closed even if never opened so a stray late callback cannot reopen-then-emit.
+ _closed = true;
+ return;
+ }
+
+ var key = _trackEntryKey!;
+ _closed = true;
+
+ // Without a known duration there is no fraction to classify and no floor to test — drop the
+ // session. In practice the WAV header sets duration well before any meaningful listen, so this
+ // only drops listens that ended before the header parsed (i.e. effectively no listen).
+ if (_duration is not { } duration || duration <= 0)
+ return;
+
+ var fraction = Math.Clamp(_highWater / duration, 0.0, 1.0);
+
+ if (!CrossesFloor(_highWater, duration))
+ return;
+
+ _sink.EmitPlay(key, Classify(fraction));
+ }
+
+ // The floor is the SMALLER of the absolute-seconds wall and the percentage of duration (§1d / D2).
+ private static bool CrossesFloor(double highWater, double duration)
+ {
+ var floor = Math.Min(FloorSeconds, FloorFraction * duration);
+ return highWater >= floor;
+ }
+
+ private static PlayBucket Classify(double fraction)
+ => fraction < SampledThreshold ? PlayBucket.Partial
+ : fraction <= CompleteThreshold ? PlayBucket.Sampled
+ : PlayBucket.Complete;
+}
diff --git a/DeepDrftPublic.Client/Services/ShareTracker.cs b/DeepDrftPublic.Client/Services/ShareTracker.cs
new file mode 100644
index 0000000..f72cd30
--- /dev/null
+++ b/DeepDrftPublic.Client/Services/ShareTracker.cs
@@ -0,0 +1,70 @@
+using System.Text.Json;
+using DeepDrftModels.DTOs;
+using DeepDrftModels.Enums;
+using Microsoft.AspNetCore.Components;
+
+namespace DeepDrftPublic.Client.Services;
+
+///
+/// Records share events from SharePopover (Phase 16 §1b / §2.1). After a successful clipboard
+/// write the popover calls ; this tracker applies the per-(target,channel)
+/// debounce — at most one event per target+channel per per session — and
+/// fires the event via navigator.sendBeacon to the proxied api/event/share route.
+///
+///
+/// Scoped (per-session) so the debounce memory lives for the session and resets on a fresh load, matching
+/// the "feels like one act" intent: copying the same link three times in a row is one share, not three.
+/// The beacon send is fire-and-forget; no anonId is sent in wave 16.1.
+///
+///
+public sealed class ShareTracker
+{
+ // One event per (target, channel) per this window per session (§1b). 60s matches the spec's
+ // recommendation — long enough to fold a flurry of repeat copies into one intent.
+ private static readonly TimeSpan DebounceWindow = TimeSpan.FromSeconds(60);
+
+ private readonly BeaconInterop _beacon;
+ private readonly string _shareUrl;
+ private readonly Dictionary _lastSent = new();
+
+ public ShareTracker(BeaconInterop beacon, NavigationManager navigation)
+ {
+ _beacon = beacon;
+ _shareUrl = $"{navigation.BaseUri}api/event/share";
+ }
+
+ ///
+ /// Record a share unless an identical (target, channel) was recorded within the debounce window.
+ /// Returns true when an event was fired, false when debounced — primarily so tests can assert the
+ /// debounce without reaching into the beacon.
+ ///
+ public bool RecordShare(ShareTargetType targetType, string targetKey, ShareChannel channel)
+ => RecordShare(targetType, targetKey, channel, DateTimeOffset.UtcNow);
+
+ ///
+ /// Debounce-aware record with an injectable so the 60s window is testable
+ /// without wall-clock waits. The parameterless overload above passes .
+ ///
+ public bool RecordShare(ShareTargetType targetType, string targetKey, ShareChannel channel, DateTimeOffset now)
+ {
+ if (string.IsNullOrWhiteSpace(targetKey))
+ return false;
+
+ var dedupeKey = $"{targetType}:{targetKey}:{channel}";
+ if (_lastSent.TryGetValue(dedupeKey, out var last) && now - last < DebounceWindow)
+ return false;
+
+ _lastSent[dedupeKey] = now;
+
+ var json = JsonSerializer.Serialize(new ShareEventDto
+ {
+ TargetType = targetType,
+ TargetKey = targetKey,
+ Channel = channel,
+ });
+
+ // Fire-and-forget — a dropped share telemetry event is acceptable.
+ _ = _beacon.SendAsync(_shareUrl, json);
+ return true;
+ }
+}
diff --git a/DeepDrftPublic.Client/Services/StreamingAudioPlayerService.cs b/DeepDrftPublic.Client/Services/StreamingAudioPlayerService.cs
index 774d005..f795535 100644
--- a/DeepDrftPublic.Client/Services/StreamingAudioPlayerService.cs
+++ b/DeepDrftPublic.Client/Services/StreamingAudioPlayerService.cs
@@ -2,6 +2,7 @@ using DeepDrftModels.DTOs;
using DeepDrftPublic.Client.Clients;
using System.Buffers;
using Microsoft.Extensions.Logging;
+using Microsoft.JSInterop;
namespace DeepDrftPublic.Client.Services;
@@ -32,6 +33,22 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
private readonly ILogger _logger;
private string? _currentTrackId;
+ // Phase 16 play-session telemetry (§2.1). The tracker observes the playback lifecycle and emits at
+ // most one bucketed play event per session, behind the engagement floor. Attached after construction
+ // by AudioPlayerProvider (the player is not DI-registered), mirroring how QueueService binds — no
+ // constructor growth propagated through DI, no construction cycle. Null when telemetry is not wired
+ // (e.g. unit tests that construct the player without it), so every call is null-guarded.
+ private PlayTracker? _playTracker;
+ private BeaconInterop? _beacon;
+ private DotNetObjectReference? _unloadRef;
+ private string? _unloadKey;
+
+ // One-shot guard so the play session opens exactly once per LoadTrackStreaming — never on the
+ // SeekBeyondBuffer re-stream, which reuses _currentTrackId and re-runs the playback-start transition
+ // with _streamingPlaybackStarted reset. A seek-beyond-buffer is the SAME play (§1d), so it must not
+ // open a new session. Set true when the session opens; reset only by LoadTrackStreaming.
+ private bool _sessionOpened;
+
public StreamingAudioPlayerService(
AudioInteropService audioInterop,
TrackMediaClient trackMediaClient,
@@ -41,6 +58,41 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
_logger = logger;
}
+ ///
+ /// Wire the play-session tracker and beacon transport into the player after construction (Phase 16
+ /// §2.1). Called once by AudioPlayerProvider. Kept off the constructor deliberately: the player
+ /// is built with new by the provider (not DI), so threading the tracker through the constructor
+ /// would force the provider to resolve it too — instead the provider injects the tracker's collaborators
+ /// and hands a built tracker here, the same post-construction binding QueueService uses. Also registers
+ /// the page-unload handler so a mid-play tab-close still records the play via sendBeacon.
+ ///
+ public void AttachTracker(PlayTracker tracker, BeaconInterop beacon)
+ {
+ _playTracker = tracker;
+ _beacon = beacon;
+
+ _unloadRef = DotNetObjectReference.Create(this);
+ _unloadKey = PlayerId;
+ // Fire-and-forget: registration only needs to have happened before the listener leaves; it
+ // never gates playback. A failure simply means tab-close mid-play isn't recorded.
+ _ = _beacon.RegisterUnloadAsync(_unloadKey, _unloadRef, nameof(OnPageUnload));
+ }
+
+ ///
+ /// Close the open play session as the page unloads (pagehide / visibility→hidden). Invoked
+ /// synchronously from the beacon's unload handler so the session's beacon is queued before the page
+ /// freezes. is idempotent, so a later organic close is a no-op.
+ ///
+ [JSInvokable]
+ public void OnPageUnload() => _playTracker?.Close();
+
+ // Advance the play-session high-water mark on each progress tick (§2.1). Seeking backward never
+ // lowers it — the tracker takes the max.
+ protected override void OnProgressTick(double currentTime) => _playTracker?.OnProgress(currentTime);
+
+ // Organic end-of-stream closes the session; the bucket reflects the high-water fraction reached.
+ protected override void OnPlaybackEnded() => _playTracker?.Close();
+
public override async Task SelectTrack(TrackDto track)
{
await SelectTrackStreaming(track);
@@ -88,6 +140,10 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
// Save track ID for seek operations
_currentTrackId = track.EntryKey;
+ // A fresh load is a fresh play candidate (§1d: replays = multiple plays). Arm the
+ // one-shot session-open guard; the session actually opens at the playback-start transition
+ // below (a track that fails to load never reaches it, so it does not count).
+ _sessionOpened = false;
// Expose to UI immediately — Now-Playing surfaces should reflect the selected
// track while it's still loading, not only after playback starts.
CurrentTrack = track;
@@ -303,8 +359,12 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
{
Duration = chunkResult.Duration.Value;
_logger.LogInformation("Duration set from WAV header: {Duration:F2} seconds", Duration);
+ // Feed the same once-only duration to the play session so it can compute the
+ // completion fraction at close. Safe before/after session open — SetDuration
+ // is a no-op when no session is open and idempotent otherwise.
+ _playTracker?.SetDuration(chunkResult.Duration.Value);
}
-
+
// Start playback as soon as we can
if (!_streamingPlaybackStarted && CanStartStreaming)
{
@@ -316,6 +376,20 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
IsPaused = false;
IsLoaded = true; // Track is loaded and ready to play (even if still downloading)
ErrorMessage = null;
+
+ // Open the play session exactly once per load, at the moment playback truly
+ // begins (§2.1). The _sessionOpened guard keeps the SeekBeyondBuffer re-stream
+ // — which re-enters this transition with _streamingPlaybackStarted reset —
+ // from opening a second session for the same play. Duration may already be
+ // known from a prior chunk, so re-feed it after opening.
+ if (!_sessionOpened && _currentTrackId is { } trackKey)
+ {
+ _sessionOpened = true;
+ _playTracker?.OnPlaybackStarted(trackKey);
+ if (Duration is { } d)
+ _playTracker?.SetDuration(d);
+ }
+
await NotifyStateChanged(); // Immediate notification for critical state change
}
else
@@ -533,6 +607,13 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
///
private async Task ResetToIdle()
{
+ // 0. Close any open play session BEFORE tearing down (§2.1). ResetToIdle is the single funnel
+ // for stop / unload / dispose / track-switch (a new LoadTrackStreaming calls it first), so a
+ // superseded listen is recorded here with its high-water bucket. Close is idempotent — if the
+ // session already closed organically or via the unload beacon, this is a no-op.
+ _playTracker?.Close();
+ _sessionOpened = false;
+
// 1. Cancel any ongoing streaming operation and wait for it to exit
// before tearing down JS state. Otherwise the loop's pending
// ProcessStreamingChunk call can land after StopAsync/UnloadAsync.
@@ -630,12 +711,24 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
{
try
{
+ // ResetToIdle closes any open play session, so a dispose mid-play still records the listen.
await ResetToIdle();
}
catch
{
// Disposal must not throw; any failure here is best-effort cleanup.
}
+
+ // Detach the page-unload handler so the torn-down circuit is never invoked, then release the
+ // self-reference. Best-effort — the JS side tolerates an absent key.
+ if (_unloadKey is not null && _beacon is not null)
+ {
+ try { await _beacon.UnregisterUnloadAsync(_unloadKey); }
+ catch { /* best-effort */ }
+ }
+ _unloadRef?.Dispose();
+ _unloadRef = null;
+
await base.DisposeAsync();
}
diff --git a/DeepDrftPublic.Client/Startup.cs b/DeepDrftPublic.Client/Startup.cs
index c5c6b67..077f82e 100644
--- a/DeepDrftPublic.Client/Startup.cs
+++ b/DeepDrftPublic.Client/Startup.cs
@@ -34,6 +34,14 @@ public static class Startup
// Waveform visualizer controls — scoped so the eight slider positions persist across navigation
// within a session and reset on a fresh page load (see WaveformVisualizerControlState).
services.AddScoped();
+
+ // Phase 16 anonymous telemetry (client side). BeaconInterop wraps sendBeacon; the play sink and
+ // share tracker fire events through it. The play tracker itself is NOT registered — the player
+ // is not DI-registered, so AudioPlayerProvider constructs the tracker and attaches it. ShareTracker
+ // is scoped so its per-(target,channel) debounce memory lives for the session.
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
}
public static void ConfigureApiHttpClient(IServiceCollection services, string baseAddress)
diff --git a/DeepDrftPublic/Components/App.razor b/DeepDrftPublic/Components/App.razor
index d192024..f11459c 100644
--- a/DeepDrftPublic/Components/App.razor
+++ b/DeepDrftPublic/Components/App.razor
@@ -25,6 +25,7 @@