From d47a5e00afaa06dd9889615724c79c740836bee1 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Wed, 10 Jun 2026 21:36:00 -0400 Subject: [PATCH] feat(tracks): add ReleaseType and TrackNumber to track metadata model and CMS edit form --- DeepDrftAPI/Controllers/TrackController.cs | 31 +++++ .../Models/UpdateTrackMetadataRequest.cs | 6 +- DeepDrftAPI/Services/UnifiedTrackService.cs | 5 + .../Data/Configurations/TrackConfiguration.cs | 13 ++ ...0_AddReleaseTypeAndTrackNumber.Designer.cs | 121 ++++++++++++++++++ ...0611005700_AddReleaseTypeAndTrackNumber.cs | 41 ++++++ .../DeepDrftContextModelSnapshot.cs | 14 ++ DeepDrftData/TrackConverter.cs | 8 +- .../Components/Pages/Tracks/TrackEdit.razor | 25 +++- .../Components/Pages/Tracks/TrackNew.razor | 5 +- DeepDrftManager/Services/CmsTrackService.cs | 9 ++ DeepDrftManager/Services/ICmsTrackService.cs | 5 + DeepDrftModels/DTOs/TrackDto.cs | 3 + DeepDrftModels/Entities/TrackEntity.cs | 3 + DeepDrftModels/Enums/ReleaseType.cs | 9 ++ 15 files changed, 292 insertions(+), 6 deletions(-) create mode 100644 DeepDrftData/Migrations/20260611005700_AddReleaseTypeAndTrackNumber.Designer.cs create mode 100644 DeepDrftData/Migrations/20260611005700_AddReleaseTypeAndTrackNumber.cs create mode 100644 DeepDrftModels/Enums/ReleaseType.cs diff --git a/DeepDrftAPI/Controllers/TrackController.cs b/DeepDrftAPI/Controllers/TrackController.cs index 3b9617c..fc674d1 100644 --- a/DeepDrftAPI/Controllers/TrackController.cs +++ b/DeepDrftAPI/Controllers/TrackController.cs @@ -7,6 +7,7 @@ using DeepDrftContent.FileDatabase.Models; using DeepDrftContent.Processors; using DeepDrftData; using DeepDrftModels.DTOs; +using DeepDrftModels.Enums; using Microsoft.AspNetCore.Mvc; namespace DeepDrftAPI.Controllers; @@ -187,6 +188,8 @@ public class TrackController : ControllerBase [FromForm] string? releaseDate, [FromForm] string? originalFileName, [FromForm] long createdByUserId, + [FromForm] string? releaseType, + [FromForm] int? trackNumber, CancellationToken cancellationToken) { _logger.LogInformation("UploadTrack called: trackName={TrackName}, artist={Artist}, fileName={FileName}, size={Size}", @@ -222,6 +225,22 @@ public class TrackController : ControllerBase parsedReleaseDate = parsed; } + // Default to Single for null/unparseable release type; default track number to a valid 1-based value. + ReleaseType parsedReleaseType; + if (!string.IsNullOrWhiteSpace(releaseType) + && Enum.TryParse(releaseType, ignoreCase: true, out var rt) + && Enum.IsDefined(rt)) + { + parsedReleaseType = rt; + } + else + { + parsedReleaseType = ReleaseType.Single; + if (!string.IsNullOrWhiteSpace(releaseType)) + _logger.LogWarning("UploadTrack: unrecognised releaseType value '{Value}', defaulting to Single", releaseType); + } + var resolvedTrackNumber = trackNumber is > 0 ? trackNumber.Value : 1; + // AudioProcessor.ProcessWavFileAsync requires a path ending in .wav and reads from disk. // Path.GetTempFileName() yields .tmp, which fails that check — generate our own .wav path. var tempPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N") + ".wav"); @@ -245,6 +264,8 @@ public class TrackController : ControllerBase parsedReleaseDate, createdByUserId, string.IsNullOrWhiteSpace(originalFileName) ? null : originalFileName, + parsedReleaseType, + resolvedTrackNumber, cancellationToken); if (!result.Success || result.Value is null) @@ -339,6 +360,9 @@ public class TrackController : ControllerBase return NotFound(); } + if (request.TrackNumber is <= 0) + return BadRequest("trackNumber must be a positive integer when provided."); + var track = lookup.Value; track.TrackName = request.TrackName; track.Artist = request.Artist; @@ -350,6 +374,13 @@ public class TrackController : ControllerBase if (request.ImagePath is not null) track.ImagePath = string.IsNullOrEmpty(request.ImagePath) ? null : request.ImagePath; + // ReleaseType / TrackNumber are non-null on the entity; null in the request means "no change". + if (request.ReleaseType is not null) + track.ReleaseType = request.ReleaseType.Value; + + if (request.TrackNumber is > 0) + track.TrackNumber = request.TrackNumber.Value; + var update = await _sqlTrackService.Update(track); if (!update.Success) { diff --git a/DeepDrftAPI/Models/UpdateTrackMetadataRequest.cs b/DeepDrftAPI/Models/UpdateTrackMetadataRequest.cs index 36eb46b..49b4410 100644 --- a/DeepDrftAPI/Models/UpdateTrackMetadataRequest.cs +++ b/DeepDrftAPI/Models/UpdateTrackMetadataRequest.cs @@ -1,3 +1,5 @@ +using DeepDrftModels.Enums; + namespace DeepDrftAPI.Models; /// @@ -15,4 +17,6 @@ public record UpdateTrackMetadataRequest( string? Album, string? Genre, DateOnly? ReleaseDate, - string? ImagePath = null); + string? ImagePath = null, + ReleaseType? ReleaseType = null, + int? TrackNumber = null); diff --git a/DeepDrftAPI/Services/UnifiedTrackService.cs b/DeepDrftAPI/Services/UnifiedTrackService.cs index 796105c..ecb5bff 100644 --- a/DeepDrftAPI/Services/UnifiedTrackService.cs +++ b/DeepDrftAPI/Services/UnifiedTrackService.cs @@ -3,6 +3,7 @@ using DeepDrftContent.Constants; using DeepDrftContent.Processors; using DeepDrftData; using DeepDrftModels.DTOs; +using DeepDrftModels.Enums; using NetBlocks.Models; using FileDb = DeepDrftContent.FileDatabase.Services.FileDatabase; @@ -50,6 +51,8 @@ public class UnifiedTrackService DateOnly? releaseDate, long createdByUserId, string? originalFileName, + ReleaseType releaseType, + int trackNumber, CancellationToken ct) { var unpersisted = await _contentTrackContentService.AddTrackFromWavAsync( @@ -62,6 +65,8 @@ public class UnifiedTrackService } unpersisted.CreatedByUserId = createdByUserId; + unpersisted.ReleaseType = releaseType; + unpersisted.TrackNumber = trackNumber; var saveResult = await _sqlTrackService.Create(TrackConverter.Convert(unpersisted)); if (!saveResult.Success || saveResult.Value is null) diff --git a/DeepDrftData/Data/Configurations/TrackConfiguration.cs b/DeepDrftData/Data/Configurations/TrackConfiguration.cs index 128639d..c42bdfd 100644 --- a/DeepDrftData/Data/Configurations/TrackConfiguration.cs +++ b/DeepDrftData/Data/Configurations/TrackConfiguration.cs @@ -1,5 +1,6 @@ using Data.Data.Configurations; using DeepDrftModels.Entities; +using DeepDrftModels.Enums; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; @@ -57,6 +58,18 @@ public class TrackConfiguration : BaseEntityConfiguration .HasMaxLength(500) .HasColumnName("original_file_name"); + builder.Property(e => e.ReleaseType) + .IsRequired() + .HasConversion() // Store as readable string, not int ordinal + .HasMaxLength(20) + .HasColumnName("release_type") + .HasDefaultValue(ReleaseType.Single); + + builder.Property(e => e.TrackNumber) + .IsRequired() + .HasColumnName("track_number") + .HasDefaultValue(1); + // Names the is_deleted index explicitly. BaseEntityConfiguration.Configure already // calls HasIndex(e => e.IsDeleted); this adds HasDatabaseName so EF always uses // "IX_track_is_deleted" regardless of auto-naming conventions. diff --git a/DeepDrftData/Migrations/20260611005700_AddReleaseTypeAndTrackNumber.Designer.cs b/DeepDrftData/Migrations/20260611005700_AddReleaseTypeAndTrackNumber.Designer.cs new file mode 100644 index 0000000..053bbee --- /dev/null +++ b/DeepDrftData/Migrations/20260611005700_AddReleaseTypeAndTrackNumber.Designer.cs @@ -0,0 +1,121 @@ +// +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("20260611005700_AddReleaseTypeAndTrackNumber")] + partial class AddReleaseTypeAndTrackNumber + { + /// + 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.TrackEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Album") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("album"); + + 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("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("OriginalFileName") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("original_file_name"); + + 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("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.ToTable("track", (string)null); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/DeepDrftData/Migrations/20260611005700_AddReleaseTypeAndTrackNumber.cs b/DeepDrftData/Migrations/20260611005700_AddReleaseTypeAndTrackNumber.cs new file mode 100644 index 0000000..eacb48c --- /dev/null +++ b/DeepDrftData/Migrations/20260611005700_AddReleaseTypeAndTrackNumber.cs @@ -0,0 +1,41 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace DeepDrftData.Migrations +{ + /// + public partial class AddReleaseTypeAndTrackNumber : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "release_type", + table: "track", + type: "character varying(20)", + maxLength: 20, + nullable: false, + defaultValue: "Single"); + + migrationBuilder.AddColumn( + name: "track_number", + table: "track", + type: "integer", + nullable: false, + defaultValue: 1); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "release_type", + table: "track"); + + migrationBuilder.DropColumn( + name: "track_number", + table: "track"); + } + } +} diff --git a/DeepDrftData/Migrations/DeepDrftContextModelSnapshot.cs b/DeepDrftData/Migrations/DeepDrftContextModelSnapshot.cs index 45cd361..00c5a77 100644 --- a/DeepDrftData/Migrations/DeepDrftContextModelSnapshot.cs +++ b/DeepDrftData/Migrations/DeepDrftContextModelSnapshot.cs @@ -81,12 +81,26 @@ namespace DeepDrftData.Migrations .HasColumnType("date") .HasColumnName("release_date"); + b.Property("ReleaseType") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("Single") + .HasColumnName("release_type"); + 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"); diff --git a/DeepDrftData/TrackConverter.cs b/DeepDrftData/TrackConverter.cs index b8d2172..7ebd13e 100644 --- a/DeepDrftData/TrackConverter.cs +++ b/DeepDrftData/TrackConverter.cs @@ -25,7 +25,9 @@ public class TrackConverter : IEntityToModelConverter ReleaseDate = entity.ReleaseDate, ImagePath = entity.ImagePath, CreatedByUserId = entity.CreatedByUserId, - OriginalFileName = entity.OriginalFileName + OriginalFileName = entity.OriginalFileName, + ReleaseType = entity.ReleaseType, + TrackNumber = entity.TrackNumber }; public static TrackEntity Convert(TrackDto model) => new() @@ -41,6 +43,8 @@ public class TrackConverter : IEntityToModelConverter ReleaseDate = model.ReleaseDate, ImagePath = model.ImagePath, CreatedByUserId = model.CreatedByUserId, - OriginalFileName = model.OriginalFileName + OriginalFileName = model.OriginalFileName, + ReleaseType = model.ReleaseType, + TrackNumber = model.TrackNumber }; } diff --git a/DeepDrftManager/Components/Pages/Tracks/TrackEdit.razor b/DeepDrftManager/Components/Pages/Tracks/TrackEdit.razor index f5a95aa..745e1bb 100644 --- a/DeepDrftManager/Components/Pages/Tracks/TrackEdit.razor +++ b/DeepDrftManager/Components/Pages/Tracks/TrackEdit.razor @@ -1,5 +1,6 @@ @page "/tracks/{Id:long}" @using DeepDrftManager.Services +@using DeepDrftModels.Enums @using Microsoft.AspNetCore.Components.Forms @attribute [Authorize] @inject ICmsTrackService CmsTrackService @@ -62,6 +63,20 @@ Label="Genre" Variant="Variant.Outlined" /> + + @foreach (var releaseType in Enum.GetValues()) + { + @releaseType + } + + + + @if (ImagePreviewUrl is { } previewUrl) @@ -207,7 +222,9 @@ string.IsNullOrWhiteSpace(_form.Album) ? null : _form.Album, string.IsNullOrWhiteSpace(_form.Genre) ? null : _form.Genre, releaseDate, - string.IsNullOrEmpty(_form.ImagePath) ? "" : _form.ImagePath); + string.IsNullOrEmpty(_form.ImagePath) ? "" : _form.ImagePath, + _form.ReleaseType, + _form.TrackNumber); if (updated.Success) { Snackbar.Add("Track updated.", Severity.Success); @@ -287,6 +304,8 @@ public string? Genre { get; set; } public string? ImagePath { get; set; } public DateTime? ReleaseDate { get; set; } + public ReleaseType ReleaseType { get; set; } = ReleaseType.Single; + public int TrackNumber { get; set; } = 1; public static TrackEditForm From(TrackDto track) => new() { @@ -297,7 +316,9 @@ ImagePath = track.ImagePath, ReleaseDate = track.ReleaseDate is { } d ? d.ToDateTime(TimeOnly.MinValue) - : null + : null, + ReleaseType = track.ReleaseType, + TrackNumber = track.TrackNumber }; } } diff --git a/DeepDrftManager/Components/Pages/Tracks/TrackNew.razor b/DeepDrftManager/Components/Pages/Tracks/TrackNew.razor index 8fdcc29..10aafd8 100644 --- a/DeepDrftManager/Components/Pages/Tracks/TrackNew.razor +++ b/DeepDrftManager/Components/Pages/Tracks/TrackNew.razor @@ -1,6 +1,7 @@ @page "/tracks/new" @using System.Security.Claims @using DeepDrftManager.Services +@using DeepDrftModels.Enums @attribute [Authorize] @inject ICmsTrackService CmsTrackService @@ -202,7 +203,9 @@ string.IsNullOrWhiteSpace(_genre) ? null : _genre, string.IsNullOrWhiteSpace(_releaseDate) ? null : _releaseDate, _selectedFile.Name, - createdByUserId); + createdByUserId, + releaseType: ReleaseType.Single, + trackNumber: 1); if (result.Success) { diff --git a/DeepDrftManager/Services/CmsTrackService.cs b/DeepDrftManager/Services/CmsTrackService.cs index c6fb909..479ddda 100644 --- a/DeepDrftManager/Services/CmsTrackService.cs +++ b/DeepDrftManager/Services/CmsTrackService.cs @@ -2,6 +2,7 @@ using System.Net; using System.Net.Http.Headers; using System.Net.Http.Json; using DeepDrftModels.DTOs; +using DeepDrftModels.Enums; using Models.Common; using NetBlocks.Models; @@ -41,6 +42,8 @@ public class CmsTrackService : ICmsTrackService string? releaseDate, string? originalFileName, long createdByUserId, + ReleaseType releaseType, + int trackNumber, CancellationToken ct = default) { // Rebuild the multipart container so the boundary is owned by HttpClient and the @@ -58,6 +61,8 @@ public class CmsTrackService : ICmsTrackService // Explicit field — decouples the admin-visible display name from the WAV part's content-disposition filename. if (!string.IsNullOrWhiteSpace(originalFileName)) multipart.Add(new StringContent(originalFileName), "originalFileName"); multipart.Add(new StringContent(createdByUserId.ToString()), "createdByUserId"); + multipart.Add(new StringContent(releaseType.ToString()), "releaseType"); + multipart.Add(new StringContent(trackNumber.ToString()), "trackNumber"); var client = _httpClientFactory.CreateClient(ContentCmsClientName); using var request = new HttpRequestMessage(HttpMethod.Post, UploadPath) { Content = multipart }; @@ -322,6 +327,8 @@ public class CmsTrackService : ICmsTrackService long id, string trackName, string artist, string? album, string? genre, DateOnly? releaseDate, string? imagePath = null, + ReleaseType? releaseType = null, + int? trackNumber = null, CancellationToken ct = default) { var client = _httpClientFactory.CreateClient(ContentCmsClientName); @@ -333,6 +340,8 @@ public class CmsTrackService : ICmsTrackService genre, releaseDate, imagePath, + releaseType = releaseType?.ToString(), + trackNumber, }; HttpResponseMessage response; diff --git a/DeepDrftManager/Services/ICmsTrackService.cs b/DeepDrftManager/Services/ICmsTrackService.cs index e98cff8..1be29f3 100644 --- a/DeepDrftManager/Services/ICmsTrackService.cs +++ b/DeepDrftManager/Services/ICmsTrackService.cs @@ -1,4 +1,5 @@ using DeepDrftModels.DTOs; +using DeepDrftModels.Enums; using Models.Common; using NetBlocks.Models; @@ -29,6 +30,8 @@ public interface ICmsTrackService string? releaseDate, string? originalFileName, long createdByUserId, + ReleaseType releaseType, + int trackNumber, CancellationToken ct = default); /// @@ -69,6 +72,8 @@ public interface ICmsTrackService long id, string trackName, string artist, string? album, string? genre, DateOnly? releaseDate, string? imagePath = null, + ReleaseType? releaseType = null, + int? trackNumber = null, CancellationToken ct = default); /// diff --git a/DeepDrftModels/DTOs/TrackDto.cs b/DeepDrftModels/DTOs/TrackDto.cs index 6b499db..aea86a4 100644 --- a/DeepDrftModels/DTOs/TrackDto.cs +++ b/DeepDrftModels/DTOs/TrackDto.cs @@ -1,3 +1,4 @@ +using DeepDrftModels.Enums; using Models.Models; namespace DeepDrftModels.DTOs; @@ -19,4 +20,6 @@ public class TrackDto : BaseModel public string? ImagePath { get; set; } public long? CreatedByUserId { get; set; } public string? OriginalFileName { get; set; } + public ReleaseType ReleaseType { get; set; } = ReleaseType.Single; + public int TrackNumber { get; set; } = 1; } diff --git a/DeepDrftModels/Entities/TrackEntity.cs b/DeepDrftModels/Entities/TrackEntity.cs index 688c22e..f60476f 100644 --- a/DeepDrftModels/Entities/TrackEntity.cs +++ b/DeepDrftModels/Entities/TrackEntity.cs @@ -1,3 +1,4 @@ +using DeepDrftModels.Enums; using Models.Entities; namespace DeepDrftModels.Entities; @@ -16,4 +17,6 @@ public class TrackEntity : BaseEntity, IEntity public string? ImagePath { get; set; } public long? CreatedByUserId { get; set; } public string? OriginalFileName { get; set; } + public ReleaseType ReleaseType { get; set; } = ReleaseType.Single; + public int TrackNumber { get; set; } = 1; } diff --git a/DeepDrftModels/Enums/ReleaseType.cs b/DeepDrftModels/Enums/ReleaseType.cs new file mode 100644 index 0000000..25c1b7a --- /dev/null +++ b/DeepDrftModels/Enums/ReleaseType.cs @@ -0,0 +1,9 @@ +namespace DeepDrftModels.Enums; + +/// The commercial release format of a track's parent release. +public enum ReleaseType +{ + Single, + EP, + Album +}