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);
|
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.
|
// 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
|
// 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.
|
// 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>
|
/// <summary>
|
||||||
/// Delete a track's SQL row, then its vault entry. SQL is the source of truth: a SQL delete
|
/// 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
|
/// 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
|
EntryKey = trackId, // FileDatabase entry ID
|
||||||
TrackName = trackName,
|
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;
|
return trackEntity;
|
||||||
|
|||||||
@@ -39,6 +39,10 @@ public class TrackConfiguration : BaseEntityConfiguration<TrackEntity>
|
|||||||
.HasColumnName("track_number")
|
.HasColumnName("track_number")
|
||||||
.HasDefaultValue(1);
|
.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)
|
builder.Property(e => e.ReleaseId)
|
||||||
.HasColumnName("release_id");
|
.HasColumnName("release_id");
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,25 @@ public interface ITrackService
|
|||||||
/// <summary>Distinct non-null genres with track counts, genre-ascending.</summary>
|
/// <summary>Distinct non-null genres with track counts, genre-ascending.</summary>
|
||||||
Task<ResultContainer<List<GenreSummaryDto>>> GetDistinctGenres(CancellationToken cancellationToken = default);
|
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>
|
/// <summary>
|
||||||
/// Resolve the release matching <paramref name="title"/> + <paramref name="artist"/>, creating
|
/// 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
|
/// 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")
|
.HasColumnType("timestamp with time zone")
|
||||||
.HasColumnName("created_at");
|
.HasColumnName("created_at");
|
||||||
|
|
||||||
|
b.Property<double?>("DurationSeconds")
|
||||||
|
.HasColumnType("double precision")
|
||||||
|
.HasColumnName("duration_seconds");
|
||||||
|
|
||||||
b.Property<string>("EntryKey")
|
b.Property<string>("EntryKey")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(100)
|
.HasMaxLength(100)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using Data.Errors;
|
|||||||
using DeepDrftData.Data;
|
using DeepDrftData.Data;
|
||||||
using DeepDrftModels.DTOs;
|
using DeepDrftModels.DTOs;
|
||||||
using DeepDrftModels.Entities;
|
using DeepDrftModels.Entities;
|
||||||
|
using DeepDrftModels.Enums;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Models.Common;
|
using Models.Common;
|
||||||
@@ -157,6 +158,57 @@ public class TrackRepository : Repository<DeepDrftContext, TrackEntity>
|
|||||||
.Select(g => new { ReleaseId = g.Key, Count = g.Count() })
|
.Select(g => new { ReleaseId = g.Key, Count = g.Count() })
|
||||||
.ToDictionaryAsync(x => x.ReleaseId, x => x.Count, ct);
|
.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,
|
// 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.
|
// signalling the manager to create one. Soft-deleted releases never match.
|
||||||
public async Task<ReleaseEntity?> GetReleaseByTitleAndArtistAsync(
|
public async Task<ReleaseEntity?> GetReleaseByTitleAndArtistAsync(
|
||||||
@@ -211,6 +263,7 @@ public class TrackRepository : Repository<DeepDrftContext, TrackEntity>
|
|||||||
target.TrackName = source.TrackName;
|
target.TrackName = source.TrackName;
|
||||||
target.TrackNumber = source.TrackNumber;
|
target.TrackNumber = source.TrackNumber;
|
||||||
target.OriginalFileName = source.OriginalFileName;
|
target.OriginalFileName = source.OriginalFileName;
|
||||||
|
target.DurationSeconds = source.DurationSeconds;
|
||||||
target.ReleaseId = source.ReleaseId;
|
target.ReleaseId = source.ReleaseId;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ public class TrackConverter : IEntityToModelConverter<TrackEntity, TrackDto>
|
|||||||
TrackName = entity.TrackName,
|
TrackName = entity.TrackName,
|
||||||
OriginalFileName = entity.OriginalFileName,
|
OriginalFileName = entity.OriginalFileName,
|
||||||
TrackNumber = entity.TrackNumber,
|
TrackNumber = entity.TrackNumber,
|
||||||
|
DurationSeconds = entity.DurationSeconds,
|
||||||
ReleaseId = entity.ReleaseId,
|
ReleaseId = entity.ReleaseId,
|
||||||
Release = entity.Release is null ? null : Convert(entity.Release)
|
Release = entity.Release is null ? null : Convert(entity.Release)
|
||||||
};
|
};
|
||||||
@@ -96,6 +97,7 @@ public class TrackConverter : IEntityToModelConverter<TrackEntity, TrackDto>
|
|||||||
TrackName = model.TrackName,
|
TrackName = model.TrackName,
|
||||||
OriginalFileName = model.OriginalFileName,
|
OriginalFileName = model.OriginalFileName,
|
||||||
TrackNumber = model.TrackNumber,
|
TrackNumber = model.TrackNumber,
|
||||||
|
DurationSeconds = model.DurationSeconds,
|
||||||
ReleaseId = model.ReleaseId
|
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)
|
public async Task<ResultContainer<TrackDto>> Create(TrackDto newTrack)
|
||||||
{
|
{
|
||||||
try
|
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 TrackName { get; set; } = string.Empty;
|
||||||
public string? OriginalFileName { get; set; }
|
public string? OriginalFileName { get; set; }
|
||||||
public int TrackNumber { get; set; } = 1;
|
public int TrackNumber { get; set; } = 1;
|
||||||
|
public double? DurationSeconds { get; set; }
|
||||||
public long? ReleaseId { get; set; }
|
public long? ReleaseId { get; set; }
|
||||||
public ReleaseDto? Release { get; set; }
|
public ReleaseDto? Release { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ public class TrackEntity : BaseEntity, IEntity
|
|||||||
public required string TrackName { get; set; }
|
public required string TrackName { get; set; }
|
||||||
public string? OriginalFileName { get; set; }
|
public string? OriginalFileName { get; set; }
|
||||||
public int TrackNumber { get; set; } = 1;
|
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 long? ReleaseId { get; set; }
|
||||||
public ReleaseEntity? Release { 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">
|
<div class="now-playing-content">
|
||||||
<NowPlayingCard />
|
<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 />
|
<NowPlayingStats />
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<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">
|
||||||
<div class="hero-stat-num">47+</div>
|
<div class="hero-stat-num">@_stats.CutTrackCount</div>
|
||||||
<div class="hero-stat-label">Live Sessions</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>
|
||||||
|
}
|
||||||
|
</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">
|
||||||
<div class="hero-stat-num">2</div>
|
<div class="hero-stat-num">@_stats.MixReleaseCount</div>
|
||||||
<div class="hero-stat-label">Members</div>
|
<div class="hero-stat-label">Sets</div>
|
||||||
|
<div class="hero-stat-sub">@RuntimeFormat.ToHoursMinutes(_stats.MixRuntimeSeconds) runtime</div>
|
||||||
</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">
|
||||||
<div class="hero-stat-num">∞</div>
|
<div class="hero-stat-num hero-stat-odometer">XXX</div>
|
||||||
<div class="hero-stat-label">Drift Points</div>
|
<div class="hero-stat-label">Plays (Coming Soon)</div>
|
||||||
</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;
|
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) {
|
@media (max-width: 599px) {
|
||||||
.hero-stat-row {
|
.hero-stat-row {
|
||||||
flex-direction: column;
|
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<ReleaseDetailViewModel>();
|
||||||
services.AddScoped<CutDetailViewModel>();
|
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
|
// Waveform visualizer controls — scoped so the eight slider positions persist across navigation
|
||||||
// within a session and reset on a fresh page load (see WaveformVisualizerControlState).
|
// within a session and reset on a fresh page load (see WaveformVisualizerControlState).
|
||||||
services.AddScoped<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