feat(phase-16): anonymous play & share telemetry substrate (wave 16.1)
Player-service play-session tracker (floor + 3-bucket classify), SharePopover share tracker with debounce, sendBeacon interop, proxied rate-limited POST api/event/{play,share}, append-only event logs + incremental play_counter with server-side release resolution. Migration authored, not applied. No anonId, no read surface.
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
using DeepDrftData;
|
||||
using DeepDrftModels.DTOs;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
|
||||
namespace DeepDrftAPI.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// 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 <c>202 Accepted</c>: these
|
||||
/// are fire-and-forget telemetry, not transactions, and the client (a <c>sendBeacon</c>) 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 <see cref="IEventService"/>.
|
||||
/// </summary>
|
||||
[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<EventController> _logger;
|
||||
|
||||
public EventController(IEventService eventService, ILogger<EventController> logger)
|
||||
{
|
||||
_eventService = eventService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
// POST api/event/play (unauthenticated, rate-limited)
|
||||
[HttpPost("play")]
|
||||
[RequestSizeLimit(MaxBodyBytes)]
|
||||
public async Task<ActionResult> 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<ActionResult> 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();
|
||||
}
|
||||
}
|
||||
@@ -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<ITrackService>(sp => sp.GetRequiredService<TrackManager>());
|
||||
builder.Services.AddScoped<UnifiedTrackService>();
|
||||
|
||||
// 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<EventRepository>()
|
||||
.AddScoped<EventManager>()
|
||||
.AddScoped<IEventService>(sp => sp.GetRequiredService<EventManager>());
|
||||
|
||||
// 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<ForwardedHeadersOptions>(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.
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
using DeepDrftModels.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace DeepDrftData.Data.Configurations;
|
||||
|
||||
/// <summary>
|
||||
/// EF configuration for the <c>play_counter</c> 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. <c>TotalPlays</c> is
|
||||
/// a computed C# property (sum of the three bucket columns) and is not mapped — it is derived on read.
|
||||
/// </summary>
|
||||
public class PlayCounterConfiguration : IEntityTypeConfiguration<PlayCounter>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<PlayCounter> 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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using DeepDrftModels.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace DeepDrftData.Data.Configurations;
|
||||
|
||||
/// <summary>
|
||||
/// EF configuration for the append-only <c>play_event</c> log (Phase 16 §4.2). Plain entity, not a
|
||||
/// <c>BaseEntity</c> — 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.
|
||||
/// </summary>
|
||||
public class PlayEventConfiguration : IEntityTypeConfiguration<PlayEvent>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<PlayEvent> 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<string>() // 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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using DeepDrftModels.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace DeepDrftData.Data.Configurations;
|
||||
|
||||
/// <summary>
|
||||
/// EF configuration for the append-only <c>share_event</c> log (Phase 16 §4.2). Plain immutable-fact
|
||||
/// entity. Indexed on the target key so per-target share tallies stay cheap.
|
||||
/// </summary>
|
||||
public class ShareEventConfiguration : IEntityTypeConfiguration<ShareEvent>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<ShareEvent> builder)
|
||||
{
|
||||
builder.ToTable("share_event");
|
||||
|
||||
builder.HasKey(e => e.Id);
|
||||
builder.Property(e => e.Id).HasColumnName("id");
|
||||
|
||||
builder.Property(e => e.TargetType)
|
||||
.IsRequired()
|
||||
.HasConversion<string>()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnName("target_type");
|
||||
|
||||
builder.Property(e => e.TargetKey)
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnName("target_key");
|
||||
|
||||
builder.Property(e => e.Channel)
|
||||
.IsRequired()
|
||||
.HasConversion<string>()
|
||||
.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");
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,12 @@ public class DeepDrftContext : DbContext
|
||||
public DbSet<SessionMetadata> SessionMetadata { get; set; }
|
||||
public DbSet<MixMetadata> MixMetadata { get; set; }
|
||||
|
||||
// Phase 16 anonymous telemetry: append-only event logs + incremental play rollup. All SQL — the
|
||||
// FileDatabase vault is not involved.
|
||||
public DbSet<PlayEvent> PlayEvents { get; set; }
|
||||
public DbSet<ShareEvent> ShareEvents { get; set; }
|
||||
public DbSet<PlayCounter> 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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
using DeepDrftData.Repositories;
|
||||
using DeepDrftModels.Enums;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NetBlocks.Models;
|
||||
|
||||
namespace DeepDrftData;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="IEventService"/> implementation over <see cref="EventRepository"/>. 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 <see cref="Result"/>. 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.
|
||||
/// </summary>
|
||||
public class EventManager : IEventService
|
||||
{
|
||||
private readonly EventRepository _repository;
|
||||
private readonly ILogger<EventManager> _logger;
|
||||
|
||||
public EventManager(EventRepository repository, ILogger<EventManager> logger)
|
||||
{
|
||||
_repository = repository;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<Result> 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<Result> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using DeepDrftModels.Enums;
|
||||
using NetBlocks.Models;
|
||||
|
||||
namespace DeepDrftData;
|
||||
|
||||
/// <summary>
|
||||
/// 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 <see cref="Result"/> at the boundary; the controller maps that to 202/4xx/5xx.
|
||||
/// </summary>
|
||||
public interface IEventService
|
||||
{
|
||||
/// <summary>
|
||||
/// Record one play: append a <c>play_event</c> row (release resolved from the track key) and bump
|
||||
/// the track's <c>play_counter</c> 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.
|
||||
/// </summary>
|
||||
Task<Result> RecordPlay(string trackEntryKey, PlayBucket bucket, string? anonId = null, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Record one share: append a <c>share_event</c> row. Target and channel come straight from the client.</summary>
|
||||
Task<Result> RecordShare(ShareTargetType targetType, string targetKey, ShareChannel channel, string? anonId = null, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,457 @@
|
||||
// <auto-generated />
|
||||
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
|
||||
{
|
||||
/// <inheritdoc />
|
||||
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<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_deleted");
|
||||
|
||||
b.Property<long>("ReleaseId")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("release_id");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.Property<string>("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<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<long>("CompleteCount")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasDefaultValue(0L)
|
||||
.HasColumnName("complete_count");
|
||||
|
||||
b.Property<long>("PartialCount")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasDefaultValue(0L)
|
||||
.HasColumnName("partial_count");
|
||||
|
||||
b.Property<long>("SampledCount")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasDefaultValue(0L)
|
||||
.HasColumnName("sampled_count");
|
||||
|
||||
b.Property<long>("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<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<string>("AnonId")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)")
|
||||
.HasColumnName("anon_id");
|
||||
|
||||
b.Property<string>("Bucket")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasColumnName("bucket");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<long?>("ReleaseId")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("release_id");
|
||||
|
||||
b.Property<string>("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<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<string>("Artist")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("artist");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<long?>("CreatedByUserId")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("created_by_user_id");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(4000)
|
||||
.HasColumnType("character varying(4000)")
|
||||
.HasColumnName("description");
|
||||
|
||||
b.Property<string>("EntryKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("entry_key");
|
||||
|
||||
b.Property<string>("Genre")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("genre");
|
||||
|
||||
b.Property<string>("ImagePath")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("image_path");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_deleted");
|
||||
|
||||
b.Property<string>("Medium")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasDefaultValue("Cut")
|
||||
.HasColumnName("medium");
|
||||
|
||||
b.Property<DateOnly?>("ReleaseDate")
|
||||
.HasColumnType("date")
|
||||
.HasColumnName("release_date");
|
||||
|
||||
b.Property<string>("ReleaseType")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasDefaultValue("Single")
|
||||
.HasColumnName("release_type");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("title");
|
||||
|
||||
b.Property<DateTime>("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<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("HeroImageEntryKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("hero_image_entry_key");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_deleted");
|
||||
|
||||
b.Property<long>("ReleaseId")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("release_id");
|
||||
|
||||
b.Property<DateTime>("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<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<string>("AnonId")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)")
|
||||
.HasColumnName("anon_id");
|
||||
|
||||
b.Property<string>("Channel")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasColumnName("channel");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("TargetKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("target_key");
|
||||
|
||||
b.Property<string>("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<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<double?>("DurationSeconds")
|
||||
.HasColumnType("double precision")
|
||||
.HasColumnName("duration_seconds");
|
||||
|
||||
b.Property<string>("EntryKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("entry_key");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_deleted");
|
||||
|
||||
b.Property<string>("OriginalFileName")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("original_file_name");
|
||||
|
||||
b.Property<long?>("ReleaseId")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("release_id");
|
||||
|
||||
b.Property<string>("TrackName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("track_name");
|
||||
|
||||
b.Property<int>("TrackNumber")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasDefaultValue(1)
|
||||
.HasColumnName("track_number");
|
||||
|
||||
b.Property<DateTime>("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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DeepDrftData.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddPlayShareTelemetry : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "play_counter",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<long>(type: "bigint", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
track_id = table.Column<long>(type: "bigint", nullable: false),
|
||||
partial_count = table.Column<long>(type: "bigint", nullable: false, defaultValue: 0L),
|
||||
sampled_count = table.Column<long>(type: "bigint", nullable: false, defaultValue: 0L),
|
||||
complete_count = table.Column<long>(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<long>(type: "bigint", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
track_entry_key = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||
release_id = table.Column<long>(type: "bigint", nullable: true),
|
||||
bucket = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
|
||||
anon_id = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true),
|
||||
created_at = table.Column<DateTime>(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<long>(type: "bigint", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
target_type = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
|
||||
target_key = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||
channel = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
|
||||
anon_id = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true),
|
||||
created_at = table.Column<DateTime>(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");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "play_counter");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "play_event");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "share_event");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -67,6 +67,94 @@ namespace DeepDrftData.Migrations
|
||||
b.ToTable("mix_metadata", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.PlayCounter", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<long>("CompleteCount")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasDefaultValue(0L)
|
||||
.HasColumnName("complete_count");
|
||||
|
||||
b.Property<long>("PartialCount")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasDefaultValue(0L)
|
||||
.HasColumnName("partial_count");
|
||||
|
||||
b.Property<long>("SampledCount")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasDefaultValue(0L)
|
||||
.HasColumnName("sampled_count");
|
||||
|
||||
b.Property<long>("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<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<string>("AnonId")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)")
|
||||
.HasColumnName("anon_id");
|
||||
|
||||
b.Property<string>("Bucket")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasColumnName("bucket");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<long?>("ReleaseId")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("release_id");
|
||||
|
||||
b.Property<string>("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<long>("Id")
|
||||
@@ -209,6 +297,53 @@ namespace DeepDrftData.Migrations
|
||||
b.ToTable("session_metadata", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.ShareEvent", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<string>("AnonId")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)")
|
||||
.HasColumnName("anon_id");
|
||||
|
||||
b.Property<string>("Channel")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasColumnName("channel");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("TargetKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("target_key");
|
||||
|
||||
b.Property<string>("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<long>("Id")
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
using DeepDrftData.Data;
|
||||
using DeepDrftModels.Entities;
|
||||
using DeepDrftModels.Enums;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace DeepDrftData.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Data access for the Phase 16 anonymous telemetry tables (all SQL — the FileDatabase vault is not
|
||||
/// involved). Owns the append-only writes to <c>play_event</c> / <c>share_event</c> and the
|
||||
/// incremental-on-write bump of the <c>play_counter</c> 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.
|
||||
///
|
||||
/// <para>
|
||||
/// Unlike <see cref="TrackRepository"/> these entities are not <c>BaseEntity</c>/<c>IEntity</c> (no
|
||||
/// soft-delete lifecycle), so this is a plain context-backed repository rather than an extension of the
|
||||
/// BlazorBlocks <c>Repository<></c> base. It holds the same scoped <see cref="DeepDrftContext"/>
|
||||
/// the rest of the SQL layer uses, never a service locator.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class EventRepository
|
||||
{
|
||||
private readonly DeepDrftContext _context;
|
||||
|
||||
public EventRepository(DeepDrftContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public async Task<bool> 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();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Append one share event. No rollup table for shares in wave 16.1 — a plain insert.</summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using DeepDrftModels.Enums;
|
||||
|
||||
namespace DeepDrftModels.DTOs;
|
||||
|
||||
/// <summary>
|
||||
/// Wire payload for <c>POST api/event/play</c> (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). <see cref="AnonId"/> is reserved for wave 16.3 and stays null in wave 16.1.
|
||||
/// </summary>
|
||||
public class PlayEventDto
|
||||
{
|
||||
public string? TrackEntryKey { get; set; }
|
||||
|
||||
public PlayBucket Bucket { get; set; }
|
||||
|
||||
public string? AnonId { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using DeepDrftModels.Enums;
|
||||
|
||||
namespace DeepDrftModels.DTOs;
|
||||
|
||||
/// <summary>
|
||||
/// Wire payload for <c>POST api/event/share</c> (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.
|
||||
/// <see cref="AnonId"/> is reserved for wave 16.3 and stays null in wave 16.1.
|
||||
/// </summary>
|
||||
public class ShareEventDto
|
||||
{
|
||||
public ShareTargetType TargetType { get; set; }
|
||||
|
||||
public string? TargetKey { get; set; }
|
||||
|
||||
public ShareChannel Channel { get; set; }
|
||||
|
||||
public string? AnonId { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
namespace DeepDrftModels.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Incremental rollup of play counts per track (Phase 16 §4.1 / D6). One row per track, bumped inside
|
||||
/// the same transaction that appends the <see cref="PlayEvent"/> — no background aggregation job. The
|
||||
/// home card and per-target reads sum these instead of <c>COUNT(*)</c>-ing the event log on every
|
||||
/// landing. Release totals are <em>derived</em> (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.
|
||||
/// </summary>
|
||||
public class PlayCounter
|
||||
{
|
||||
public long Id { get; set; }
|
||||
|
||||
/// <summary>The track these counts belong to (SQL id). Unique — one counter row per track.</summary>
|
||||
public long TrackId { get; set; }
|
||||
|
||||
/// <summary>Count of plays that ended in the <c>Partial</c> bucket (< 30%).</summary>
|
||||
public long PartialCount { get; set; }
|
||||
|
||||
/// <summary>Count of plays that ended in the <c>Sampled</c> bucket (30%–80%).</summary>
|
||||
public long SampledCount { get; set; }
|
||||
|
||||
/// <summary>Count of plays that ended in the <c>Complete</c> bucket (> 80%).</summary>
|
||||
public long CompleteCount { get; set; }
|
||||
|
||||
/// <summary>Total plays for the track — the sum of the three bucket counts (headline figure).</summary>
|
||||
public long TotalPlays => PartialCount + SampledCount + CompleteCount;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using DeepDrftModels.Enums;
|
||||
|
||||
namespace DeepDrftModels.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// 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 <c>BaseEntity</c>: events
|
||||
/// have no soft-delete lifecycle, no <c>UpdatedAt</c> — 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.
|
||||
/// </summary>
|
||||
public class PlayEvent
|
||||
{
|
||||
public long Id { get; set; }
|
||||
|
||||
/// <summary>The played track's vault entry key (the only target the client sends).</summary>
|
||||
public required string TrackEntryKey { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The owning release's SQL id, resolved from <see cref="TrackEntryKey"/> at write time. Null when
|
||||
/// the track is loose (no release) or the key did not resolve to a live track at write time.
|
||||
/// </summary>
|
||||
public long? ReleaseId { get; set; }
|
||||
|
||||
/// <summary>The completion bucket computed client-side from the high-water fraction (§1a / D1).</summary>
|
||||
public PlayBucket Bucket { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public string? AnonId { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
using DeepDrftModels.Enums;
|
||||
|
||||
namespace DeepDrftModels.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// 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 <see cref="PlayEvent"/>
|
||||
/// it is deliberately NOT a <c>BaseEntity</c> — 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.
|
||||
/// </summary>
|
||||
public class ShareEvent
|
||||
{
|
||||
public long Id { get; set; }
|
||||
|
||||
/// <summary>Whether the share targets a track or a release.</summary>
|
||||
public ShareTargetType TargetType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The shared target's key: a track's vault <c>EntryKey</c> or a release's public <c>EntryKey</c>,
|
||||
/// selected by <see cref="TargetType"/>. 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.
|
||||
/// </summary>
|
||||
public required string TargetKey { get; set; }
|
||||
|
||||
/// <summary>The channel the share was performed through (link vs. embed).</summary>
|
||||
public ShareChannel Channel { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Anonymous listener token (Phase 16 §3, wave 16.3). Reserved nullable; unused in wave 16.1.
|
||||
/// </summary>
|
||||
public string? AnonId { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace DeepDrftModels.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// 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:
|
||||
/// <c>Partial</c> [0, 30%), <c>Sampled</c> [30%, 80%], <c>Complete</c> (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.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<PlayBucket>))]
|
||||
public enum PlayBucket
|
||||
{
|
||||
/// <summary>Reached < 30% of duration — a skip or a brief partial listen (still past the floor).</summary>
|
||||
Partial,
|
||||
|
||||
/// <summary>Reached 30%–80% of duration — a real listen that was neither a skip nor a finish.</summary>
|
||||
Sampled,
|
||||
|
||||
/// <summary>Reached > 80% of duration — effectively a finished listen.</summary>
|
||||
Complete
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace DeepDrftModels.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// The channel a share was performed through (Phase 16 §1b). Today both originate from
|
||||
/// <c>SharePopover</c>'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.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<ShareChannel>))]
|
||||
public enum ShareChannel
|
||||
{
|
||||
/// <summary>Copy-link — the canonical track or release URL placed on the clipboard.</summary>
|
||||
Link,
|
||||
|
||||
/// <summary>Copy-embed — the <c><iframe></c> snippet for the single-track FramePlayer.</summary>
|
||||
Embed
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<ShareTargetType>))]
|
||||
public enum ShareTargetType
|
||||
{
|
||||
/// <summary>The share targets a single track, addressed by its vault <c>EntryKey</c>.</summary>
|
||||
Track,
|
||||
|
||||
/// <summary>The share targets a release, addressed by its public <c>EntryKey</c>.</summary>
|
||||
Release
|
||||
}
|
||||
@@ -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<StreamingAudioPlayerService> 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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
protected virtual void OnProgressTick(double currentTime) { }
|
||||
|
||||
/// <summary>
|
||||
/// Telemetry seam (Phase 16): called on organic end-of-stream, before <see cref="TrackEnded"/> fires.
|
||||
/// The streaming subclass overrides this to close the play session. No-op in the base.
|
||||
/// </summary>
|
||||
protected virtual void OnPlaybackEnded() { }
|
||||
|
||||
|
||||
protected async Task EnsureInitializedAsync()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
using Microsoft.JSInterop;
|
||||
|
||||
namespace DeepDrftPublic.Client.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Thin C# wrapper over the <c>window.DeepDrftBeacon</c> TS interop (Phase 16 §2.2). Wraps the
|
||||
/// <c>navigator.sendBeacon</c> POST and the page-unload registration so the rest of the client never
|
||||
/// touches <see cref="IJSRuntime"/> 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.
|
||||
/// </summary>
|
||||
public sealed class BeaconInterop
|
||||
{
|
||||
private readonly IJSRuntime _js;
|
||||
|
||||
public BeaconInterop(IJSRuntime js)
|
||||
{
|
||||
_js = js;
|
||||
}
|
||||
|
||||
/// <summary>Queue a fire-and-forget POST of a JSON body to the given absolute URL.</summary>
|
||||
public async Task SendAsync(string url, string json)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _js.InvokeAsync<bool>("DeepDrftBeacon.send", url, json);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Module not loaded / not interactive yet — drop the event silently.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Register a .NET unload callback (fires on pagehide / visibility→hidden) under a key.</summary>
|
||||
public async Task RegisterUnloadAsync<T>(string key, DotNetObjectReference<T> 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.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Detach a previously-registered unload callback.</summary>
|
||||
public async Task UnregisterUnloadAsync(string key)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _js.InvokeVoidAsync("DeepDrftBeacon.unregisterUnload", key);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Disposal best-effort.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using System.Text.Json;
|
||||
using DeepDrftModels.DTOs;
|
||||
using DeepDrftModels.Enums;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace DeepDrftPublic.Client.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Production <see cref="IPlayEventSink"/> (Phase 16 §2.2): serializes the play classification and fires
|
||||
/// it via <c>navigator.sendBeacon</c> to the proxied <c>api/event/play</c> route. Fire-and-forget by
|
||||
/// design — <see cref="IPlayEventSink.EmitPlay"/> 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 <c>anonId</c> is sent in wave 16.1.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using DeepDrftModels.Enums;
|
||||
|
||||
namespace DeepDrftPublic.Client.Services;
|
||||
|
||||
/// <summary>
|
||||
/// The emit seam for the <see cref="PlayTracker"/> (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 <c>sendBeacon</c> POST to
|
||||
/// <c>api/event/play</c>; 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.
|
||||
/// </summary>
|
||||
public interface IPlayEventSink
|
||||
{
|
||||
/// <summary>Emit one recorded play. Called at most once per session, only when the floor is crossed.</summary>
|
||||
void EmitPlay(string trackEntryKey, PlayBucket bucket);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
using DeepDrftModels.Enums;
|
||||
|
||||
namespace DeepDrftPublic.Client.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
///
|
||||
/// <para>
|
||||
/// Deliberately free of any player, HTTP, or JS dependency: it takes an <see cref="IPlayEventSink"/> 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).
|
||||
/// </para>
|
||||
///
|
||||
/// <para>Not thread-safe: the WASM dispatcher is single-threaded, and every call originates there.</para>
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>True while a session is open (playback started, not yet closed). Drives the unload beacon.</summary>
|
||||
public bool HasOpenSession => _trackEntryKey is not null && !_closed;
|
||||
|
||||
/// <summary>
|
||||
/// 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 <see cref="Close"/> still records the
|
||||
/// prior listen. Duration is unknown at open and arrives later via <see cref="SetDuration"/>.
|
||||
/// </summary>
|
||||
public void OnPlaybackStarted(string trackEntryKey)
|
||||
{
|
||||
if (HasOpenSession)
|
||||
Close();
|
||||
|
||||
_trackEntryKey = trackEntryKey;
|
||||
_duration = null;
|
||||
_highWater = 0;
|
||||
_closed = false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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 <c>Duration</c> exactly once.
|
||||
/// </summary>
|
||||
public void SetDuration(double durationSeconds)
|
||||
{
|
||||
if (!HasOpenSession) return;
|
||||
if (_duration is null && durationSeconds > 0)
|
||||
_duration = durationSeconds;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
public void OnProgress(double currentTime)
|
||||
{
|
||||
if (!HasOpenSession) return;
|
||||
if (currentTime > _highWater)
|
||||
_highWater = currentTime;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
using System.Text.Json;
|
||||
using DeepDrftModels.DTOs;
|
||||
using DeepDrftModels.Enums;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace DeepDrftPublic.Client.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Records share events from <c>SharePopover</c> (Phase 16 §1b / §2.1). After a successful clipboard
|
||||
/// write the popover calls <see cref="RecordShare"/>; this tracker applies the per-(target,channel)
|
||||
/// debounce — at most one event per target+channel per <see cref="DebounceWindow"/> per session — and
|
||||
/// fires the event via <c>navigator.sendBeacon</c> to the proxied <c>api/event/share</c> route.
|
||||
///
|
||||
/// <para>
|
||||
/// 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 <c>anonId</c> is sent in wave 16.1.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
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<string, DateTimeOffset> _lastSent = new();
|
||||
|
||||
public ShareTracker(BeaconInterop beacon, NavigationManager navigation)
|
||||
{
|
||||
_beacon = beacon;
|
||||
_shareUrl = $"{navigation.BaseUri}api/event/share";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public bool RecordShare(ShareTargetType targetType, string targetKey, ShareChannel channel)
|
||||
=> RecordShare(targetType, targetKey, channel, DateTimeOffset.UtcNow);
|
||||
|
||||
/// <summary>
|
||||
/// Debounce-aware record with an injectable <paramref name="now"/> so the 60s window is testable
|
||||
/// without wall-clock waits. The parameterless overload above passes <see cref="DateTimeOffset.UtcNow"/>.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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<StreamingAudioPlayerService> _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<StreamingAudioPlayerService>? _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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wire the play-session tracker and beacon transport into the player after construction (Phase 16
|
||||
/// §2.1). Called once by <c>AudioPlayerProvider</c>. Kept off the constructor deliberately: the player
|
||||
/// is built with <c>new</c> 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.
|
||||
/// </summary>
|
||||
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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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. <see cref="PlayTracker.Close"/> is idempotent, so a later organic close is a no-op.
|
||||
/// </summary>
|
||||
[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,6 +359,10 @@ 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
|
||||
@@ -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
|
||||
/// </summary>
|
||||
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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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<WaveformVisualizerControlState>();
|
||||
|
||||
// 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<BeaconInterop>();
|
||||
services.AddScoped<IPlayEventSink, BeaconPlayEventSink>();
|
||||
services.AddScoped<ShareTracker>();
|
||||
}
|
||||
|
||||
public static void ConfigureApiHttpClient(IServiceCollection services, string baseAddress)
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
<script src=@Assets["_content/MudBlazor/MudBlazor.min.js"]></script>
|
||||
<script type="module">
|
||||
import('./js/audio/index.js');
|
||||
import('./js/telemetry/beacon.js');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
using System.Text;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace DeepDrftPublic.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Proxies the anonymous telemetry write endpoints (<c>POST api/event/play</c> / <c>api/event/share</c>)
|
||||
/// to DeepDrftAPI so the WASM client never makes a cross-origin request (Phase 16 §2.2). Mirrors
|
||||
/// <see cref="TrackProxyController"/>'s idiom — the named <c>"DeepDrft.API"</c> 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.
|
||||
/// </summary>
|
||||
// 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<EventProxyController> _logger;
|
||||
|
||||
public EventProxyController(IHttpClientFactory httpClientFactory, ILogger<EventProxyController> logger)
|
||||
{
|
||||
_upstream = httpClientFactory.CreateClient("DeepDrft.API");
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>Proxies a play event upstream. Body is opaque JSON — validated by DeepDrftAPI, not here.</summary>
|
||||
[HttpPost("play")]
|
||||
public Task<ActionResult> ForwardPlay(CancellationToken ct = default) => Forward("api/event/play", ct);
|
||||
|
||||
/// <summary>Proxies a share event upstream.</summary>
|
||||
[HttpPost("share")]
|
||||
public Task<ActionResult> ForwardShare(CancellationToken ct = default) => Forward("api/event/share", ct);
|
||||
|
||||
private async Task<ActionResult> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<unknown>;
|
||||
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<string, UnloadEntry>();
|
||||
|
||||
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 };
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Storage-layer tests for the Phase 16 telemetry writes (<see cref="EventRepository"/>): 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 <see cref="HomeStatsQueryTests"/>. 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.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class PlayEventQueryTests
|
||||
{
|
||||
private DeepDrftContext _context = null!;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<DeepDrftContext>()
|
||||
.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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
using DeepDrftModels.Enums;
|
||||
using DeepDrftPublic.Client.Services;
|
||||
|
||||
namespace DeepDrftTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the Phase 16 play-session tracker (<see cref="PlayTracker"/>): 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.
|
||||
/// </summary>
|
||||
[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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
using DeepDrftModels.Enums;
|
||||
using DeepDrftPublic.Client.Services;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.JSInterop;
|
||||
using Microsoft.JSInterop.Infrastructure;
|
||||
|
||||
namespace DeepDrftTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the Phase 16 share tracker (<see cref="ShareTracker"/>): 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 <see cref="IJSRuntime"/>; 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.
|
||||
/// </summary>
|
||||
[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<TValue> InvokeAsync<TValue>(string identifier, object?[]? args)
|
||||
=> ValueTask.FromResult<TValue>(default!);
|
||||
|
||||
public ValueTask<TValue> InvokeAsync<TValue>(string identifier, CancellationToken cancellationToken, object?[]? args)
|
||||
=> ValueTask.FromResult<TValue>(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);
|
||||
}
|
||||
Reference in New Issue
Block a user