Merge nowplaying-stats into dev (live home-hero aggregate stats + track duration column)

This commit is contained in:
daniel-c-harvey
2026-06-18 12:58:54 -04:00
26 changed files with 1120 additions and 9 deletions
@@ -0,0 +1,36 @@
using DeepDrftData;
using Microsoft.AspNetCore.Mvc;
namespace DeepDrftAPI.Controllers;
[ApiController]
[Route("api/[controller]")]
public class StatsController : ControllerBase
{
private readonly ITrackService _sqlTrackService;
private readonly ILogger<StatsController> _logger;
public StatsController(ITrackService sqlTrackService, ILogger<StatsController> logger)
{
_sqlTrackService = sqlTrackService;
_logger = logger;
}
// GET api/stats/home (unauthenticated)
// Aggregate figures behind the public home hero stat row — one read for all three cards. Same auth
// posture as the other public browse reads (GET api/track/page). The aggregation lives in the SQL
// service/repository; this controller stays a thin HTTP boundary.
[HttpGet("home")]
public async Task<ActionResult> GetHome(CancellationToken ct = default)
{
var result = await _sqlTrackService.GetHomeStats(ct);
if (!result.Success || result.Value is null)
{
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
_logger.LogError("GetHome stats failed: {Error}", error);
return StatusCode(500, "Failed to load stats");
}
return Ok(result.Value);
}
}
@@ -173,6 +173,26 @@ public class TrackController : ControllerBase
return Ok(status);
}
// POST api/track/duration/backfill ([ApiKeyAuthorize], no body)
// One-time admin backfill: for every track whose SQL duration is still null, read the duration from
// the vault audio and write it to SQL. Mirrors the waveform backfill posture. Idempotent — a re-run
// only touches still-missing rows. Returns { updated, skipped }. Declared in the literal-route block
// (before "{trackId}") so the segment is never treated as a trackId.
[ApiKeyAuthorize]
[HttpPost("duration/backfill")]
public async Task<ActionResult> BackfillDurations(CancellationToken cancellationToken)
{
var result = await _unifiedService.BackfillDurationsAsync(cancellationToken);
if (!result.Success)
{
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
_logger.LogError("BackfillDurations failed: {Error}", error);
return StatusCode(500, error);
}
return Ok(new { updated = result.Value.Updated, skipped = result.Value.Skipped });
}
// POST api/track/upload: raw audio in (multipart/form-data) + metadata → persisted TrackDto out.
// Accepts .wav, .mp3, and .flac. Used by the CMS upload flow on DeepDrftManager; that host
// proxies the upload here so it never touches the vault disk path or SQL directly.
@@ -193,6 +193,54 @@ public class UnifiedTrackService
}
}
/// <summary>
/// One-time backfill: for every non-deleted track whose SQL duration is still null, read the
/// processor-extracted runtime from the vault audio (by EntryKey) and write it to SQL. The migration
/// cannot read the vault, so this runs at runtime after deploy. Idempotent — a re-run only touches
/// rows still missing a duration. Returns (updated, skipped) counts. A per-track vault miss or SQL
/// failure is logged and skipped, never aborting the batch.
/// </summary>
public async Task<ResultContainer<(int Updated, int Skipped)>> BackfillDurationsAsync(CancellationToken ct)
{
var missing = await _sqlTrackService.GetTracksMissingDuration(ct);
if (!missing.Success || missing.Value is null)
{
var error = missing.Messages.FirstOrDefault()?.Message ?? "Unknown error";
_logger.LogError("BackfillDurationsAsync: failed to load tracks missing duration: {Error}", error);
return ResultContainer<(int, int)>.CreateFailResult($"Could not load tracks: {error}");
}
var updated = 0;
var skipped = 0;
foreach (var track in missing.Value)
{
ct.ThrowIfCancellationRequested();
var audio = await _contentTrackContentService.GetAudioBinaryAsync(track.EntryKey);
if (audio is null)
{
_logger.LogWarning("BackfillDurationsAsync: no vault audio for {EntryKey} (track {Id}); skipping.",
track.EntryKey, track.Id);
skipped++;
continue;
}
var write = await _sqlTrackService.UpdateDuration(track.Id, audio.Duration, ct);
if (!write.Success)
{
var error = write.Messages.FirstOrDefault()?.Message ?? "Unknown error";
_logger.LogWarning("BackfillDurationsAsync: SQL update failed for track {Id}: {Error}", track.Id, error);
skipped++;
continue;
}
updated++;
}
_logger.LogInformation("BackfillDurationsAsync complete: {Updated} updated, {Skipped} skipped.", updated, skipped);
return ResultContainer<(int, int)>.CreatePassResult((updated, skipped));
}
/// <summary>
/// Delete a track's SQL row, then its vault entry. SQL is the source of truth: a SQL delete
/// failure fails the operation (and leaves the vault untouched), but a subsequent vault delete
+4 -1
View File
@@ -74,7 +74,10 @@ public class TrackContentService
{
EntryKey = trackId, // FileDatabase entry ID
TrackName = trackName,
OriginalFileName = originalFileName
OriginalFileName = originalFileName,
// Persist the processor-extracted runtime to SQL so aggregate queries (total mix runtime)
// need not touch the vault. Same value the high-res waveform compute reads downstream.
DurationSeconds = audioBinary.Duration
};
return trackEntity;
@@ -39,6 +39,10 @@ public class TrackConfiguration : BaseEntityConfiguration<TrackEntity>
.HasColumnName("track_number")
.HasDefaultValue(1);
// Nullable: existing rows carry NULL until the one-time duration backfill populates them.
builder.Property(e => e.DurationSeconds)
.HasColumnName("duration_seconds");
builder.Property(e => e.ReleaseId)
.HasColumnName("release_id");
+19
View File
@@ -28,6 +28,25 @@ public interface ITrackService
/// <summary>Distinct non-null genres with track counts, genre-ascending.</summary>
Task<ResultContainer<List<GenreSummaryDto>>> GetDistinctGenres(CancellationToken cancellationToken = default);
/// <summary>
/// Aggregate figures behind the public home hero stat row: Cut track count + per-ReleaseType Cut
/// release breakdown, Mix release count, and total Mix runtime in seconds. One read for all three cards.
/// </summary>
Task<ResultContainer<HomeStatsDto>> GetHomeStats(CancellationToken cancellationToken = default);
/// <summary>
/// Non-deleted tracks whose SQL duration is still null — the work list for the one-time duration
/// backfill. The backfill reads each track's stored duration from the vault and writes it via
/// <see cref="UpdateDuration"/>.
/// </summary>
Task<ResultContainer<List<TrackDto>>> GetTracksMissingDuration(CancellationToken cancellationToken = default);
/// <summary>
/// Set the SQL duration for one track. Idempotent: a track whose duration is already set is left
/// untouched. Backs the duration backfill. Returns the number of rows updated (0 or 1).
/// </summary>
Task<ResultContainer<int>> UpdateDuration(long id, double durationSeconds, CancellationToken cancellationToken = default);
/// <summary>
/// Resolve the release matching <paramref name="title"/> + <paramref name="artist"/>, creating
/// one from <paramref name="releaseData"/> when none exists. Backs the upload flow's FK
@@ -0,0 +1,322 @@
// <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("20260618155002_AddTrackDuration")]
partial class AddTrackDuration
{
/// <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.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.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,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DeepDrftData.Migrations
{
/// <inheritdoc />
public partial class AddTrackDuration : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<double>(
name: "duration_seconds",
table: "track",
type: "double precision",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "duration_seconds",
table: "track");
}
}
}
@@ -222,6 +222,10 @@ namespace DeepDrftData.Migrations
.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)
@@ -3,6 +3,7 @@ using Data.Errors;
using DeepDrftData.Data;
using DeepDrftModels.DTOs;
using DeepDrftModels.Entities;
using DeepDrftModels.Enums;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Models.Common;
@@ -157,6 +158,58 @@ public class TrackRepository : Repository<DeepDrftContext, TrackEntity>
.Select(g => new { ReleaseId = g.Key, Count = g.Count() })
.ToDictionaryAsync(x => x.ReleaseId, x => x.Count, ct);
// Aggregate figures for the public home hero stat row, assembled in as few round-trips as is clean.
// All counts go through Query (!t.IsDeleted) plus an explicit !t.Release.IsDeleted guard so tracks
// under a directly-deleted release are also excluded. Mix runtime sums DurationSeconds with a
// null-coalesce to 0 so not-yet-backfilled rows contribute zero rather than throwing or skewing the
// total. The cut release-type breakdown is grouped here so a zero-count type is simply absent from
// the result (no present-with-zero row).
public async Task<HomeStatsDto> GetHomeStatsAsync(CancellationToken ct = default)
{
var releases = _context.Set<ReleaseEntity>().Where(r => !r.IsDeleted);
var cutTrackCount = await Query
.CountAsync(t => t.Release != null && !t.Release.IsDeleted && t.Release.Medium == ReleaseMedium.Cut, ct);
var cutReleaseTypeCounts = await releases
.Where(r => r.Medium == ReleaseMedium.Cut)
.GroupBy(r => r.ReleaseType)
.Select(g => new CutReleaseTypeCount { ReleaseType = g.Key, Count = g.Count() })
.ToListAsync(ct);
var mixReleaseCount = await releases
.CountAsync(r => r.Medium == ReleaseMedium.Mix, ct);
var mixRuntimeSeconds = await Query
.Where(t => t.Release != null && !t.Release.IsDeleted && t.Release.Medium == ReleaseMedium.Mix)
.SumAsync(t => t.DurationSeconds ?? 0d, ct);
return new HomeStatsDto
{
CutTrackCount = cutTrackCount,
CutReleaseTypeCounts = cutReleaseTypeCounts,
MixReleaseCount = mixReleaseCount,
MixRuntimeSeconds = mixRuntimeSeconds,
};
}
// EntryKey + stored duration for non-deleted tracks whose SQL duration is still null — the work list
// the one-time duration backfill iterates. The migration cannot read the vault, so duration is filled
// at runtime: this lists which rows still need it, the backfill reads each from the vault and writes
// it back via UpdateDurationAsync.
public async Task<List<TrackEntity>> GetTracksMissingDurationAsync(CancellationToken ct = default)
=> await Query.Where(t => t.DurationSeconds == null).ToListAsync(ct);
// Set-based duration write for one track (no load round-trip), used by the backfill. The
// DurationSeconds == null guard keeps a re-run from re-stamping updated_at on an already-filled row
// and from clobbering a value the upload path may have set in the meantime.
public async Task<int> UpdateDurationAsync(long id, double durationSeconds, CancellationToken ct = default)
=> await Query
.Where(t => t.Id == id && t.DurationSeconds == null)
.ExecuteUpdateAsync(s => s
.SetProperty(t => t.DurationSeconds, durationSeconds)
.SetProperty(t => t.UpdatedAt, DateTime.UtcNow), ct);
// Resolve an existing release by its natural key (title + artist). Returns null when no match,
// signalling the manager to create one. Soft-deleted releases never match.
public async Task<ReleaseEntity?> GetReleaseByTitleAndArtistAsync(
@@ -211,6 +264,7 @@ public class TrackRepository : Repository<DeepDrftContext, TrackEntity>
target.TrackName = source.TrackName;
target.TrackNumber = source.TrackNumber;
target.OriginalFileName = source.OriginalFileName;
target.DurationSeconds = source.DurationSeconds;
target.ReleaseId = source.ReleaseId;
}
}
+2
View File
@@ -80,6 +80,7 @@ public class TrackConverter : IEntityToModelConverter<TrackEntity, TrackDto>
TrackName = entity.TrackName,
OriginalFileName = entity.OriginalFileName,
TrackNumber = entity.TrackNumber,
DurationSeconds = entity.DurationSeconds,
ReleaseId = entity.ReleaseId,
Release = entity.Release is null ? null : Convert(entity.Release)
};
@@ -96,6 +97,7 @@ public class TrackConverter : IEntityToModelConverter<TrackEntity, TrackDto>
TrackName = model.TrackName,
OriginalFileName = model.OriginalFileName,
TrackNumber = model.TrackNumber,
DurationSeconds = model.DurationSeconds,
ReleaseId = model.ReleaseId
};
}
+40
View File
@@ -236,6 +236,46 @@ public class TrackManager
}
}
public async Task<ResultContainer<HomeStatsDto>> GetHomeStats(CancellationToken cancellationToken = default)
{
try
{
var stats = await Repository.GetHomeStatsAsync(cancellationToken);
return ResultContainer<HomeStatsDto>.CreatePassResult(stats);
}
catch (Exception e)
{
return ResultContainer<HomeStatsDto>.CreateFailResult(e.Message);
}
}
public async Task<ResultContainer<List<TrackDto>>> GetTracksMissingDuration(CancellationToken cancellationToken = default)
{
try
{
var entities = await Repository.GetTracksMissingDurationAsync(cancellationToken);
return ResultContainer<List<TrackDto>>.CreatePassResult(
entities.Select(TrackConverter.Convert).ToList());
}
catch (Exception e)
{
return ResultContainer<List<TrackDto>>.CreateFailResult(e.Message);
}
}
public async Task<ResultContainer<int>> UpdateDuration(long id, double durationSeconds, CancellationToken cancellationToken = default)
{
try
{
var updated = await Repository.UpdateDurationAsync(id, durationSeconds, cancellationToken);
return ResultContainer<int>.CreatePassResult(updated);
}
catch (Exception e)
{
return ResultContainer<int>.CreateFailResult(e.Message);
}
}
public async Task<ResultContainer<TrackDto>> Create(TrackDto newTrack)
{
try
+37
View File
@@ -0,0 +1,37 @@
using DeepDrftModels.Enums;
namespace DeepDrftModels.DTOs;
/// <summary>
/// Aggregate figures behind the public home hero stat row (NowPlayingStats). A single read returns
/// everything the three cards need so the client makes one round-trip. All counts exclude soft-deleted
/// rows. The Plays card is a static placeholder and has no field here.
/// </summary>
public class HomeStatsDto
{
/// <summary>Total non-deleted tracks whose release is the Cut medium. The Studio Cuts card's primary figure.</summary>
public int CutTrackCount { get; set; }
/// <summary>
/// Per-ReleaseType counts of non-deleted Cut releases. Only types with at least one release are
/// present — a zero-count type is absent from the list (the card suppresses it). The Studio Cuts
/// card's secondary breakdown.
/// </summary>
public List<CutReleaseTypeCount> CutReleaseTypeCounts { get; set; } = [];
/// <summary>Total non-deleted releases of the Mix medium. The Mixes card's primary figure ("N Sets").</summary>
public int MixReleaseCount { get; set; }
/// <summary>
/// Sum of DurationSeconds across all non-deleted tracks on Mix releases. Tracks with a null
/// duration (not yet backfilled) contribute 0. The Mixes card's secondary figure, rendered hh:mm.
/// </summary>
public double MixRuntimeSeconds { get; set; }
}
/// <summary>One row of the Cut release-type breakdown: a ReleaseType and how many Cut releases have it.</summary>
public class CutReleaseTypeCount
{
public ReleaseType ReleaseType { get; set; }
public int Count { get; set; }
}
+1
View File
@@ -16,6 +16,7 @@ public class TrackDto : BaseModel
public string TrackName { get; set; } = string.Empty;
public string? OriginalFileName { get; set; }
public int TrackNumber { get; set; } = 1;
public double? DurationSeconds { get; set; }
public long? ReleaseId { get; set; }
public ReleaseDto? Release { get; set; }
}
+4
View File
@@ -15,6 +15,10 @@ public class TrackEntity : BaseEntity, IEntity
public required string TrackName { get; set; }
public string? OriginalFileName { get; set; }
public int TrackNumber { get; set; } = 1;
// Audio runtime in seconds, extracted by the processor at upload (AudioBinary.Duration) and
// persisted here so aggregate queries (e.g. total mix runtime) read it from SQL rather than the
// vault. Nullable: rows that predate this column are valid until the one-time backfill populates them.
public double? DurationSeconds { get; set; }
public long? ReleaseId { get; set; }
public ReleaseEntity? Release { get; set; }
}
@@ -0,0 +1,39 @@
using DeepDrftModels.DTOs;
using NetBlocks.Models;
using System.Text.Json;
namespace DeepDrftPublic.Client.Clients;
/// <summary>
/// HTTP client for the public stats read surface. Uses the named <c>"DeepDrft.API"</c> client like
/// <see cref="TrackClient"/> and <see cref="ReleaseClient"/>: on WASM it points at the public host and
/// proxies through <c>StatsProxyController</c>; on SSR prerender it points directly at DeepDrftAPI. The
/// route is an unauthenticated read; the response deserializes as a bare DTO (no ApiResultDto envelope),
/// matching the API's <c>Ok(value)</c> shape.
/// </summary>
public class StatsClient
{
private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true };
private readonly HttpClient _http;
public StatsClient(IHttpClientFactory httpClientFactory)
{
_http = httpClientFactory.CreateClient("DeepDrft.API");
}
public async Task<ApiResult<HomeStatsDto>> GetHomeStats()
{
var response = await _http.GetAsync("api/stats/home");
if (!response.IsSuccessStatusCode)
return ApiResult<HomeStatsDto>.CreateFailResult($"HTTP {(int)response.StatusCode}");
var json = await response.Content.ReadAsStringAsync();
var stats = JsonSerializer.Deserialize<HomeStatsDto>(json, JsonOptions);
return stats is not null
? ApiResult<HomeStatsDto>.CreatePassResult(stats)
: ApiResult<HomeStatsDto>.CreateFailResult("Failed to deserialize response");
}
}
@@ -31,7 +31,8 @@
<div class="now-playing-content">
<NowPlayingCard />
@* Stat row - hard-coded for now. TODO Phase 2: wire to real track count / identity model. *@
@* Stat row — live aggregate figures (Cut track count + type breakdown, Mix sets + runtime);
the Plays card is a static placeholder pending real play tracking. *@
<NowPlayingStats />
</div>
</div>
@@ -1,14 +1,94 @@
@using DeepDrftModels.DTOs
@using DeepDrftModels.Enums
@using DeepDrftPublic.Client.Helpers
@using DeepDrftPublic.Client.Services
@implements IDisposable
<div class="hero-stat-row">
@* Studio Cuts — primary figure is the total Cut-medium track count; the secondary breakdown lists
per-ReleaseType Cut release counts, zero-count types already suppressed server-side. *@
<div class="hero-stat">
<div class="hero-stat-num">47+</div>
<div class="hero-stat-label">Live Sessions</div>
<div class="hero-stat-num">@_stats.CutTrackCount</div>
<div class="hero-stat-label">Studio Cuts</div>
@if (_stats.CutReleaseTypeCounts.Count > 0)
{
<div class="hero-stat-breakdown">
@foreach (var row in _stats.CutReleaseTypeCounts)
{
<span class="hero-stat-breakdown-item">@row.Count @PluralizeReleaseType(row.ReleaseType, row.Count)</span>
}
</div>
}
</div>
@* Mixes — primary figure is the Mix release count labelled "Sets"; the secondary figure is total
mix runtime as hh:mm. *@
<div class="hero-stat">
<div class="hero-stat-num">2</div>
<div class="hero-stat-label">Members</div>
<div class="hero-stat-num">@_stats.MixReleaseCount</div>
<div class="hero-stat-label">Sets</div>
<div class="hero-stat-sub">@RuntimeFormat.ToHoursMinutes(_stats.MixRuntimeSeconds) runtime</div>
</div>
@* Plays — static placeholder (real play/share tracking is a future phase). Odometer treatment over
the existing card style; copy is placeholder pending sign-off. *@
<div class="hero-stat">
<div class="hero-stat-num">&infin;</div>
<div class="hero-stat-label">Drift Points</div>
<div class="hero-stat-num hero-stat-odometer">XXX</div>
<div class="hero-stat-label">Plays (Coming Soon)</div>
</div>
</div>
</div>
@code {
[Inject] public required IStatsDataService StatsData { get; set; }
[Inject] public required PersistentComponentState PersistentState { get; set; }
private const string PersistKey = "home-stats";
private HomeStatsDto _stats = new();
private bool _loaded;
private PersistingComponentStateSubscription _persistingSubscription;
protected override async Task OnInitializedAsync()
{
_persistingSubscription = PersistentState.RegisterOnPersisting(Persist);
// Bridge the prerendered fetch across the prerender -> WASM seam so the WASM boot does not
// re-fetch and flicker the figures (the TracksView persistent-state seam, applied to stats).
if (PersistentState.TryTakeFromJson<HomeStatsDto>(PersistKey, out var restored) && restored is not null)
{
_stats = restored;
_loaded = true;
return;
}
var result = await StatsData.GetHomeStats();
if (result is { Success: true, Value: { } stats })
{
_stats = stats;
_loaded = true;
}
}
// Only bridge a successful fetch. If prerender failed, persist nothing so the WASM pass re-fetches
// rather than restoring zeros — mirrors the guard on the medium-browse persist path.
private Task Persist()
{
if (_loaded)
PersistentState.PersistAsJson(PersistKey, _stats);
return Task.CompletedTask;
}
private static string PluralizeReleaseType(ReleaseType type, int count)
{
var label = type switch
{
ReleaseType.Single => "Single",
ReleaseType.EP => "EP",
ReleaseType.Album => "Album",
_ => type.ToString()
};
// EP pluralizes as "EPs"; Single/Album take a plain trailing s.
return count == 1 ? label : label + "s";
}
public void Dispose() => _persistingSubscription.Dispose();
}
@@ -27,6 +27,42 @@
margin-top: 0.4rem;
}
/* Studio Cuts per-ReleaseType breakdown — mono caption rows below the label, reusing the label's
palette so the card reads as one block. */
.hero-stat-breakdown {
display: flex;
flex-direction: column;
gap: 0.1rem;
margin-top: 0.5rem;
}
.hero-stat-breakdown-item {
font-family: var(--deepdrft-font-mono);
font-size: 0.58rem;
letter-spacing: 0.12em;
color: rgba(250, 250, 248, 0.55);
}
/* Mixes runtime sub-figure — sits under the label, slightly brighter than the label caption. */
.hero-stat-sub {
font-family: var(--deepdrft-font-mono);
font-size: 0.58rem;
letter-spacing: 0.12em;
color: rgba(250, 250, 248, 0.55);
margin-top: 0.5rem;
}
/* Plays placeholder — a light 90s visitor-counter / odometer embellishment over the existing
numeric treatment: monospace digits, boxed and tracked out like a mechanical counter. */
.hero-stat-odometer {
font-family: var(--deepdrft-font-mono);
letter-spacing: 0.18em;
background: rgba(0, 0, 0, 0.35);
border: 1px solid rgba(250, 250, 248, 0.12);
padding: 0.1rem 0.35rem;
display: inline-block;
}
@media (max-width: 599px) {
.hero-stat-row {
flex-direction: column;
@@ -0,0 +1,19 @@
namespace DeepDrftPublic.Client.Helpers;
/// <summary>
/// Formats a runtime expressed in seconds as a compact <c>hh:mm</c> string for the home hero stat row.
/// Hours are not zero-padded and may exceed two digits (mixes are few, so a large total simply renders
/// "123:45"); minutes are always two digits. Negative or non-finite inputs clamp to "0:00".
/// </summary>
public static class RuntimeFormat
{
public static string ToHoursMinutes(double totalSeconds)
{
if (double.IsNaN(totalSeconds) || double.IsInfinity(totalSeconds) || totalSeconds <= 0)
return "0:00";
var total = TimeSpan.FromSeconds(totalSeconds);
var hours = (int)total.TotalHours;
return $"{hours}:{total.Minutes:D2}";
}
}
@@ -0,0 +1,15 @@
using DeepDrftModels.DTOs;
using NetBlocks.Models;
namespace DeepDrftPublic.Client.Services;
/// <summary>
/// Home-stats read abstraction. Both SSR and WASM renders are served by <c>StatsClientDataService</c>
/// in this assembly, which delegates to <see cref="Clients.StatsClient"/> over HTTP. Components inject
/// this single seam so they do not branch on render mode — mirrors <see cref="IReleaseDataService"/>.
/// </summary>
public interface IStatsDataService
{
/// <summary>Aggregate figures behind the public home hero stat row, in one round-trip.</summary>
Task<ApiResult<HomeStatsDto>> GetHomeStats();
}
@@ -0,0 +1,22 @@
using DeepDrftModels.DTOs;
using DeepDrftPublic.Client.Clients;
using NetBlocks.Models;
namespace DeepDrftPublic.Client.Services;
/// <summary>
/// <see cref="IStatsDataService"/> backed by <see cref="StatsClient"/> (HTTP to the <c>DeepDrft.API</c>
/// backend). Used on both the SSR prerender and WASM interactive passes — the stats read surface is
/// HTTP-only, so there is no separate in-process implementation.
/// </summary>
public class StatsClientDataService : IStatsDataService
{
private readonly StatsClient _statsClient;
public StatsClientDataService(StatsClient statsClient)
{
_statsClient = statsClient;
}
public Task<ApiResult<HomeStatsDto>> GetHomeStats() => _statsClient.GetHomeStats();
}
+4
View File
@@ -26,6 +26,10 @@ public static class Startup
services.AddScoped<ReleaseDetailViewModel>();
services.AddScoped<CutDetailViewModel>();
// Home hero stats read surface — same HTTP posture as the track/release clients.
services.AddScoped<StatsClient>();
services.AddScoped<IStatsDataService, StatsClientDataService>();
// 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>();
@@ -0,0 +1,51 @@
using Microsoft.AspNetCore.Mvc;
namespace DeepDrftPublic.Controllers;
/// <summary>
/// Proxies the public stats read surface to DeepDrftAPI so the browser never makes a cross-origin
/// request. Mirrors <see cref="ReleaseProxyController"/>: the WASM client issues relative
/// <c>api/stats/*</c> requests against this host, which forwards them upstream. SSR prerender calls
/// DeepDrftAPI directly via the same named client — no proxy hop on the server side. Unauthenticated read.
/// </summary>
[ApiController]
[Route("api/stats")]
public class StatsProxyController : ControllerBase
{
private readonly HttpClient _upstream;
private readonly ILogger<StatsProxyController> _logger;
public StatsProxyController(IHttpClientFactory httpClientFactory, ILogger<StatsProxyController> logger)
{
_upstream = httpClientFactory.CreateClient("DeepDrft.API");
_logger = logger;
}
/// <summary>Proxies the home hero aggregate figures. Small JSON, buffered and relayed.</summary>
[HttpGet("home")]
public async Task<ActionResult> GetHome(CancellationToken ct = default)
{
HttpResponseMessage upstream;
try
{
upstream = await _upstream.GetAsync("api/stats/home", HttpCompletionOption.ResponseHeadersRead, ct);
}
catch (Exception ex)
{
_logger.LogError(ex, "Upstream call to DeepDrftAPI stats/home failed");
return StatusCode(502, "Upstream unavailable");
}
using (upstream)
{
if (!upstream.IsSuccessStatusCode)
{
_logger.LogWarning("DeepDrftAPI stats/home returned {Status}", (int)upstream.StatusCode);
return StatusCode((int)upstream.StatusCode);
}
var json = await upstream.Content.ReadAsStringAsync(ct);
return Content(json, "application/json");
}
}
}
+182
View File
@@ -0,0 +1,182 @@
using Data.Data.Repositories;
using DeepDrftData.Data;
using DeepDrftData.Repositories;
using DeepDrftModels.Entities;
using DeepDrftModels.Enums;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
namespace DeepDrftTests;
/// <summary>
/// Aggregate-query tests for the public home hero stat row, exercising
/// <see cref="TrackRepository.GetHomeStatsAsync"/>: the Cut track count, the per-ReleaseType Cut
/// release breakdown (zero-count types absent), the Mix release count, and the Mix-runtime sum
/// (null durations contributing 0). Runs on the EF in-memory provider like
/// <see cref="ReleaseBrowseQueryTests"/> — every predicate here (Count, GroupBy, Sum with a
/// null-coalesce) translates in process.
/// </summary>
[TestFixture]
public class HomeStatsQueryTests
{
private DeepDrftContext _context = null!;
[SetUp]
public void SetUp()
{
var options = new DbContextOptionsBuilder<DeepDrftContext>()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.Options;
_context = new DeepDrftContext(options);
}
[TearDown]
public void TearDown() => _context.Dispose();
private TrackRepository CreateRepository()
=> new(_context, NullLogger<Repository<DeepDrftContext, TrackEntity>>.Instance);
private static ReleaseEntity Release(
string title, ReleaseMedium medium, ReleaseType releaseType = ReleaseType.Single)
=> new()
{
EntryKey = Guid.NewGuid().ToString("N"),
Title = title,
Artist = "A",
Medium = medium,
ReleaseType = releaseType,
};
private static TrackEntity Track(ReleaseEntity release, double? duration = null)
=> new()
{
EntryKey = Guid.NewGuid().ToString("N"),
TrackName = "T",
Release = release,
DurationSeconds = duration,
};
private async Task SeedAsync(IEnumerable<ReleaseEntity> releases, IEnumerable<TrackEntity> tracks)
{
_context.Releases.AddRange(releases);
_context.Tracks.AddRange(tracks);
await _context.SaveChangesAsync();
}
// Cut track count reflects only tracks whose release is the Cut medium — Session and Mix tracks
// are excluded — given a mix of all three media.
[Test]
public async Task GetHomeStatsAsync_CutTrackCount_CountsOnlyCutMediumTracks()
{
var cut = Release("Cut", ReleaseMedium.Cut, ReleaseType.Album);
var session = Release("Session", ReleaseMedium.Session);
var mix = Release("Mix", ReleaseMedium.Mix);
await SeedAsync(
new[] { cut, session, mix },
new[] { Track(cut), Track(cut), Track(session), Track(mix) });
var stats = await CreateRepository().GetHomeStatsAsync();
Assert.That(stats.CutTrackCount, Is.EqualTo(2));
}
// The Cut release-type breakdown groups by ReleaseType, and a type with zero Cut releases is absent
// from the result entirely (not present-with-zero).
[Test]
public async Task GetHomeStatsAsync_CutReleaseTypeBreakdown_OmitsZeroCountTypes()
{
await SeedAsync(
new[]
{
Release("Cut Single 1", ReleaseMedium.Cut, ReleaseType.Single),
Release("Cut Single 2", ReleaseMedium.Cut, ReleaseType.Single),
Release("Cut Album", ReleaseMedium.Cut, ReleaseType.Album),
// A Mix release with ReleaseType.EP must not leak into the Cut breakdown.
Release("Mix EP", ReleaseMedium.Mix, ReleaseType.EP),
},
Array.Empty<TrackEntity>());
var stats = await CreateRepository().GetHomeStatsAsync();
Assert.That(stats.CutReleaseTypeCounts.Any(c => c.ReleaseType == ReleaseType.EP), Is.False,
"EP has zero Cut releases and must be absent, not present-with-zero");
Assert.That(stats.CutReleaseTypeCounts.Single(c => c.ReleaseType == ReleaseType.Single).Count, Is.EqualTo(2));
Assert.That(stats.CutReleaseTypeCounts.Single(c => c.ReleaseType == ReleaseType.Album).Count, Is.EqualTo(1));
}
// Mix release count counts Mix-medium releases, and the runtime sum tolerates null durations:
// not-yet-backfilled tracks contribute 0 rather than throwing or skewing the total.
[Test]
public async Task GetHomeStatsAsync_MixRuntime_TreatsNullDurationsAsZero()
{
var mixA = Release("Mix A", ReleaseMedium.Mix);
var mixB = Release("Mix B", ReleaseMedium.Mix);
var cut = Release("Cut", ReleaseMedium.Cut);
await SeedAsync(
new[] { mixA, mixB, cut },
new[]
{
Track(mixA, duration: 600d),
Track(mixB, duration: null), // not yet backfilled — contributes 0
Track(cut, duration: 120d), // Cut track must not count toward mix runtime
});
var stats = await CreateRepository().GetHomeStatsAsync();
Assert.That(stats.MixReleaseCount, Is.EqualTo(2));
Assert.That(stats.MixRuntimeSeconds, Is.EqualTo(600d));
}
// Soft-deleted releases and tracks never count toward any figure.
[Test]
public async Task GetHomeStatsAsync_ExcludesSoftDeletedRowsFromAllFigures()
{
var liveCut = Release("Live Cut", ReleaseMedium.Cut, ReleaseType.Album);
var deletedCut = Release("Dead Cut", ReleaseMedium.Cut, ReleaseType.Album);
deletedCut.IsDeleted = true;
var mix = Release("Mix", ReleaseMedium.Mix);
var deletedMixTrack = Track(mix, duration: 999d);
deletedMixTrack.IsDeleted = true;
await SeedAsync(
new[] { liveCut, deletedCut, mix },
new[] { Track(liveCut), deletedMixTrack });
var stats = await CreateRepository().GetHomeStatsAsync();
Assert.That(stats.CutTrackCount, Is.EqualTo(1));
Assert.That(stats.CutReleaseTypeCounts.Single(c => c.ReleaseType == ReleaseType.Album).Count, Is.EqualTo(1));
Assert.That(stats.MixRuntimeSeconds, Is.EqualTo(0d), "the only mix track is soft-deleted");
}
// A live track under a directly-deleted release must be excluded from the track-based figures.
// SoftDeleteReleaseAsync does not cascade to child tracks, so without the !t.Release.IsDeleted
// guard the track-count and runtime figures are internally inconsistent with the release-level ones.
[Test]
public async Task GetHomeStatsAsync_ExcludesLiveTracksUnderSoftDeletedRelease()
{
var liveCut = Release("Live Cut", ReleaseMedium.Cut, ReleaseType.Album);
var deletedCut = Release("Dead Cut", ReleaseMedium.Cut, ReleaseType.Album);
deletedCut.IsDeleted = true;
var deletedMix = Release("Dead Mix", ReleaseMedium.Mix);
deletedMix.IsDeleted = true;
// Both tracks are themselves live — only their parent release is soft-deleted.
var liveTrackUnderDeletedCut = Track(deletedCut);
var liveTrackUnderDeletedMix = Track(deletedMix, duration: 900d);
await SeedAsync(
new[] { liveCut, deletedCut, deletedMix },
new[] { Track(liveCut), liveTrackUnderDeletedCut, liveTrackUnderDeletedMix });
var stats = await CreateRepository().GetHomeStatsAsync();
Assert.That(stats.CutTrackCount, Is.EqualTo(1),
"live track under deleted Cut release must not inflate CutTrackCount");
Assert.That(stats.MixReleaseCount, Is.EqualTo(0),
"deleted Mix release must not count");
Assert.That(stats.MixRuntimeSeconds, Is.EqualTo(0d),
"live track under deleted Mix release must not inflate MixRuntimeSeconds");
}
}
+40
View File
@@ -0,0 +1,40 @@
using DeepDrftPublic.Client.Helpers;
namespace DeepDrftTests;
/// <summary>
/// Unit tests for the home-stat-row runtime formatter (<see cref="RuntimeFormat.ToHoursMinutes"/>):
/// the hh:mm shape, hour rollover past 60 minutes, multi-hour totals, and the clamp on non-positive /
/// non-finite input.
/// </summary>
[TestFixture]
public class RuntimeFormatTests
{
// 12h34m -> "12:34": the brief's worked example, hours not zero-padded, minutes two digits.
[Test]
public void ToHoursMinutes_TwelveHoursThirtyFour_FormatsHhMm()
=> Assert.That(RuntimeFormat.ToHoursMinutes((12 * 3600) + (34 * 60)), Is.EqualTo("12:34"));
// 90 minutes rolls into 1 hour 30 minutes — minutes never exceed 59.
[Test]
public void ToHoursMinutes_NinetyMinutes_RollsIntoHours()
=> Assert.That(RuntimeFormat.ToHoursMinutes(90 * 60), Is.EqualTo("1:30"));
// Sub-hour totals show 0 hours with zero-padded minutes.
[Test]
public void ToHoursMinutes_UnderOneHour_ShowsZeroHours()
=> Assert.That(RuntimeFormat.ToHoursMinutes(5 * 60), Is.EqualTo("0:05"));
// Totals beyond 99h are not truncated — hours simply take more than two digits (mixes are few).
[Test]
public void ToHoursMinutes_BeyondNinetyNineHours_DoesNotTruncate()
=> Assert.That(RuntimeFormat.ToHoursMinutes((123 * 3600) + (45 * 60)), Is.EqualTo("123:45"));
// Zero / negative / non-finite inputs clamp to "0:00" rather than producing a negative or NaN render.
[TestCase(0d)]
[TestCase(-10d)]
[TestCase(double.NaN)]
[TestCase(double.PositiveInfinity)]
public void ToHoursMinutes_NonPositiveOrNonFinite_ClampsToZero(double seconds)
=> Assert.That(RuntimeFormat.ToHoursMinutes(seconds), Is.EqualTo("0:00"));
}