diff --git a/DeepDrftAPI/Controllers/StatsController.cs b/DeepDrftAPI/Controllers/StatsController.cs new file mode 100644 index 0000000..7f03e49 --- /dev/null +++ b/DeepDrftAPI/Controllers/StatsController.cs @@ -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 _logger; + + public StatsController(ITrackService sqlTrackService, ILogger 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 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); + } +} diff --git a/DeepDrftAPI/Controllers/TrackController.cs b/DeepDrftAPI/Controllers/TrackController.cs index e70907e..146f07b 100644 --- a/DeepDrftAPI/Controllers/TrackController.cs +++ b/DeepDrftAPI/Controllers/TrackController.cs @@ -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 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. diff --git a/DeepDrftAPI/Services/UnifiedTrackService.cs b/DeepDrftAPI/Services/UnifiedTrackService.cs index 5ed663c..71d7568 100644 --- a/DeepDrftAPI/Services/UnifiedTrackService.cs +++ b/DeepDrftAPI/Services/UnifiedTrackService.cs @@ -193,6 +193,54 @@ public class UnifiedTrackService } } + /// + /// 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. + /// + public async Task> 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)); + } + /// /// 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 diff --git a/DeepDrftContent/TrackContentService.cs b/DeepDrftContent/TrackContentService.cs index bf6d13c..c978653 100644 --- a/DeepDrftContent/TrackContentService.cs +++ b/DeepDrftContent/TrackContentService.cs @@ -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; diff --git a/DeepDrftData/Data/Configurations/TrackConfiguration.cs b/DeepDrftData/Data/Configurations/TrackConfiguration.cs index 7151478..5a92b9a 100644 --- a/DeepDrftData/Data/Configurations/TrackConfiguration.cs +++ b/DeepDrftData/Data/Configurations/TrackConfiguration.cs @@ -39,6 +39,10 @@ public class TrackConfiguration : BaseEntityConfiguration .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"); diff --git a/DeepDrftData/ITrackService.cs b/DeepDrftData/ITrackService.cs index 9650f6d..f6c4a23 100644 --- a/DeepDrftData/ITrackService.cs +++ b/DeepDrftData/ITrackService.cs @@ -28,6 +28,25 @@ public interface ITrackService /// Distinct non-null genres with track counts, genre-ascending. Task>> GetDistinctGenres(CancellationToken cancellationToken = default); + /// + /// 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. + /// + Task> GetHomeStats(CancellationToken cancellationToken = default); + + /// + /// 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 + /// . + /// + Task>> GetTracksMissingDuration(CancellationToken cancellationToken = default); + + /// + /// 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). + /// + Task> UpdateDuration(long id, double durationSeconds, CancellationToken cancellationToken = default); + /// /// Resolve the release matching + , creating /// one from when none exists. Backs the upload flow's FK diff --git a/DeepDrftData/Migrations/20260618155002_AddTrackDuration.Designer.cs b/DeepDrftData/Migrations/20260618155002_AddTrackDuration.Designer.cs new file mode 100644 index 0000000..90f157f --- /dev/null +++ b/DeepDrftData/Migrations/20260618155002_AddTrackDuration.Designer.cs @@ -0,0 +1,322 @@ +// +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 + { + /// + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_deleted"); + + b.Property("ReleaseId") + .HasColumnType("bigint") + .HasColumnName("release_id"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Artist") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("artist"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedByUserId") + .HasColumnType("bigint") + .HasColumnName("created_by_user_id"); + + b.Property("Description") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)") + .HasColumnName("description"); + + b.Property("EntryKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("entry_key"); + + b.Property("Genre") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("genre"); + + b.Property("ImagePath") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("image_path"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_deleted"); + + b.Property("Medium") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("Cut") + .HasColumnName("medium"); + + b.Property("ReleaseDate") + .HasColumnType("date") + .HasColumnName("release_date"); + + b.Property("ReleaseType") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("Single") + .HasColumnName("release_type"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("HeroImageEntryKey") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("hero_image_entry_key"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_deleted"); + + b.Property("ReleaseId") + .HasColumnType("bigint") + .HasColumnName("release_id"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DurationSeconds") + .HasColumnType("double precision") + .HasColumnName("duration_seconds"); + + b.Property("EntryKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("entry_key"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_deleted"); + + b.Property("OriginalFileName") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("original_file_name"); + + b.Property("ReleaseId") + .HasColumnType("bigint") + .HasColumnName("release_id"); + + b.Property("TrackName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("track_name"); + + b.Property("TrackNumber") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(1) + .HasColumnName("track_number"); + + b.Property("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 + } + } +} diff --git a/DeepDrftData/Migrations/20260618155002_AddTrackDuration.cs b/DeepDrftData/Migrations/20260618155002_AddTrackDuration.cs new file mode 100644 index 0000000..4f3bf50 --- /dev/null +++ b/DeepDrftData/Migrations/20260618155002_AddTrackDuration.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace DeepDrftData.Migrations +{ + /// + public partial class AddTrackDuration : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "duration_seconds", + table: "track", + type: "double precision", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "duration_seconds", + table: "track"); + } + } +} diff --git a/DeepDrftData/Migrations/DeepDrftContextModelSnapshot.cs b/DeepDrftData/Migrations/DeepDrftContextModelSnapshot.cs index 33b6111..f602484 100644 --- a/DeepDrftData/Migrations/DeepDrftContextModelSnapshot.cs +++ b/DeepDrftData/Migrations/DeepDrftContextModelSnapshot.cs @@ -222,6 +222,10 @@ namespace DeepDrftData.Migrations .HasColumnType("timestamp with time zone") .HasColumnName("created_at"); + b.Property("DurationSeconds") + .HasColumnType("double precision") + .HasColumnName("duration_seconds"); + b.Property("EntryKey") .IsRequired() .HasMaxLength(100) diff --git a/DeepDrftData/Repositories/TrackRepository.cs b/DeepDrftData/Repositories/TrackRepository.cs index caafea4..3b638ff 100644 --- a/DeepDrftData/Repositories/TrackRepository.cs +++ b/DeepDrftData/Repositories/TrackRepository.cs @@ -3,6 +3,7 @@ using Data.Errors; using DeepDrftData.Data; using DeepDrftModels.DTOs; using DeepDrftModels.Entities; +using DeepDrftModels.Enums; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Models.Common; @@ -157,6 +158,58 @@ public class TrackRepository : Repository .Select(g => new { ReleaseId = g.Key, Count = g.Count() }) .ToDictionaryAsync(x => x.ReleaseId, x => x.Count, ct); + // Aggregate figures for the public home hero stat row, assembled in as few round-trips as is clean. + // All counts go through Query (!t.IsDeleted) plus an explicit !t.Release.IsDeleted guard so tracks + // under a directly-deleted release are also excluded. Mix runtime sums DurationSeconds with a + // null-coalesce to 0 so not-yet-backfilled rows contribute zero rather than throwing or skewing the + // total. The cut release-type breakdown is grouped here so a zero-count type is simply absent from + // the result (no present-with-zero row). + public async Task GetHomeStatsAsync(CancellationToken ct = default) + { + var releases = _context.Set().Where(r => !r.IsDeleted); + + var cutTrackCount = await Query + .CountAsync(t => t.Release != null && !t.Release.IsDeleted && t.Release.Medium == ReleaseMedium.Cut, ct); + + var cutReleaseTypeCounts = await releases + .Where(r => r.Medium == ReleaseMedium.Cut) + .GroupBy(r => r.ReleaseType) + .Select(g => new CutReleaseTypeCount { ReleaseType = g.Key, Count = g.Count() }) + .ToListAsync(ct); + + var mixReleaseCount = await releases + .CountAsync(r => r.Medium == ReleaseMedium.Mix, ct); + + var mixRuntimeSeconds = await Query + .Where(t => t.Release != null && !t.Release.IsDeleted && t.Release.Medium == ReleaseMedium.Mix) + .SumAsync(t => t.DurationSeconds ?? 0d, ct); + + return new HomeStatsDto + { + CutTrackCount = cutTrackCount, + CutReleaseTypeCounts = cutReleaseTypeCounts, + MixReleaseCount = mixReleaseCount, + MixRuntimeSeconds = mixRuntimeSeconds, + }; + } + + // EntryKey + stored duration for non-deleted tracks whose SQL duration is still null — the work list + // the one-time duration backfill iterates. The migration cannot read the vault, so duration is filled + // at runtime: this lists which rows still need it, the backfill reads each from the vault and writes + // it back via UpdateDurationAsync. + public async Task> 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 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 GetReleaseByTitleAndArtistAsync( @@ -211,6 +264,7 @@ public class TrackRepository : Repository target.TrackName = source.TrackName; target.TrackNumber = source.TrackNumber; target.OriginalFileName = source.OriginalFileName; + target.DurationSeconds = source.DurationSeconds; target.ReleaseId = source.ReleaseId; } } diff --git a/DeepDrftData/TrackConverter.cs b/DeepDrftData/TrackConverter.cs index 7827d9b..b72111c 100644 --- a/DeepDrftData/TrackConverter.cs +++ b/DeepDrftData/TrackConverter.cs @@ -80,6 +80,7 @@ public class TrackConverter : IEntityToModelConverter 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 TrackName = model.TrackName, OriginalFileName = model.OriginalFileName, TrackNumber = model.TrackNumber, + DurationSeconds = model.DurationSeconds, ReleaseId = model.ReleaseId }; } diff --git a/DeepDrftData/TrackManager.cs b/DeepDrftData/TrackManager.cs index 73c466a..240ec98 100644 --- a/DeepDrftData/TrackManager.cs +++ b/DeepDrftData/TrackManager.cs @@ -236,6 +236,46 @@ public class TrackManager } } + public async Task> GetHomeStats(CancellationToken cancellationToken = default) + { + try + { + var stats = await Repository.GetHomeStatsAsync(cancellationToken); + return ResultContainer.CreatePassResult(stats); + } + catch (Exception e) + { + return ResultContainer.CreateFailResult(e.Message); + } + } + + public async Task>> GetTracksMissingDuration(CancellationToken cancellationToken = default) + { + try + { + var entities = await Repository.GetTracksMissingDurationAsync(cancellationToken); + return ResultContainer>.CreatePassResult( + entities.Select(TrackConverter.Convert).ToList()); + } + catch (Exception e) + { + return ResultContainer>.CreateFailResult(e.Message); + } + } + + public async Task> UpdateDuration(long id, double durationSeconds, CancellationToken cancellationToken = default) + { + try + { + var updated = await Repository.UpdateDurationAsync(id, durationSeconds, cancellationToken); + return ResultContainer.CreatePassResult(updated); + } + catch (Exception e) + { + return ResultContainer.CreateFailResult(e.Message); + } + } + public async Task> Create(TrackDto newTrack) { try diff --git a/DeepDrftModels/DTOs/HomeStatsDto.cs b/DeepDrftModels/DTOs/HomeStatsDto.cs new file mode 100644 index 0000000..5d582fd --- /dev/null +++ b/DeepDrftModels/DTOs/HomeStatsDto.cs @@ -0,0 +1,37 @@ +using DeepDrftModels.Enums; + +namespace DeepDrftModels.DTOs; + +/// +/// 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. +/// +public class HomeStatsDto +{ + /// Total non-deleted tracks whose release is the Cut medium. The Studio Cuts card's primary figure. + public int CutTrackCount { get; set; } + + /// + /// 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. + /// + public List CutReleaseTypeCounts { get; set; } = []; + + /// Total non-deleted releases of the Mix medium. The Mixes card's primary figure ("N Sets"). + public int MixReleaseCount { get; set; } + + /// + /// 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. + /// + public double MixRuntimeSeconds { get; set; } +} + +/// One row of the Cut release-type breakdown: a ReleaseType and how many Cut releases have it. +public class CutReleaseTypeCount +{ + public ReleaseType ReleaseType { get; set; } + public int Count { get; set; } +} diff --git a/DeepDrftModels/DTOs/TrackDto.cs b/DeepDrftModels/DTOs/TrackDto.cs index 89fe0fa..e0165a8 100644 --- a/DeepDrftModels/DTOs/TrackDto.cs +++ b/DeepDrftModels/DTOs/TrackDto.cs @@ -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; } } diff --git a/DeepDrftModels/Entities/TrackEntity.cs b/DeepDrftModels/Entities/TrackEntity.cs index 50e1f1f..959982f 100644 --- a/DeepDrftModels/Entities/TrackEntity.cs +++ b/DeepDrftModels/Entities/TrackEntity.cs @@ -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; } } diff --git a/DeepDrftPublic.Client/Clients/StatsClient.cs b/DeepDrftPublic.Client/Clients/StatsClient.cs new file mode 100644 index 0000000..a144416 --- /dev/null +++ b/DeepDrftPublic.Client/Clients/StatsClient.cs @@ -0,0 +1,39 @@ +using DeepDrftModels.DTOs; +using NetBlocks.Models; +using System.Text.Json; + +namespace DeepDrftPublic.Client.Clients; + +/// +/// HTTP client for the public stats read surface. Uses the named "DeepDrft.API" client like +/// and : on WASM it points at the public host and +/// proxies through StatsProxyController; 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 Ok(value) shape. +/// +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> GetHomeStats() + { + var response = await _http.GetAsync("api/stats/home"); + + if (!response.IsSuccessStatusCode) + return ApiResult.CreateFailResult($"HTTP {(int)response.StatusCode}"); + + var json = await response.Content.ReadAsStringAsync(); + var stats = JsonSerializer.Deserialize(json, JsonOptions); + + return stats is not null + ? ApiResult.CreatePassResult(stats) + : ApiResult.CreateFailResult("Failed to deserialize response"); + } +} diff --git a/DeepDrftPublic.Client/Controls/NowPlaying.razor b/DeepDrftPublic.Client/Controls/NowPlaying.razor index 9c8b7a4..906d568 100644 --- a/DeepDrftPublic.Client/Controls/NowPlaying.razor +++ b/DeepDrftPublic.Client/Controls/NowPlaying.razor @@ -31,7 +31,8 @@
- @* 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. *@
diff --git a/DeepDrftPublic.Client/Controls/NowPlayingStats.razor b/DeepDrftPublic.Client/Controls/NowPlayingStats.razor index 01f3d4d..36d07df 100644 --- a/DeepDrftPublic.Client/Controls/NowPlayingStats.razor +++ b/DeepDrftPublic.Client/Controls/NowPlayingStats.razor @@ -1,14 +1,94 @@ +@using DeepDrftModels.DTOs +@using DeepDrftModels.Enums +@using DeepDrftPublic.Client.Helpers +@using DeepDrftPublic.Client.Services +@implements IDisposable +
+ @* 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. *@
-
47+
-
Live Sessions
+
@_stats.CutTrackCount
+
Studio Cuts
+ @if (_stats.CutReleaseTypeCounts.Count > 0) + { +
+ @foreach (var row in _stats.CutReleaseTypeCounts) + { + @row.Count @PluralizeReleaseType(row.ReleaseType, row.Count) + } +
+ }
+ + @* Mixes — primary figure is the Mix release count labelled "Sets"; the secondary figure is total + mix runtime as hh:mm. *@
-
2
-
Members
+
@_stats.MixReleaseCount
+
Sets
+
@RuntimeFormat.ToHoursMinutes(_stats.MixRuntimeSeconds) runtime
+ + @* Plays — static placeholder (real play/share tracking is a future phase). Odometer treatment over + the existing card style; copy is placeholder pending sign-off. *@
-
-
Drift Points
+
XXX
+
Plays (Coming Soon)
-
\ No newline at end of file + + +@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(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(); +} diff --git a/DeepDrftPublic.Client/Controls/NowPlayingStats.razor.css b/DeepDrftPublic.Client/Controls/NowPlayingStats.razor.css index 2016701..0d75f96 100644 --- a/DeepDrftPublic.Client/Controls/NowPlayingStats.razor.css +++ b/DeepDrftPublic.Client/Controls/NowPlayingStats.razor.css @@ -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; diff --git a/DeepDrftPublic.Client/Helpers/RuntimeFormat.cs b/DeepDrftPublic.Client/Helpers/RuntimeFormat.cs new file mode 100644 index 0000000..85904ef --- /dev/null +++ b/DeepDrftPublic.Client/Helpers/RuntimeFormat.cs @@ -0,0 +1,19 @@ +namespace DeepDrftPublic.Client.Helpers; + +/// +/// Formats a runtime expressed in seconds as a compact hh:mm 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". +/// +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}"; + } +} diff --git a/DeepDrftPublic.Client/Services/IStatsDataService.cs b/DeepDrftPublic.Client/Services/IStatsDataService.cs new file mode 100644 index 0000000..ba7a127 --- /dev/null +++ b/DeepDrftPublic.Client/Services/IStatsDataService.cs @@ -0,0 +1,15 @@ +using DeepDrftModels.DTOs; +using NetBlocks.Models; + +namespace DeepDrftPublic.Client.Services; + +/// +/// Home-stats read abstraction. Both SSR and WASM renders are served by StatsClientDataService +/// in this assembly, which delegates to over HTTP. Components inject +/// this single seam so they do not branch on render mode — mirrors . +/// +public interface IStatsDataService +{ + /// Aggregate figures behind the public home hero stat row, in one round-trip. + Task> GetHomeStats(); +} diff --git a/DeepDrftPublic.Client/Services/StatsClientDataService.cs b/DeepDrftPublic.Client/Services/StatsClientDataService.cs new file mode 100644 index 0000000..e7c12af --- /dev/null +++ b/DeepDrftPublic.Client/Services/StatsClientDataService.cs @@ -0,0 +1,22 @@ +using DeepDrftModels.DTOs; +using DeepDrftPublic.Client.Clients; +using NetBlocks.Models; + +namespace DeepDrftPublic.Client.Services; + +/// +/// backed by (HTTP to the DeepDrft.API +/// 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. +/// +public class StatsClientDataService : IStatsDataService +{ + private readonly StatsClient _statsClient; + + public StatsClientDataService(StatsClient statsClient) + { + _statsClient = statsClient; + } + + public Task> GetHomeStats() => _statsClient.GetHomeStats(); +} diff --git a/DeepDrftPublic.Client/Startup.cs b/DeepDrftPublic.Client/Startup.cs index 7e193d4..5ba3473 100644 --- a/DeepDrftPublic.Client/Startup.cs +++ b/DeepDrftPublic.Client/Startup.cs @@ -26,6 +26,10 @@ public static class Startup services.AddScoped(); services.AddScoped(); + // Home hero stats read surface — same HTTP posture as the track/release clients. + services.AddScoped(); + services.AddScoped(); + // 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(); diff --git a/DeepDrftPublic/Controllers/StatsProxyController.cs b/DeepDrftPublic/Controllers/StatsProxyController.cs new file mode 100644 index 0000000..37ad321 --- /dev/null +++ b/DeepDrftPublic/Controllers/StatsProxyController.cs @@ -0,0 +1,51 @@ +using Microsoft.AspNetCore.Mvc; + +namespace DeepDrftPublic.Controllers; + +/// +/// Proxies the public stats read surface to DeepDrftAPI so the browser never makes a cross-origin +/// request. Mirrors : the WASM client issues relative +/// api/stats/* 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. +/// +[ApiController] +[Route("api/stats")] +public class StatsProxyController : ControllerBase +{ + private readonly HttpClient _upstream; + private readonly ILogger _logger; + + public StatsProxyController(IHttpClientFactory httpClientFactory, ILogger logger) + { + _upstream = httpClientFactory.CreateClient("DeepDrft.API"); + _logger = logger; + } + + /// Proxies the home hero aggregate figures. Small JSON, buffered and relayed. + [HttpGet("home")] + public async Task 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"); + } + } +} diff --git a/DeepDrftTests/HomeStatsQueryTests.cs b/DeepDrftTests/HomeStatsQueryTests.cs new file mode 100644 index 0000000..c081d15 --- /dev/null +++ b/DeepDrftTests/HomeStatsQueryTests.cs @@ -0,0 +1,182 @@ +using Data.Data.Repositories; +using DeepDrftData.Data; +using DeepDrftData.Repositories; +using DeepDrftModels.Entities; +using DeepDrftModels.Enums; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; + +namespace DeepDrftTests; + +/// +/// Aggregate-query tests for the public home hero stat row, exercising +/// : 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 +/// — every predicate here (Count, GroupBy, Sum with a +/// null-coalesce) translates in process. +/// +[TestFixture] +public class HomeStatsQueryTests +{ + private DeepDrftContext _context = null!; + + [SetUp] + public void SetUp() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + _context = new DeepDrftContext(options); + } + + [TearDown] + public void TearDown() => _context.Dispose(); + + private TrackRepository CreateRepository() + => new(_context, NullLogger>.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 releases, IEnumerable 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()); + + var stats = await CreateRepository().GetHomeStatsAsync(); + + Assert.That(stats.CutReleaseTypeCounts.Any(c => c.ReleaseType == ReleaseType.EP), Is.False, + "EP has zero Cut releases and must be absent, not present-with-zero"); + Assert.That(stats.CutReleaseTypeCounts.Single(c => c.ReleaseType == ReleaseType.Single).Count, Is.EqualTo(2)); + Assert.That(stats.CutReleaseTypeCounts.Single(c => c.ReleaseType == ReleaseType.Album).Count, Is.EqualTo(1)); + } + + // Mix release count counts Mix-medium releases, and the runtime sum tolerates null durations: + // not-yet-backfilled tracks contribute 0 rather than throwing or skewing the total. + [Test] + public async Task GetHomeStatsAsync_MixRuntime_TreatsNullDurationsAsZero() + { + var mixA = Release("Mix A", ReleaseMedium.Mix); + var mixB = Release("Mix B", ReleaseMedium.Mix); + var cut = Release("Cut", ReleaseMedium.Cut); + await SeedAsync( + new[] { mixA, mixB, cut }, + new[] + { + Track(mixA, duration: 600d), + Track(mixB, duration: null), // not yet backfilled — contributes 0 + Track(cut, duration: 120d), // Cut track must not count toward mix runtime + }); + + var stats = await CreateRepository().GetHomeStatsAsync(); + + Assert.That(stats.MixReleaseCount, Is.EqualTo(2)); + Assert.That(stats.MixRuntimeSeconds, Is.EqualTo(600d)); + } + + // Soft-deleted releases and tracks never count toward any figure. + [Test] + public async Task GetHomeStatsAsync_ExcludesSoftDeletedRowsFromAllFigures() + { + var liveCut = Release("Live Cut", ReleaseMedium.Cut, ReleaseType.Album); + var deletedCut = Release("Dead Cut", ReleaseMedium.Cut, ReleaseType.Album); + deletedCut.IsDeleted = true; + var mix = Release("Mix", ReleaseMedium.Mix); + + var deletedMixTrack = Track(mix, duration: 999d); + deletedMixTrack.IsDeleted = true; + + await SeedAsync( + new[] { liveCut, deletedCut, mix }, + new[] { Track(liveCut), deletedMixTrack }); + + var stats = await CreateRepository().GetHomeStatsAsync(); + + Assert.That(stats.CutTrackCount, Is.EqualTo(1)); + Assert.That(stats.CutReleaseTypeCounts.Single(c => c.ReleaseType == ReleaseType.Album).Count, Is.EqualTo(1)); + Assert.That(stats.MixRuntimeSeconds, Is.EqualTo(0d), "the only mix track is soft-deleted"); + } + + // A live track under a directly-deleted release must be excluded from the track-based figures. + // SoftDeleteReleaseAsync does not cascade to child tracks, so without the !t.Release.IsDeleted + // guard the track-count and runtime figures are internally inconsistent with the release-level ones. + [Test] + public async Task GetHomeStatsAsync_ExcludesLiveTracksUnderSoftDeletedRelease() + { + var liveCut = Release("Live Cut", ReleaseMedium.Cut, ReleaseType.Album); + var deletedCut = Release("Dead Cut", ReleaseMedium.Cut, ReleaseType.Album); + deletedCut.IsDeleted = true; + var deletedMix = Release("Dead Mix", ReleaseMedium.Mix); + deletedMix.IsDeleted = true; + + // Both tracks are themselves live — only their parent release is soft-deleted. + var liveTrackUnderDeletedCut = Track(deletedCut); + var liveTrackUnderDeletedMix = Track(deletedMix, duration: 900d); + + await SeedAsync( + new[] { liveCut, deletedCut, deletedMix }, + new[] { Track(liveCut), liveTrackUnderDeletedCut, liveTrackUnderDeletedMix }); + + var stats = await CreateRepository().GetHomeStatsAsync(); + + Assert.That(stats.CutTrackCount, Is.EqualTo(1), + "live track under deleted Cut release must not inflate CutTrackCount"); + Assert.That(stats.MixReleaseCount, Is.EqualTo(0), + "deleted Mix release must not count"); + Assert.That(stats.MixRuntimeSeconds, Is.EqualTo(0d), + "live track under deleted Mix release must not inflate MixRuntimeSeconds"); + } +} diff --git a/DeepDrftTests/RuntimeFormatTests.cs b/DeepDrftTests/RuntimeFormatTests.cs new file mode 100644 index 0000000..f6c56c5 --- /dev/null +++ b/DeepDrftTests/RuntimeFormatTests.cs @@ -0,0 +1,40 @@ +using DeepDrftPublic.Client.Helpers; + +namespace DeepDrftTests; + +/// +/// Unit tests for the home-stat-row runtime formatter (): +/// the hh:mm shape, hour rollover past 60 minutes, multi-hour totals, and the clamp on non-positive / +/// non-finite input. +/// +[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")); +}