Wire NowPlayingStats to live aggregates: add SQL track duration column, stats endpoint, and duration backfill
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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">∞</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();
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
Reference in New Issue
Block a user