diff --git a/DeepDrftAPI/Controllers/TrackController.cs b/DeepDrftAPI/Controllers/TrackController.cs index fb0fde9..d46eb66 100644 --- a/DeepDrftAPI/Controllers/TrackController.cs +++ b/DeepDrftAPI/Controllers/TrackController.cs @@ -189,6 +189,7 @@ public class TrackController : ControllerBase [FromForm] string? artist, [FromForm] string? album, [FromForm] string? genre, + [FromForm] string? description, [FromForm] string? releaseDate, [FromForm] string? originalFileName, [FromForm] long createdByUserId, @@ -283,6 +284,7 @@ public class TrackController : ControllerBase artist, string.IsNullOrWhiteSpace(album) ? null : album, string.IsNullOrWhiteSpace(genre) ? null : genre, + string.IsNullOrWhiteSpace(description) ? null : description, parsedReleaseDate, createdByUserId, string.IsNullOrWhiteSpace(originalFileName) ? null : originalFileName, @@ -412,6 +414,7 @@ public class TrackController : ControllerBase release.Artist = request.Artist; release.Title = request.Album ?? string.Empty; release.Genre = request.Genre; + release.Description = request.Description; release.ReleaseDate = request.ReleaseDate; // ImagePath is tri-state: null = no change, "" = clear, value = set. diff --git a/DeepDrftAPI/Models/UpdateTrackMetadataRequest.cs b/DeepDrftAPI/Models/UpdateTrackMetadataRequest.cs index 6d97717..d443028 100644 --- a/DeepDrftAPI/Models/UpdateTrackMetadataRequest.cs +++ b/DeepDrftAPI/Models/UpdateTrackMetadataRequest.cs @@ -16,6 +16,7 @@ public record UpdateTrackMetadataRequest( string Artist, string? Album, string? Genre, + string? Description, DateOnly? ReleaseDate, string? ImagePath = null, ReleaseType? ReleaseType = null, diff --git a/DeepDrftAPI/Services/UnifiedTrackService.cs b/DeepDrftAPI/Services/UnifiedTrackService.cs index d0718ef..0327229 100644 --- a/DeepDrftAPI/Services/UnifiedTrackService.cs +++ b/DeepDrftAPI/Services/UnifiedTrackService.cs @@ -57,6 +57,7 @@ public class UnifiedTrackService string artist, string? album, string? genre, + string? description, DateOnly? releaseDate, long createdByUserId, string? originalFileName, @@ -106,8 +107,8 @@ public class UnifiedTrackService // Resolve the release FK before persisting the track. An upload with an album lands on the // shared release (created on first sighting); an upload without one stays a loose track with - // a null ReleaseId. Release-cardinal metadata (artist/genre/releaseDate/type/uploader) rides - // on the release, not the track. + // a null ReleaseId. Release-cardinal metadata (artist/genre/description/releaseDate/type/uploader) + // rides on the release, not the track. long? releaseId = null; if (!string.IsNullOrWhiteSpace(album)) { @@ -116,6 +117,7 @@ public class UnifiedTrackService Title = album, Artist = artist, Genre = genre, + Description = description, ReleaseDate = releaseDate, ReleaseType = releaseType, Medium = medium, diff --git a/DeepDrftData/Data/Configurations/ReleaseConfiguration.cs b/DeepDrftData/Data/Configurations/ReleaseConfiguration.cs index a9eb1c0..1e9ab4e 100644 --- a/DeepDrftData/Data/Configurations/ReleaseConfiguration.cs +++ b/DeepDrftData/Data/Configurations/ReleaseConfiguration.cs @@ -35,6 +35,11 @@ public class ReleaseConfiguration : BaseEntityConfiguration .HasMaxLength(100) .HasColumnName("genre"); + // Plain-text prose blurb. Generous ceiling for a paragraph; nullable (no data migration). + builder.Property(e => e.Description) + .HasMaxLength(4000) + .HasColumnName("description"); + builder.Property(e => e.ReleaseDate) .HasColumnName("release_date"); diff --git a/DeepDrftData/Migrations/20260616035252_AddReleaseDescription.Designer.cs b/DeepDrftData/Migrations/20260616035252_AddReleaseDescription.Designer.cs new file mode 100644 index 0000000..5515556 --- /dev/null +++ b/DeepDrftData/Migrations/20260616035252_AddReleaseDescription.Designer.cs @@ -0,0 +1,308 @@ +// +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("20260616035252_AddReleaseDescription")] + partial class AddReleaseDescription + { + /// + 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("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("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("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/20260616035252_AddReleaseDescription.cs b/DeepDrftData/Migrations/20260616035252_AddReleaseDescription.cs new file mode 100644 index 0000000..2f1316a --- /dev/null +++ b/DeepDrftData/Migrations/20260616035252_AddReleaseDescription.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace DeepDrftData.Migrations +{ + /// + public partial class AddReleaseDescription : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "description", + table: "release", + type: "character varying(4000)", + maxLength: 4000, + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "description", + table: "release"); + } + } +} diff --git a/DeepDrftData/Migrations/DeepDrftContextModelSnapshot.cs b/DeepDrftData/Migrations/DeepDrftContextModelSnapshot.cs index 16400b3..dbfb337 100644 --- a/DeepDrftData/Migrations/DeepDrftContextModelSnapshot.cs +++ b/DeepDrftData/Migrations/DeepDrftContextModelSnapshot.cs @@ -90,6 +90,11 @@ namespace DeepDrftData.Migrations .HasColumnType("bigint") .HasColumnName("created_by_user_id"); + b.Property("Description") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)") + .HasColumnName("description"); + b.Property("Genre") .HasMaxLength(100) .HasColumnType("character varying(100)") diff --git a/DeepDrftData/TrackConverter.cs b/DeepDrftData/TrackConverter.cs index a8c0a8b..9de23ac 100644 --- a/DeepDrftData/TrackConverter.cs +++ b/DeepDrftData/TrackConverter.cs @@ -24,6 +24,7 @@ public class TrackConverter : IEntityToModelConverter Title = entity.Title, Artist = entity.Artist, Genre = entity.Genre, + Description = entity.Description, ReleaseDate = entity.ReleaseDate, ImagePath = entity.ImagePath, Medium = entity.Medium, @@ -55,6 +56,7 @@ public class TrackConverter : IEntityToModelConverter Title = dto.Title, Artist = dto.Artist, Genre = dto.Genre, + Description = dto.Description, ReleaseDate = dto.ReleaseDate, ImagePath = dto.ImagePath, Medium = dto.Medium, diff --git a/DeepDrftData/TrackManager.cs b/DeepDrftData/TrackManager.cs index 108e228..643aa64 100644 --- a/DeepDrftData/TrackManager.cs +++ b/DeepDrftData/TrackManager.cs @@ -281,6 +281,7 @@ public class TrackManager releaseEntity.Title = release.Title; releaseEntity.Artist = release.Artist; releaseEntity.Genre = release.Genre; + releaseEntity.Description = release.Description; releaseEntity.ReleaseDate = release.ReleaseDate; releaseEntity.ImagePath = release.ImagePath; releaseEntity.Medium = release.Medium; diff --git a/DeepDrftManager/Components/Pages/Tracks/AlbumHeaderFields.razor b/DeepDrftManager/Components/Pages/Tracks/AlbumHeaderFields.razor index 113c85d..3ea9236 100644 --- a/DeepDrftManager/Components/Pages/Tracks/AlbumHeaderFields.razor +++ b/DeepDrftManager/Components/Pages/Tracks/AlbumHeaderFields.razor @@ -22,6 +22,11 @@ T="string" Label="Release Date (YYYY-MM-DD)" Placeholder="2024-01-15" Variant="Variant.Outlined" Disabled="Disabled" /> + + + @@ -79,6 +84,8 @@ [Parameter] public EventCallback ArtistChanged { get; set; } [Parameter] public string Genre { get; set; } = string.Empty; [Parameter] public EventCallback GenreChanged { get; set; } + [Parameter] public string Description { get; set; } = string.Empty; + [Parameter] public EventCallback DescriptionChanged { get; set; } [Parameter] public string ReleaseDate { get; set; } = string.Empty; [Parameter] public EventCallback ReleaseDateChanged { get; set; } [Parameter] public ReleaseType ReleaseType { get; set; } = ReleaseType.Single; diff --git a/DeepDrftManager/Components/Pages/Tracks/BatchEdit.razor b/DeepDrftManager/Components/Pages/Tracks/BatchEdit.razor index 9e80a5f..d344903 100644 --- a/DeepDrftManager/Components/Pages/Tracks/BatchEdit.razor +++ b/DeepDrftManager/Components/Pages/Tracks/BatchEdit.razor @@ -33,6 +33,7 @@ UpdateAsync( long id, string trackName, string artist, - string? album, string? genre, DateOnly? releaseDate, + string? album, string? genre, string? description, DateOnly? releaseDate, string? imagePath = null, ReleaseType? releaseType = null, ReleaseMedium? medium = null, @@ -385,6 +387,7 @@ public class CmsTrackService : ICmsTrackService artist, album, genre, + description, releaseDate, imagePath, releaseType = releaseType.HasValue ? (int?)releaseType.Value : null, diff --git a/DeepDrftManager/Services/ICmsTrackService.cs b/DeepDrftManager/Services/ICmsTrackService.cs index c6b2edb..3925e7f 100644 --- a/DeepDrftManager/Services/ICmsTrackService.cs +++ b/DeepDrftManager/Services/ICmsTrackService.cs @@ -30,6 +30,7 @@ public interface ICmsTrackService string artist, string? album, string? genre, + string? description, string? releaseDate, string? originalFileName, long createdByUserId, @@ -85,7 +86,7 @@ public interface ICmsTrackService /// Task UpdateAsync( long id, string trackName, string artist, - string? album, string? genre, DateOnly? releaseDate, + string? album, string? genre, string? description, DateOnly? releaseDate, string? imagePath = null, ReleaseType? releaseType = null, ReleaseMedium? medium = null, diff --git a/DeepDrftModels/DTOs/ReleaseDto.cs b/DeepDrftModels/DTOs/ReleaseDto.cs index 7d5b7bf..5df19f6 100644 --- a/DeepDrftModels/DTOs/ReleaseDto.cs +++ b/DeepDrftModels/DTOs/ReleaseDto.cs @@ -12,6 +12,7 @@ public class ReleaseDto : BaseModel public string Title { get; set; } = string.Empty; public string Artist { get; set; } = string.Empty; public string? Genre { get; set; } + public string? Description { get; set; } public DateOnly? ReleaseDate { get; set; } public string? ImagePath { get; set; } public ReleaseMedium Medium { get; set; } = ReleaseMedium.Cut; diff --git a/DeepDrftModels/Entities/ReleaseEntity.cs b/DeepDrftModels/Entities/ReleaseEntity.cs index 1ef3aa7..6cf3533 100644 --- a/DeepDrftModels/Entities/ReleaseEntity.cs +++ b/DeepDrftModels/Entities/ReleaseEntity.cs @@ -15,6 +15,10 @@ public class ReleaseEntity : BaseEntity, IEntity public required string Title { get; set; } public required string Artist { get; set; } public string? Genre { get; set; } + // Free-text prose blurb describing the release. Uniform across media (Cut/Session/Mix), so it + // lives on the base table alongside Genre rather than in a per-medium satellite. Plain text, + // max 4000 (configured in ReleaseConfiguration); nullable so existing rows migrate as NULL. + public string? Description { get; set; } public DateOnly? ReleaseDate { get; set; } public string? ImagePath { get; set; } public ReleaseType ReleaseType { get; set; } = ReleaseType.Single; diff --git a/DeepDrftTests/MediumWritePathTests.cs b/DeepDrftTests/MediumWritePathTests.cs index a282c63..a56e647 100644 --- a/DeepDrftTests/MediumWritePathTests.cs +++ b/DeepDrftTests/MediumWritePathTests.cs @@ -161,6 +161,82 @@ public class MediumWritePathTests Assert.That(dto.ReleaseType, Is.Null); } + // 11.G — Description round-trips through both converter directions verbatim (no medium dance, + // unlike ReleaseType): entity → DTO preserves the prose, and DTO → entity carries it back. + [Test] + public void Convert_Description_RoundTripsBothDirections() + { + const string prose = "A late-night set\nrecorded at the Vault."; + var entity = new ReleaseEntity + { + Title = "Live at the Vault", Artist = "Artist A", + Medium = ReleaseMedium.Session, Description = prose, + }; + + var dto = TrackConverter.Convert(entity); + Assert.That(dto.Description, Is.EqualTo(prose), "entity → DTO preserves Description"); + + var back = TrackConverter.Convert(dto); + Assert.That(back.Description, Is.EqualTo(prose), "DTO → entity preserves Description"); + } + + // 11.G — a null Description round-trips as null in both directions (existing rows migrate as NULL). + [Test] + public void Convert_NullDescription_RoundTripsAsNull() + { + var entity = new ReleaseEntity { Title = "Studio Album", Artist = "Artist C", Description = null }; + + var dto = TrackConverter.Convert(entity); + Assert.That(dto.Description, Is.Null); + + var back = TrackConverter.Convert(dto); + Assert.That(back.Description, Is.Null); + } + + // 11.G — Description rides the release-cardinal write channel onto the persisted release row, + // exactly as Genre does. FindOrCreateRelease is the upload-path projection point. + [Test] + public async Task FindOrCreateRelease_NewRelease_PersistsDescription() + { + const string prose = "Three cuts pressed for the summer."; + var manager = CreateManager(CreateRepository()); + + var data = ReleaseData("Studio Album", "Artist C", ReleaseMedium.Cut); + data.Description = prose; + + var result = await manager.FindOrCreateRelease("Studio Album", "Artist C", data); + + Assert.That(result.Success, Is.True); + Assert.That(result.Value!.Description, Is.EqualTo(prose)); + + var stored = await CreateRepository().GetReleaseByIdAsync(result.Value.Id); + Assert.That(stored!.Description, Is.EqualTo(prose)); + } + + // 11.C — editing a track's linked release sets the Description on the persisted release row, + // mirroring the PUT api/track/meta apply (release.Description = request.Description). + [Test] + public async Task Update_SetsReleaseDescription_PersistsDescription() + { + const string prose = "Now with a proper blurb."; + var repo = CreateRepository(); + ITrackService manager = CreateManager(repo); + + var release = new ReleaseEntity { Title = "Studio Album", Artist = "Artist C", Medium = ReleaseMedium.Cut }; + var track = new TrackEntity { EntryKey = "ek-1", TrackName = "Track", Release = release }; + _context.Tracks.Add(track); + await _context.SaveChangesAsync(); + + var loaded = (await manager.GetById(track.Id)).Value!; + loaded.Release!.Description = prose; + + var result = await manager.Update(loaded); + Assert.That(result.Success, Is.True); + + var stored = await CreateRepository().GetReleaseByIdAsync(release.Id); + Assert.That(stored!.Description, Is.EqualTo(prose)); + } + // 9.5.C — releaseId filter returns only the tracks of the given release. Built on the repository // directly to assert the WHERE release_id predicate in isolation. [Test]