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..75dff45 --- /dev/null +++ b/DeepDrftData/Repositories/EventRepository.cs @@ -0,0 +1,121 @@ +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. + 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 84f1912..b17a532 100644 --- a/DeepDrftPublic.Client/Controls/SharePopover.razor.cs +++ b/DeepDrftPublic.Client/Controls/SharePopover.razor.cs @@ -1,5 +1,6 @@ using DeepDrftModels.Enums; using DeepDrftPublic.Client.Common; +using DeepDrftPublic.Client.Services; using Microsoft.AspNetCore.Components; using Microsoft.JSInterop; @@ -27,6 +28,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; @@ -67,6 +69,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); } @@ -76,6 +86,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 5ba3473..81020df 100644 --- a/DeepDrftPublic.Client/Startup.cs +++ b/DeepDrftPublic.Client/Startup.cs @@ -33,6 +33,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 @@ diff --git a/DeepDrftPublic/Controllers/EventProxyController.cs b/DeepDrftPublic/Controllers/EventProxyController.cs new file mode 100644 index 0000000..0724e48 --- /dev/null +++ b/DeepDrftPublic/Controllers/EventProxyController.cs @@ -0,0 +1,70 @@ +using System.Text; +using Microsoft.AspNetCore.Mvc; + +namespace DeepDrftPublic.Controllers; + +/// +/// Proxies the anonymous telemetry write endpoints (POST api/event/play / api/event/share) +/// to DeepDrftAPI so the WASM client never makes a cross-origin request (Phase 16 §2.2). Mirrors +/// 's idiom — the named "DeepDrft.API" client forwards the +/// request upstream — but for a POST write: the small JSON body is buffered and relayed verbatim, and +/// the upstream status (202 on success, 4xx on a rejected payload, 429 on rate limit) passes back so the +/// beacon's fire-and-forget contract is preserved end to end. SSR never posts these — they originate +/// from the browser player/share surfaces only. +/// +// A sendBeacon POST cannot attach a Blazor antiforgery token, so the telemetry write routes opt out +// explicitly. They are anonymous, idempotent-enough fire-and-forget logging — there is no +// state-changing user action to protect with CSRF tokens, and the upstream rate-limits by IP. +[ApiController] +[Route("api/event")] +[IgnoreAntiforgeryToken] +public class EventProxyController : ControllerBase +{ + private readonly HttpClient _upstream; + private readonly ILogger _logger; + + public EventProxyController(IHttpClientFactory httpClientFactory, ILogger logger) + { + _upstream = httpClientFactory.CreateClient("DeepDrft.API"); + _logger = logger; + } + + /// Proxies a play event upstream. Body is opaque JSON — validated by DeepDrftAPI, not here. + [HttpPost("play")] + public Task ForwardPlay(CancellationToken ct = default) => Forward("api/event/play", ct); + + /// Proxies a share event upstream. + [HttpPost("share")] + public Task ForwardShare(CancellationToken ct = default) => Forward("api/event/share", ct); + + private async Task Forward(string upstreamPath, CancellationToken ct) + { + // Buffer the small JSON body and relay it verbatim. Reading the raw body keeps the proxy + // transparent — it does not deserialize or re-shape the payload, just forwards it. + string body; + using (var reader = new StreamReader(Request.Body, Encoding.UTF8)) + { + body = await reader.ReadToEndAsync(ct); + } + + using var content = new StringContent(body, Encoding.UTF8, "application/json"); + + HttpResponseMessage upstream; + try + { + upstream = await _upstream.PostAsync(upstreamPath, content, ct); + } + catch (Exception ex) + { + _logger.LogError(ex, "Upstream call to DeepDrftAPI {Path} failed", upstreamPath); + return StatusCode(502, "Upstream unavailable"); + } + + // Relay the upstream status as-is. Telemetry is fire-and-forget; the beacon never reads the + // body, so there is nothing to relay beyond the code (202 / 400 / 429 / 5xx). + using (upstream) + { + return StatusCode((int)upstream.StatusCode); + } + } +} diff --git a/DeepDrftPublic/Interop/telemetry/beacon.ts b/DeepDrftPublic/Interop/telemetry/beacon.ts new file mode 100644 index 0000000..5bf8264 --- /dev/null +++ b/DeepDrftPublic/Interop/telemetry/beacon.ts @@ -0,0 +1,88 @@ +/** + * Telemetry beacon interop (Phase 16 §2.2). A thin wrapper over navigator.sendBeacon for fire-and-forget + * play/share events, plus a page-unload handler that lets the player close an open play session as the + * tab goes away. sendBeacon (not fetch) is the load-bearing choice: it survives page unload, where a + * fetch would be cancelled — exactly the tab-close edge case the play metric must still record. + * + * Exposed on window.DeepDrftBeacon; imported once in App.razor alongside the audio engine. + */ + +// .NET interop type — a DotNetObjectReference the unload handler invokes back into. +interface DotNetObjectReference { + invokeMethodAsync(methodName: string, ...args: unknown[]): Promise; + invokeMethod(methodName: string, ...args: unknown[]): unknown; +} + +// Registered unload listeners. Holding the handler lets us detach on dispose so a torn-down player +// circuit does not get called into. +type UnloadEntry = { dotNetRef: DotNetObjectReference; methodName: string }; +const unloadHandlers = new Map(); + +let unloadWired = false; + +// Fire every registered unload handler synchronously. invokeMethod (sync) — not invokeMethodAsync — is +// required here: in pagehide/visibilitychange→hidden the event loop will not pump a microtask before the +// page is frozen, so an awaited call would never run. The .NET side does only synchronous beacon work. +function fireUnloadHandlers(): void { + for (const { dotNetRef, methodName } of unloadHandlers.values()) { + try { + dotNetRef.invokeMethod(methodName); + } catch { + // A torn-down circuit or a transient interop failure must never block unload. + } + } +} + +function wireUnloadOnce(): void { + if (unloadWired) return; + unloadWired = true; + + // pagehide is the canonical "page is going away" signal (covers tab close, navigation, and the + // bfcache freeze). visibilitychange→hidden additionally covers the mobile case where the tab is + // backgrounded and may be discarded without a pagehide. Both funnel to the same close path; the + // .NET side is idempotent, so a double-fire closes the session at most once. + window.addEventListener('pagehide', fireUnloadHandlers); + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'hidden') fireUnloadHandlers(); + }); +} + +const DeepDrftBeacon = { + /** + * Queue a fire-and-forget POST of a small JSON body. Returns false if the browser refused to queue + * the beacon (e.g. over the per-origin byte budget) — callers ignore it; a dropped telemetry event + * is acceptable by design. + */ + send: (url: string, json: string): boolean => { + try { + const blob = new Blob([json], { type: 'application/json' }); + return navigator.sendBeacon(url, blob); + } catch { + return false; + } + }, + + /** + * Register a .NET callback to run on page unload (and on visibility→hidden). Keyed so a given player + * registers once and can replace/detach cleanly across its lifecycle. + */ + registerUnload: (key: string, dotNetRef: DotNetObjectReference, methodName: string): void => { + wireUnloadOnce(); + unloadHandlers.set(key, { dotNetRef, methodName }); + }, + + /** Detach a previously-registered unload callback (player dispose). */ + unregisterUnload: (key: string): void => { + unloadHandlers.delete(key); + }, +}; + +declare global { + interface Window { + DeepDrftBeacon: typeof DeepDrftBeacon; + } +} + +window.DeepDrftBeacon = DeepDrftBeacon; + +export { DeepDrftBeacon }; diff --git a/DeepDrftTests/PlayEventQueryTests.cs b/DeepDrftTests/PlayEventQueryTests.cs new file mode 100644 index 0000000..052b02e --- /dev/null +++ b/DeepDrftTests/PlayEventQueryTests.cs @@ -0,0 +1,174 @@ +using DeepDrftData.Data; +using DeepDrftData.Repositories; +using DeepDrftModels.Entities; +using DeepDrftModels.Enums; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; + +namespace DeepDrftTests; + +/// +/// Storage-layer tests for the Phase 16 telemetry writes (): server-side +/// release resolution (§2.3 / D4), the incremental play-counter bump in the same write (D6), the +/// derived-release-total shape (a release's plays are the sum of its tracks'), and the share append. +/// Runs on the EF in-memory provider like . In-memory does not support +/// real transactions, so the transaction-ignored warning is suppressed — the production Postgres path +/// wraps the append + bump in one transaction, which the warning would otherwise turn into an error here. +/// +[TestFixture] +public class PlayEventQueryTests +{ + private DeepDrftContext _context = null!; + + [SetUp] + public void SetUp() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning)) + .Options; + _context = new DeepDrftContext(options); + } + + [TearDown] + public void TearDown() => _context.Dispose(); + + private EventRepository CreateRepository() => new(_context); + + private async Task<(ReleaseEntity Release, TrackEntity Track)> SeedTrackAsync(string trackKey) + { + var release = new ReleaseEntity + { + EntryKey = Guid.NewGuid().ToString("N"), + Title = "R", + Artist = "A", + Medium = ReleaseMedium.Cut, + }; + var track = new TrackEntity { EntryKey = trackKey, TrackName = "T", Release = release }; + _context.Releases.Add(release); + _context.Tracks.Add(track); + await _context.SaveChangesAsync(); + return (release, track); + } + + // A play that reaches the repository (the floor is the tracker's job) writes exactly one play_event + // row with the release id resolved server-side, and bumps the matching bucket on the track's counter. + [Test] + public async Task RecordPlayAsync_ResolvesReleaseAndBumpsCounter() + { + var (release, track) = await SeedTrackAsync("track-1"); + + await CreateRepository().RecordPlayAsync("track-1", PlayBucket.Complete, anonId: null); + + var ev = await _context.PlayEvents.SingleAsync(); + Assert.That(ev.TrackEntryKey, Is.EqualTo("track-1")); + Assert.That(ev.ReleaseId, Is.EqualTo(release.Id), "release resolved server-side from the track key"); + Assert.That(ev.Bucket, Is.EqualTo(PlayBucket.Complete)); + Assert.That(ev.AnonId, Is.Null, "no anonId is written in wave 16.1"); + + var counter = await _context.PlayCounters.SingleAsync(c => c.TrackId == track.Id); + Assert.That(counter.CompleteCount, Is.EqualTo(1)); + Assert.That(counter.TotalPlays, Is.EqualTo(1)); + } + + // Each bucket bumps its own column; total plays is the sum across buckets. + [Test] + public async Task RecordPlayAsync_BucketsAccumulateIndependently() + { + var (_, track) = 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); + + var counter = await _context.PlayCounters.SingleAsync(c => c.TrackId == track.Id); + Assert.That(counter.PartialCount, Is.EqualTo(1)); + Assert.That(counter.SampledCount, Is.EqualTo(2)); + Assert.That(counter.CompleteCount, Is.EqualTo(1)); + Assert.That(counter.TotalPlays, Is.EqualTo(4)); + Assert.That(await _context.PlayEvents.CountAsync(), Is.EqualTo(4)); + } + + // Release totals are derived (D4): summing the counters of the release's tracks gives release plays; + // there is no separate release-counter row. + [Test] + public async Task RecordPlayAsync_ReleaseTotalIsSumOfTrackCounters() + { + var release = new ReleaseEntity + { + EntryKey = Guid.NewGuid().ToString("N"), Title = "R", Artist = "A", Medium = ReleaseMedium.Cut, + }; + var t1 = new TrackEntity { EntryKey = "t1", TrackName = "T1", Release = release }; + var t2 = new TrackEntity { EntryKey = "t2", TrackName = "T2", Release = release }; + _context.Releases.Add(release); + _context.Tracks.AddRange(t1, t2); + await _context.SaveChangesAsync(); + + var repo = CreateRepository(); + await repo.RecordPlayAsync("t1", PlayBucket.Complete, null); + await repo.RecordPlayAsync("t1", PlayBucket.Partial, null); + await repo.RecordPlayAsync("t2", PlayBucket.Sampled, null); + + var releaseTotal = await _context.PlayCounters + .Where(c => _context.Tracks.Any(t => t.Id == c.TrackId && t.ReleaseId == release.Id)) + .SumAsync(c => c.PartialCount + c.SampledCount + c.CompleteCount); + + Assert.That(releaseTotal, Is.EqualTo(3)); + } + + // A loose track (no release) logs the event with a null release id and still bumps its own counter. + [Test] + public async Task RecordPlayAsync_LooseTrack_NullReleaseStillCounts() + { + var track = new TrackEntity { EntryKey = "loose", TrackName = "T" }; + _context.Tracks.Add(track); + await _context.SaveChangesAsync(); + + await CreateRepository().RecordPlayAsync("loose", PlayBucket.Sampled, null); + + var ev = await _context.PlayEvents.SingleAsync(); + Assert.That(ev.ReleaseId, Is.Null); + Assert.That((await _context.PlayCounters.SingleAsync(c => c.TrackId == track.Id)).SampledCount, Is.EqualTo(1)); + } + + // A play of an unknown/removed track key still logs (null release, no counter bump) rather than failing. + [Test] + public async Task RecordPlayAsync_UnknownTrackKey_LogsEventWithoutCounter() + { + await CreateRepository().RecordPlayAsync("does-not-exist", PlayBucket.Partial, null); + + var ev = await _context.PlayEvents.SingleAsync(); + Assert.That(ev.ReleaseId, Is.Null); + Assert.That(await _context.PlayCounters.AnyAsync(), Is.False, "no track to roll up against"); + } + + // A soft-deleted track resolves to null (the !IsDeleted guard) — the play still logs, no counter bump. + [Test] + public async Task RecordPlayAsync_SoftDeletedTrack_DoesNotResolveRelease() + { + var (_, track) = await SeedTrackAsync("gone"); + track.IsDeleted = true; + await _context.SaveChangesAsync(); + + await CreateRepository().RecordPlayAsync("gone", PlayBucket.Complete, null); + + var ev = await _context.PlayEvents.SingleAsync(); + Assert.That(ev.ReleaseId, Is.Null); + Assert.That(await _context.PlayCounters.AnyAsync(c => c.TrackId == track.Id), Is.False); + } + + // A share append writes one row with the target, channel, and a null anonId. + [Test] + public async Task RecordShareAsync_AppendsRow() + { + await CreateRepository().RecordShareAsync(ShareTargetType.Release, "rel-key", ShareChannel.Embed, anonId: null); + + var ev = await _context.ShareEvents.SingleAsync(); + Assert.That(ev.TargetType, Is.EqualTo(ShareTargetType.Release)); + Assert.That(ev.TargetKey, Is.EqualTo("rel-key")); + Assert.That(ev.Channel, Is.EqualTo(ShareChannel.Embed)); + Assert.That(ev.AnonId, Is.Null); + } +} diff --git a/DeepDrftTests/PlayTrackerTests.cs b/DeepDrftTests/PlayTrackerTests.cs new file mode 100644 index 0000000..8fd433c --- /dev/null +++ b/DeepDrftTests/PlayTrackerTests.cs @@ -0,0 +1,205 @@ +using DeepDrftModels.Enums; +using DeepDrftPublic.Client.Services; + +namespace DeepDrftTests; + +/// +/// Unit tests for the Phase 16 play-session tracker (): the engagement-floor +/// gate (§1d / D2 — ≥3s OR ≥5% of duration, whichever smaller) and the three-bucket completion +/// classification (§1a / D1 — partial <30%, sampled 30–80%, complete >80%), exercised behind a +/// fake sink so the logic is tested with no player or JS interop — the seam the spec calls out as +/// testable. Also covers the high-water (seek-backward) and idempotent-close invariants. +/// +[TestFixture] +public class PlayTrackerTests +{ + // Captures emitted plays so assertions read the (key, bucket) the tracker classified. + private sealed class FakeSink : IPlayEventSink + { + public List<(string Key, PlayBucket Bucket)> Emitted { get; } = new(); + public void EmitPlay(string trackEntryKey, PlayBucket bucket) => Emitted.Add((trackEntryKey, bucket)); + } + + private FakeSink _sink = null!; + private PlayTracker _tracker = null!; + + [SetUp] + public void SetUp() + { + _sink = new FakeSink(); + _tracker = new PlayTracker(_sink); + } + + // Drive a full session: open, set duration, advance to a high-water position, close. + private void PlaySession(string key, double duration, double highWater) + { + _tracker.OnPlaybackStarted(key); + _tracker.SetDuration(duration); + _tracker.OnProgress(highWater); + _tracker.Close(); + } + + // --- Engagement floor (§1d / D2) --- + + // A long track floors on the 3-second wall (3s < 5% of 200s = 10s): under 3s sends nothing. + [Test] + public void Close_LongTrackUnderThreeSeconds_SendsNothing() + { + PlaySession("t", duration: 200, highWater: 2.5); + Assert.That(_sink.Emitted, Is.Empty); + } + + // The same long track at exactly the 3-second floor crosses it and records a (partial) play. + [Test] + public void Close_LongTrackAtThreeSecondFloor_RecordsPlay() + { + PlaySession("t", duration: 200, highWater: 3.0); + Assert.That(_sink.Emitted, Has.Count.EqualTo(1)); + Assert.That(_sink.Emitted[0].Bucket, Is.EqualTo(PlayBucket.Partial)); + } + + // A short clip floors on the percentage (5% of 40s = 2s < 3s): 1.5s is below 2s → nothing. + [Test] + public void Close_ShortClipUnderFivePercent_SendsNothing() + { + PlaySession("t", duration: 40, highWater: 1.5); + Assert.That(_sink.Emitted, Is.Empty); + } + + // The same short clip at the 5%-of-duration floor (2s) crosses it and records. + [Test] + public void Close_ShortClipAtFivePercentFloor_RecordsPlay() + { + PlaySession("t", duration: 40, highWater: 2.0); + Assert.That(_sink.Emitted, Has.Count.EqualTo(1)); + } + + // --- Bucket classification (§1a / D1) --- + + // [floor, 30%) → partial. 60/200 = 30% is the boundary, so 59.9/200 (just under) is partial. + [Test] + public void Close_UnderThirtyPercent_ClassifiesPartial() + { + PlaySession("t", duration: 200, highWater: 50); // 25% + Assert.That(_sink.Emitted[0].Bucket, Is.EqualTo(PlayBucket.Partial)); + } + + // Exactly 30% is the start of the sampled band [30%, 80%]. + [Test] + public void Close_AtThirtyPercent_ClassifiesSampled() + { + PlaySession("t", duration: 200, highWater: 60); // 30% + Assert.That(_sink.Emitted[0].Bucket, Is.EqualTo(PlayBucket.Sampled)); + } + + // Mid-band is sampled. + [Test] + public void Close_MidBand_ClassifiesSampled() + { + PlaySession("t", duration: 200, highWater: 120); // 60% + Assert.That(_sink.Emitted[0].Bucket, Is.EqualTo(PlayBucket.Sampled)); + } + + // Exactly 80% is the inclusive top of the sampled band — still sampled, not complete. + [Test] + public void Close_AtEightyPercent_ClassifiesSampled() + { + PlaySession("t", duration: 200, highWater: 160); // 80% + Assert.That(_sink.Emitted[0].Bucket, Is.EqualTo(PlayBucket.Sampled)); + } + + // Past 80% is complete. + [Test] + public void Close_OverEightyPercent_ClassifiesComplete() + { + PlaySession("t", duration: 200, highWater: 190); // 95% + Assert.That(_sink.Emitted[0].Bucket, Is.EqualTo(PlayBucket.Complete)); + } + + // A full listen classifies complete, and the high-water is clamped (never over 100%). + [Test] + public void Close_FullListen_ClassifiesComplete() + { + PlaySession("t", duration: 200, highWater: 200); + Assert.That(_sink.Emitted[0].Bucket, Is.EqualTo(PlayBucket.Complete)); + } + + // --- High-water invariant (§1d: seeks never lower the mark) --- + + // Seeking backward after reaching the end still classifies complete — the max position wins. + [Test] + public void Close_SeekBackwardAfterEnd_StaysComplete() + { + _tracker.OnPlaybackStarted("t"); + _tracker.SetDuration(200); + _tracker.OnProgress(190); // reached 95% + _tracker.OnProgress(20); // seek back to 10% — must NOT lower the high-water + _tracker.Close(); + Assert.That(_sink.Emitted[0].Bucket, Is.EqualTo(PlayBucket.Complete)); + } + + // --- Session lifecycle --- + + // Carries the track key the session opened with through to the emitted event. + [Test] + public void Close_RecordsTheOpenedTrackKey() + { + PlaySession("the-entry-key", duration: 100, highWater: 90); + Assert.That(_sink.Emitted[0].Key, Is.EqualTo("the-entry-key")); + } + + // Close is idempotent — a second close (e.g. organic end after the unload beacon already fired) emits nothing further. + [Test] + public void Close_CalledTwice_EmitsOnce() + { + PlaySession("t", duration: 100, highWater: 90); + _tracker.Close(); + Assert.That(_sink.Emitted, Has.Count.EqualTo(1)); + } + + // A session with no duration ever set (header never parsed) has no fraction to classify → nothing. + [Test] + public void Close_WithoutDuration_SendsNothing() + { + _tracker.OnPlaybackStarted("t"); + _tracker.OnProgress(30); + _tracker.Close(); + Assert.That(_sink.Emitted, Is.Empty); + } + + // Progress before any session opens is ignored (no open session to advance), so a later open+close + // starts the high-water from zero. + [Test] + public void OnProgress_BeforeOpen_IsIgnored() + { + _tracker.OnProgress(150); + PlaySession("t", duration: 200, highWater: 10); // 5% — partial + Assert.That(_sink.Emitted[0].Bucket, Is.EqualTo(PlayBucket.Partial)); + } + + // Opening a new session while one is still open closes (and records) the prior one — a track-switch + // that did not route through Close still records the superseded listen. Two complete plays result. + [Test] + public void OnPlaybackStarted_WhileOpen_ClosesPriorSession() + { + _tracker.OnPlaybackStarted("first"); + _tracker.SetDuration(100); + _tracker.OnProgress(95); // first: complete + _tracker.OnPlaybackStarted("second"); // supersedes — records first + _tracker.SetDuration(100); + _tracker.OnProgress(95); // second: complete + _tracker.Close(); + + Assert.That(_sink.Emitted.Select(e => e.Key), Is.EqualTo(new[] { "first", "second" })); + Assert.That(_sink.Emitted.All(e => e.Bucket == PlayBucket.Complete), Is.True); + } + + // A replay (open the same key again after closing) is a second, independent play (§1d). + [Test] + public void Replay_RecordsTwoPlays() + { + PlaySession("t", duration: 100, highWater: 95); + PlaySession("t", duration: 100, highWater: 95); + Assert.That(_sink.Emitted, Has.Count.EqualTo(2)); + } +} diff --git a/DeepDrftTests/ShareTrackerTests.cs b/DeepDrftTests/ShareTrackerTests.cs new file mode 100644 index 0000000..3d8f5e8 --- /dev/null +++ b/DeepDrftTests/ShareTrackerTests.cs @@ -0,0 +1,94 @@ +using DeepDrftModels.Enums; +using DeepDrftPublic.Client.Services; +using Microsoft.AspNetCore.Components; +using Microsoft.JSInterop; +using Microsoft.JSInterop.Infrastructure; + +namespace DeepDrftTests; + +/// +/// Unit tests for the Phase 16 share tracker (): the per-(target,channel) +/// debounce (§1b — at most one event per target+channel per 60s window per session). The tracker fires +/// through a beacon that wraps ; the tests use a no-op JS runtime (the send is +/// fire-and-forget and its outcome is irrelevant) and assert on the debounce decision via the bool the +/// recorder returns — true when an event fired, false when debounced. +/// +[TestFixture] +public class ShareTrackerTests +{ + // sendBeacon interop is fire-and-forget; the tracker never reads the result, so a no-op runtime that + // returns default for any invocation is sufficient to exercise the debounce logic. + private sealed class NoopJsRuntime : IJSRuntime + { + public ValueTask InvokeAsync(string identifier, object?[]? args) + => ValueTask.FromResult(default!); + + public ValueTask InvokeAsync(string identifier, CancellationToken cancellationToken, object?[]? args) + => ValueTask.FromResult(default!); + } + + // Minimal NavigationManager so the tracker can compose the (unused-in-test) beacon URL. + private sealed class TestNavigationManager : NavigationManager + { + public TestNavigationManager() => Initialize("https://deepdrft.test/", "https://deepdrft.test/"); + protected override void NavigateToCore(string uri, bool forceLoad) { } + } + + private ShareTracker _tracker = null!; + private readonly DateTimeOffset _t0 = new(2026, 6, 19, 12, 0, 0, TimeSpan.Zero); + + [SetUp] + public void SetUp() + => _tracker = new ShareTracker(new BeaconInterop(new NoopJsRuntime()), new TestNavigationManager()); + + // A copy-link records one share with channel = link. + [Test] + public void RecordShare_CopyLink_FiresOnce() + => Assert.That(_tracker.RecordShare(ShareTargetType.Track, "k", ShareChannel.Link, _t0), Is.True); + + // A copy-embed records one share with channel = embed — distinct (target,channel) from the link copy. + [Test] + public void RecordShare_CopyEmbedAfterLink_FiresSeparately() + { + Assert.That(_tracker.RecordShare(ShareTargetType.Track, "k", ShareChannel.Link, _t0), Is.True); + Assert.That(_tracker.RecordShare(ShareTargetType.Track, "k", ShareChannel.Embed, _t0), Is.True, + "embed is a different channel from link — not debounced against it"); + } + + // An immediate repeat copy of the same (target, channel) within the window is debounced. + [Test] + public void RecordShare_ImmediateRepeat_IsDebounced() + { + Assert.That(_tracker.RecordShare(ShareTargetType.Track, "k", ShareChannel.Link, _t0), Is.True); + Assert.That(_tracker.RecordShare(ShareTargetType.Track, "k", ShareChannel.Link, _t0.AddSeconds(5)), Is.False); + } + + // After the 60s window elapses, the same (target, channel) fires again. + [Test] + public void RecordShare_AfterWindow_FiresAgain() + { + Assert.That(_tracker.RecordShare(ShareTargetType.Track, "k", ShareChannel.Link, _t0), Is.True); + Assert.That(_tracker.RecordShare(ShareTargetType.Track, "k", ShareChannel.Link, _t0.AddSeconds(61)), Is.True); + } + + // Different targets debounce independently — sharing track A then track B both fire. + [Test] + public void RecordShare_DifferentTargets_FireIndependently() + { + Assert.That(_tracker.RecordShare(ShareTargetType.Track, "a", ShareChannel.Link, _t0), Is.True); + Assert.That(_tracker.RecordShare(ShareTargetType.Track, "b", ShareChannel.Link, _t0), Is.True); + } + + // A track key and a release key are distinct targets even if the key string collides. + [Test] + public void RecordShare_TrackVsRelease_AreDistinctTargets() + { + Assert.That(_tracker.RecordShare(ShareTargetType.Track, "x", ShareChannel.Link, _t0), Is.True); + Assert.That(_tracker.RecordShare(ShareTargetType.Release, "x", ShareChannel.Link, _t0), Is.True); + } + + // A blank target key never fires (defensive — the popover guards too). + [Test] + public void RecordShare_BlankKey_DoesNotFire() + => Assert.That(_tracker.RecordShare(ShareTargetType.Track, " ", ShareChannel.Link, _t0), Is.False); +}