Wire NowPlayingStats to live aggregates: add SQL track duration column, stats endpoint, and duration backfill

This commit is contained in:
daniel-c-harvey
2026-06-18 11:53:49 -04:00
parent 8ddecb4acc
commit 5f0422a263
26 changed files with 1089 additions and 9 deletions
@@ -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,57 @@ 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 / the release set's !IsDeleted filter so soft-deleted rows never count.
// 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.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.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 +263,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