Merge branch 'p8-w12-release-track-normalize' into dev
This commit is contained in:
@@ -77,12 +77,13 @@ public class TrackController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GET api/track/albums (unauthenticated)
|
// GET api/track/albums (unauthenticated)
|
||||||
// Distinct non-null albums with track counts and cover keys. Public browse data, same posture as
|
// All releases with per-release track counts. Public browse data, same posture as GET
|
||||||
// GET api/track/page. Literal segment, declared before the parameterized "{trackId}" route.
|
// api/track/page. Literal segment, declared before the parameterized "{trackId}" route.
|
||||||
|
// Route name kept as "albums" for client/proxy compatibility; the payload is List<ReleaseDto>.
|
||||||
[HttpGet("albums")]
|
[HttpGet("albums")]
|
||||||
public async Task<ActionResult> GetAlbums(CancellationToken ct = default)
|
public async Task<ActionResult> GetAlbums(CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var result = await _sqlTrackService.GetDistinctAlbums(ct);
|
var result = await _sqlTrackService.GetReleases(ct);
|
||||||
if (!result.Success || result.Value is null)
|
if (!result.Success || result.Value is null)
|
||||||
{
|
{
|
||||||
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||||
@@ -367,23 +368,33 @@ public class TrackController : ControllerBase
|
|||||||
return BadRequest("trackNumber must be a positive integer when provided.");
|
return BadRequest("trackNumber must be a positive integer when provided.");
|
||||||
|
|
||||||
var track = lookup.Value;
|
var track = lookup.Value;
|
||||||
|
|
||||||
|
// Track-cardinal fields update the track row directly.
|
||||||
track.TrackName = request.TrackName;
|
track.TrackName = request.TrackName;
|
||||||
track.Artist = request.Artist;
|
|
||||||
track.Album = request.Album;
|
|
||||||
track.Genre = request.Genre;
|
|
||||||
track.ReleaseDate = request.ReleaseDate;
|
|
||||||
|
|
||||||
// Only update ImagePath when the request explicitly provides a value (null = no change, "" = clear).
|
|
||||||
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)
|
if (request.TrackNumber is > 0)
|
||||||
track.TrackNumber = request.TrackNumber.Value;
|
track.TrackNumber = request.TrackNumber.Value;
|
||||||
|
|
||||||
|
// Release-cardinal fields update the linked release (handled in TrackManager.Update, which
|
||||||
|
// persists track.Release when the track carries a resolved ReleaseId). The loaded track has
|
||||||
|
// its Release populated via the Include; mutate it in place so the edited values flow through.
|
||||||
|
// A loose track (no release) cannot take release-cardinal edits — there is no release row to
|
||||||
|
// write to — so these fields are simply not persisted in that case.
|
||||||
|
if (track.Release is { } release)
|
||||||
|
{
|
||||||
|
release.Artist = request.Artist;
|
||||||
|
release.Title = request.Album ?? string.Empty;
|
||||||
|
release.Genre = request.Genre;
|
||||||
|
release.ReleaseDate = request.ReleaseDate;
|
||||||
|
|
||||||
|
// ImagePath is tri-state: null = no change, "" = clear, value = set.
|
||||||
|
if (request.ImagePath is not null)
|
||||||
|
release.ImagePath = string.IsNullOrEmpty(request.ImagePath) ? null : request.ImagePath;
|
||||||
|
|
||||||
|
// ReleaseType is non-null on the release; null in the request means "no change".
|
||||||
|
if (request.ReleaseType is not null)
|
||||||
|
release.ReleaseType = request.ReleaseType.Value;
|
||||||
|
}
|
||||||
|
|
||||||
var update = await _sqlTrackService.Update(track);
|
var update = await _sqlTrackService.Update(track);
|
||||||
if (!update.Success)
|
if (!update.Success)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -65,11 +65,43 @@ public class UnifiedTrackService
|
|||||||
return ResultContainer<TrackDto>.CreateFailResult("Failed to process and store WAV.");
|
return ResultContainer<TrackDto>.CreateFailResult("Failed to process and store WAV.");
|
||||||
}
|
}
|
||||||
|
|
||||||
unpersisted.CreatedByUserId = createdByUserId;
|
|
||||||
unpersisted.ReleaseType = releaseType;
|
|
||||||
unpersisted.TrackNumber = trackNumber;
|
unpersisted.TrackNumber = trackNumber;
|
||||||
|
|
||||||
var saveResult = await _sqlTrackService.Create(TrackConverter.Convert(unpersisted));
|
// 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.
|
||||||
|
long? releaseId = null;
|
||||||
|
if (!string.IsNullOrWhiteSpace(album))
|
||||||
|
{
|
||||||
|
var releaseData = new ReleaseDto
|
||||||
|
{
|
||||||
|
Title = album,
|
||||||
|
Artist = artist,
|
||||||
|
Genre = genre,
|
||||||
|
ReleaseDate = releaseDate,
|
||||||
|
ReleaseType = releaseType,
|
||||||
|
CreatedByUserId = createdByUserId,
|
||||||
|
};
|
||||||
|
|
||||||
|
var releaseResult = await _sqlTrackService.FindOrCreateRelease(album, artist, releaseData, ct);
|
||||||
|
if (!releaseResult.Success || releaseResult.Value is null)
|
||||||
|
{
|
||||||
|
var error = releaseResult.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||||
|
_logger.LogError(
|
||||||
|
"Track persisted to vault but release resolution failed. Orphaned entry: {EntryKey}. Error: {Error}",
|
||||||
|
unpersisted.EntryKey, error);
|
||||||
|
return ResultContainer<TrackDto>.CreateFailResult($"Track was uploaded but could not be saved: {error}");
|
||||||
|
}
|
||||||
|
|
||||||
|
releaseId = releaseResult.Value.Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
var trackDto = TrackConverter.Convert(unpersisted);
|
||||||
|
trackDto.ReleaseId = releaseId;
|
||||||
|
trackDto.Release = null; // FK already resolved; Create must not re-resolve a detached graph.
|
||||||
|
|
||||||
|
var saveResult = await _sqlTrackService.Create(trackDto);
|
||||||
if (!saveResult.Success || saveResult.Value is null)
|
if (!saveResult.Success || saveResult.Value is null)
|
||||||
{
|
{
|
||||||
// Vault write succeeded, SQL persist failed — audio is orphaned in the tracks vault
|
// Vault write succeeded, SQL persist failed — audio is orphaned in the tracks vault
|
||||||
|
|||||||
@@ -67,15 +67,13 @@ public class TrackContentService
|
|||||||
throw new InvalidOperationException("Failed to store audio in FileDatabase");
|
throw new InvalidOperationException("Failed to store audio in FileDatabase");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create the track entity for SQL database
|
// Create the track entity for SQL database. Post Phase 8 §8.0 the entity holds only
|
||||||
|
// track-cardinal fields; release-cardinal data (artist/album/genre/releaseDate) is
|
||||||
|
// resolved into a ReleaseEntity by the caller (UnifiedTrackService) and linked via FK.
|
||||||
var trackEntity = new TrackEntity
|
var trackEntity = new TrackEntity
|
||||||
{
|
{
|
||||||
EntryKey = trackId, // FileDatabase entry ID
|
EntryKey = trackId, // FileDatabase entry ID
|
||||||
TrackName = trackName,
|
TrackName = trackName,
|
||||||
Artist = artist,
|
|
||||||
Album = album,
|
|
||||||
Genre = genre,
|
|
||||||
ReleaseDate = releaseDate,
|
|
||||||
OriginalFileName = originalFileName
|
OriginalFileName = originalFileName
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
using Data.Data.Configurations;
|
||||||
|
using DeepDrftModels.Entities;
|
||||||
|
using DeepDrftModels.Enums;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||||
|
|
||||||
|
namespace DeepDrftData.Data.Configurations;
|
||||||
|
|
||||||
|
public class ReleaseConfiguration : BaseEntityConfiguration<ReleaseEntity>
|
||||||
|
{
|
||||||
|
public override void Configure(EntityTypeBuilder<ReleaseEntity> builder)
|
||||||
|
{
|
||||||
|
// Wires up Id PK + audit columns (CreatedAt, UpdatedAt, IsDeleted) and the IsDeleted index.
|
||||||
|
base.Configure(builder);
|
||||||
|
|
||||||
|
builder.ToTable("release");
|
||||||
|
|
||||||
|
// Map the base audit columns to the snake_case naming the rest of the schema uses.
|
||||||
|
builder.Property(e => e.Id).HasColumnName("id");
|
||||||
|
builder.Property(e => e.CreatedAt).HasColumnName("created_at");
|
||||||
|
builder.Property(e => e.UpdatedAt).HasColumnName("updated_at");
|
||||||
|
builder.Property(e => e.IsDeleted).HasColumnName("is_deleted");
|
||||||
|
|
||||||
|
builder.Property(e => e.Title)
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnName("title");
|
||||||
|
|
||||||
|
builder.Property(e => e.Artist)
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnName("artist");
|
||||||
|
|
||||||
|
builder.Property(e => e.Genre)
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnName("genre");
|
||||||
|
|
||||||
|
builder.Property(e => e.ReleaseDate)
|
||||||
|
.HasColumnName("release_date");
|
||||||
|
|
||||||
|
builder.Property(e => e.ImagePath)
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnName("image_path");
|
||||||
|
|
||||||
|
builder.Property(e => e.ReleaseType)
|
||||||
|
.IsRequired()
|
||||||
|
.HasConversion<string>() // Store as readable string, not int ordinal
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnName("release_type")
|
||||||
|
.HasDefaultValue(ReleaseType.Single);
|
||||||
|
|
||||||
|
builder.Property(e => e.CreatedByUserId)
|
||||||
|
.HasColumnName("created_by_user_id");
|
||||||
|
|
||||||
|
// Names the is_deleted index explicitly. BaseEntityConfiguration.Configure already
|
||||||
|
// calls HasIndex(e => e.IsDeleted); this adds HasDatabaseName so EF always uses
|
||||||
|
// "IX_release_is_deleted" regardless of auto-naming conventions.
|
||||||
|
builder.HasIndex(e => e.IsDeleted).HasDatabaseName("IX_release_is_deleted");
|
||||||
|
|
||||||
|
// Unique constraint on the natural key (title + artist). Prevents duplicate release rows
|
||||||
|
// from concurrent uploads of the same album. The FindOrCreateRelease path catches the
|
||||||
|
// resulting ClassifiedDbException (UniqueViolation) and re-queries for the winning row.
|
||||||
|
builder.HasIndex(e => new { e.Title, e.Artist })
|
||||||
|
.IsUnique()
|
||||||
|
.HasDatabaseName("IX_release_title_artist");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
using Data.Data.Configurations;
|
using Data.Data.Configurations;
|
||||||
using DeepDrftModels.Entities;
|
using DeepDrftModels.Entities;
|
||||||
using DeepDrftModels.Enums;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||||
|
|
||||||
@@ -31,45 +30,25 @@ public class TrackConfiguration : BaseEntityConfiguration<TrackEntity>
|
|||||||
.HasMaxLength(200)
|
.HasMaxLength(200)
|
||||||
.HasColumnName("track_name");
|
.HasColumnName("track_name");
|
||||||
|
|
||||||
builder.Property(e => e.Artist)
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(200)
|
|
||||||
.HasColumnName("artist");
|
|
||||||
|
|
||||||
builder.Property(e => e.Album)
|
|
||||||
.HasMaxLength(200)
|
|
||||||
.HasColumnName("album");
|
|
||||||
|
|
||||||
builder.Property(e => e.Genre)
|
|
||||||
.HasMaxLength(100)
|
|
||||||
.HasColumnName("genre");
|
|
||||||
|
|
||||||
builder.Property(e => e.ReleaseDate)
|
|
||||||
.HasColumnName("release_date");
|
|
||||||
|
|
||||||
builder.Property(e => e.ImagePath)
|
|
||||||
.HasMaxLength(500)
|
|
||||||
.HasColumnName("image_path");
|
|
||||||
|
|
||||||
builder.Property(e => e.CreatedByUserId)
|
|
||||||
.HasColumnName("created_by_user_id");
|
|
||||||
|
|
||||||
builder.Property(e => e.OriginalFileName)
|
builder.Property(e => e.OriginalFileName)
|
||||||
.HasMaxLength(500)
|
.HasMaxLength(500)
|
||||||
.HasColumnName("original_file_name");
|
.HasColumnName("original_file_name");
|
||||||
|
|
||||||
builder.Property(e => e.ReleaseType)
|
|
||||||
.IsRequired()
|
|
||||||
.HasConversion<string>() // Store as readable string, not int ordinal
|
|
||||||
.HasMaxLength(20)
|
|
||||||
.HasColumnName("release_type")
|
|
||||||
.HasDefaultValue(ReleaseType.Single);
|
|
||||||
|
|
||||||
builder.Property(e => e.TrackNumber)
|
builder.Property(e => e.TrackNumber)
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnName("track_number")
|
.HasColumnName("track_number")
|
||||||
.HasDefaultValue(1);
|
.HasDefaultValue(1);
|
||||||
|
|
||||||
|
builder.Property(e => e.ReleaseId)
|
||||||
|
.HasColumnName("release_id");
|
||||||
|
|
||||||
|
// Nullable FK to the release-cardinal row. SetNull on delete: removing a release leaves its
|
||||||
|
// tracks intact as loose tracks rather than cascading them away.
|
||||||
|
builder.HasOne(e => e.Release)
|
||||||
|
.WithMany(r => r.Tracks)
|
||||||
|
.HasForeignKey(e => e.ReleaseId)
|
||||||
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
// Names the is_deleted index explicitly. BaseEntityConfiguration.Configure already
|
// Names the is_deleted index explicitly. BaseEntityConfiguration.Configure already
|
||||||
// calls HasIndex(e => e.IsDeleted); this adds HasDatabaseName so EF always uses
|
// calls HasIndex(e => e.IsDeleted); this adds HasDatabaseName so EF always uses
|
||||||
// "IX_track_is_deleted" regardless of auto-naming conventions.
|
// "IX_track_is_deleted" regardless of auto-naming conventions.
|
||||||
|
|||||||
@@ -11,11 +11,13 @@ public class DeepDrftContext : DbContext
|
|||||||
}
|
}
|
||||||
|
|
||||||
public DbSet<TrackEntity> Tracks { get; set; }
|
public DbSet<TrackEntity> Tracks { get; set; }
|
||||||
|
public DbSet<ReleaseEntity> Releases { get; set; }
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
base.OnModelCreating(modelBuilder);
|
base.OnModelCreating(modelBuilder);
|
||||||
|
|
||||||
modelBuilder.ApplyConfiguration(new TrackConfiguration());
|
modelBuilder.ApplyConfiguration(new TrackConfiguration());
|
||||||
|
modelBuilder.ApplyConfiguration(new ReleaseConfiguration());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,12 +22,20 @@ public interface ITrackService
|
|||||||
Task<ResultContainer<List<TrackDto>>> GetAll();
|
Task<ResultContainer<List<TrackDto>>> GetAll();
|
||||||
Task<ResultContainer<PagedResult<TrackDto>>> GetPaged(int pageNumber, int pageSize, string? sortColumn, bool sortDescending, TrackFilter? filter = null, CancellationToken cancellationToken = default);
|
Task<ResultContainer<PagedResult<TrackDto>>> GetPaged(int pageNumber, int pageSize, string? sortColumn, bool sortDescending, TrackFilter? filter = null, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
/// <summary>Distinct non-null albums with track counts and a representative cover key, album-ascending.</summary>
|
/// <summary>All releases, title-ascending, each carrying its non-deleted track count.</summary>
|
||||||
Task<ResultContainer<List<AlbumSummaryDto>>> GetDistinctAlbums(CancellationToken cancellationToken = default);
|
Task<ResultContainer<List<ReleaseDto>>> GetReleases(CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
/// <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>
|
||||||
|
/// 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
|
||||||
|
/// resolution so a track lands on a shared release rather than duplicating release-cardinal data.
|
||||||
|
/// </summary>
|
||||||
|
Task<ResultContainer<ReleaseDto>> FindOrCreateRelease(
|
||||||
|
string title, string artist, ReleaseDto releaseData, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
Task<ResultContainer<TrackDto>> Create(TrackDto newTrack);
|
Task<ResultContainer<TrackDto>> Create(TrackDto newTrack);
|
||||||
Task<ResultContainer<TrackDto>> Update(TrackDto track);
|
Task<ResultContainer<TrackDto>> Update(TrackDto track);
|
||||||
Task<Result> Delete(long id);
|
Task<Result> Delete(long id);
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
// <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("20260611164537_NormalizeReleaseTrack")]
|
||||||
|
partial class NormalizeReleaseTrack
|
||||||
|
{
|
||||||
|
/// <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.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>("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<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("IsDeleted")
|
||||||
|
.HasDatabaseName("IX_release_is_deleted");
|
||||||
|
|
||||||
|
b.ToTable("release", (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<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.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("Tracks");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace DeepDrftData.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class NormalizeReleaseTrack : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
// 1. Create the release table.
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "release",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
id = table.Column<long>(type: "bigint", nullable: false)
|
||||||
|
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||||
|
title = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||||
|
artist = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||||
|
genre = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
|
||||||
|
release_date = table.Column<DateOnly>(type: "date", nullable: true),
|
||||||
|
image_path = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
|
||||||
|
release_type = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false, defaultValue: "Single"),
|
||||||
|
created_by_user_id = table.Column<long>(type: "bigint", nullable: true),
|
||||||
|
created_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||||
|
updated_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||||
|
is_deleted = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_release", x => x.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_release_is_deleted",
|
||||||
|
table: "release",
|
||||||
|
column: "is_deleted");
|
||||||
|
|
||||||
|
// 2. Add the nullable FK column to track. A fresh column (not a rename of
|
||||||
|
// created_by_user_id) so existing rows start with a null release until back-filled.
|
||||||
|
migrationBuilder.AddColumn<long>(
|
||||||
|
name: "release_id",
|
||||||
|
table: "track",
|
||||||
|
type: "bigint",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
// 3. Data migration — must run after the release table exists and release_id is added,
|
||||||
|
// and before the release-cardinal columns are dropped from track (the SELECT reads them).
|
||||||
|
// Create one release row per distinct (album, artist) from existing tracks, carrying
|
||||||
|
// the release-cardinal fields. Tracks with a null album remain release_id = null.
|
||||||
|
migrationBuilder.Sql(@"
|
||||||
|
INSERT INTO release (title, artist, genre, release_date, image_path, release_type,
|
||||||
|
created_by_user_id, created_at, updated_at, is_deleted)
|
||||||
|
SELECT DISTINCT ON (album, artist)
|
||||||
|
album, artist, genre, release_date, image_path, release_type,
|
||||||
|
created_by_user_id, NOW(), NOW(), false
|
||||||
|
FROM track
|
||||||
|
WHERE album IS NOT NULL
|
||||||
|
ORDER BY album, artist, id;
|
||||||
|
");
|
||||||
|
|
||||||
|
// Back-fill the FK: match each track to the release created from its (album, artist).
|
||||||
|
migrationBuilder.Sql(@"
|
||||||
|
UPDATE track
|
||||||
|
SET release_id = r.id
|
||||||
|
FROM release r
|
||||||
|
WHERE track.album = r.title
|
||||||
|
AND track.artist = r.artist;
|
||||||
|
");
|
||||||
|
|
||||||
|
// 4. Index + FK now that the column carries its back-filled values.
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_track_release_id",
|
||||||
|
table: "track",
|
||||||
|
column: "release_id");
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_track_release_release_id",
|
||||||
|
table: "track",
|
||||||
|
column: "release_id",
|
||||||
|
principalTable: "release",
|
||||||
|
principalColumn: "id",
|
||||||
|
onDelete: ReferentialAction.SetNull);
|
||||||
|
|
||||||
|
// 5. Drop the now-migrated release-cardinal columns from track.
|
||||||
|
migrationBuilder.DropColumn(name: "album", table: "track");
|
||||||
|
migrationBuilder.DropColumn(name: "artist", table: "track");
|
||||||
|
migrationBuilder.DropColumn(name: "genre", table: "track");
|
||||||
|
migrationBuilder.DropColumn(name: "image_path", table: "track");
|
||||||
|
migrationBuilder.DropColumn(name: "release_date", table: "track");
|
||||||
|
migrationBuilder.DropColumn(name: "release_type", table: "track");
|
||||||
|
migrationBuilder.DropColumn(name: "created_by_user_id", table: "track");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
// 1. Re-add the track release-cardinal columns. artist is non-nullable with a default so
|
||||||
|
// the add succeeds against existing rows before the back-fill repopulates it.
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "album",
|
||||||
|
table: "track",
|
||||||
|
type: "character varying(200)",
|
||||||
|
maxLength: 200,
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "artist",
|
||||||
|
table: "track",
|
||||||
|
type: "character varying(200)",
|
||||||
|
maxLength: 200,
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: "");
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "genre",
|
||||||
|
table: "track",
|
||||||
|
type: "character varying(100)",
|
||||||
|
maxLength: 100,
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "image_path",
|
||||||
|
table: "track",
|
||||||
|
type: "character varying(500)",
|
||||||
|
maxLength: 500,
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<DateOnly>(
|
||||||
|
name: "release_date",
|
||||||
|
table: "track",
|
||||||
|
type: "date",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "release_type",
|
||||||
|
table: "track",
|
||||||
|
type: "character varying(20)",
|
||||||
|
maxLength: 20,
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: "Single");
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<long>(
|
||||||
|
name: "created_by_user_id",
|
||||||
|
table: "track",
|
||||||
|
type: "bigint",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
// 2. Re-populate the track columns from the release join before the release table and FK go.
|
||||||
|
migrationBuilder.Sql(@"
|
||||||
|
UPDATE track
|
||||||
|
SET artist = r.artist,
|
||||||
|
album = r.title,
|
||||||
|
genre = r.genre,
|
||||||
|
release_date = r.release_date,
|
||||||
|
image_path = r.image_path,
|
||||||
|
release_type = r.release_type,
|
||||||
|
created_by_user_id = r.created_by_user_id
|
||||||
|
FROM release r
|
||||||
|
WHERE track.release_id = r.id;
|
||||||
|
");
|
||||||
|
|
||||||
|
// 3. Drop the FK, index, the release_id column, and the release table.
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_track_release_release_id",
|
||||||
|
table: "track");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_track_release_id",
|
||||||
|
table: "track");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "release_id",
|
||||||
|
table: "track");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "release");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+178
@@ -0,0 +1,178 @@
|
|||||||
|
// <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("20260611184732_AddReleaseUniqueTitleArtist")]
|
||||||
|
partial class AddReleaseUniqueTitleArtist
|
||||||
|
{
|
||||||
|
/// <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.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>("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<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("IsDeleted")
|
||||||
|
.HasDatabaseName("IX_release_is_deleted");
|
||||||
|
|
||||||
|
b.HasIndex("Title", "Artist")
|
||||||
|
.IsUnique()
|
||||||
|
.HasDatabaseName("IX_release_title_artist");
|
||||||
|
|
||||||
|
b.ToTable("release", (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<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.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("Tracks");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace DeepDrftData.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddReleaseUniqueTitleArtist : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_release_title_artist",
|
||||||
|
table: "release",
|
||||||
|
columns: new[] { "title", "artist" },
|
||||||
|
unique: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_release_title_artist",
|
||||||
|
table: "release");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,7 +22,7 @@ namespace DeepDrftData.Migrations
|
|||||||
|
|
||||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||||
|
|
||||||
modelBuilder.Entity("DeepDrftModels.Entities.TrackEntity", b =>
|
modelBuilder.Entity("DeepDrftModels.Entities.ReleaseEntity", b =>
|
||||||
{
|
{
|
||||||
b.Property<long>("Id")
|
b.Property<long>("Id")
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
@@ -31,11 +31,6 @@ namespace DeepDrftData.Migrations
|
|||||||
|
|
||||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||||
|
|
||||||
b.Property<string>("Album")
|
|
||||||
.HasMaxLength(200)
|
|
||||||
.HasColumnType("character varying(200)")
|
|
||||||
.HasColumnName("album");
|
|
||||||
|
|
||||||
b.Property<string>("Artist")
|
b.Property<string>("Artist")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(200)
|
.HasMaxLength(200)
|
||||||
@@ -50,12 +45,6 @@ namespace DeepDrftData.Migrations
|
|||||||
.HasColumnType("bigint")
|
.HasColumnType("bigint")
|
||||||
.HasColumnName("created_by_user_id");
|
.HasColumnName("created_by_user_id");
|
||||||
|
|
||||||
b.Property<string>("EntryKey")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(100)
|
|
||||||
.HasColumnType("character varying(100)")
|
|
||||||
.HasColumnName("entry_key");
|
|
||||||
|
|
||||||
b.Property<string>("Genre")
|
b.Property<string>("Genre")
|
||||||
.HasMaxLength(100)
|
.HasMaxLength(100)
|
||||||
.HasColumnType("character varying(100)")
|
.HasColumnType("character varying(100)")
|
||||||
@@ -72,11 +61,6 @@ namespace DeepDrftData.Migrations
|
|||||||
.HasDefaultValue(false)
|
.HasDefaultValue(false)
|
||||||
.HasColumnName("is_deleted");
|
.HasColumnName("is_deleted");
|
||||||
|
|
||||||
b.Property<string>("OriginalFileName")
|
|
||||||
.HasMaxLength(500)
|
|
||||||
.HasColumnType("character varying(500)")
|
|
||||||
.HasColumnName("original_file_name");
|
|
||||||
|
|
||||||
b.Property<DateOnly?>("ReleaseDate")
|
b.Property<DateOnly?>("ReleaseDate")
|
||||||
.HasColumnType("date")
|
.HasColumnType("date")
|
||||||
.HasColumnName("release_date");
|
.HasColumnName("release_date");
|
||||||
@@ -89,6 +73,62 @@ namespace DeepDrftData.Migrations
|
|||||||
.HasDefaultValue("Single")
|
.HasDefaultValue("Single")
|
||||||
.HasColumnName("release_type");
|
.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("IsDeleted")
|
||||||
|
.HasDatabaseName("IX_release_is_deleted");
|
||||||
|
|
||||||
|
b.HasIndex("Title", "Artist")
|
||||||
|
.IsUnique()
|
||||||
|
.HasDatabaseName("IX_release_title_artist");
|
||||||
|
|
||||||
|
b.ToTable("release", (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<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")
|
b.Property<string>("TrackName")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(200)
|
.HasMaxLength(200)
|
||||||
@@ -110,8 +150,25 @@ namespace DeepDrftData.Migrations
|
|||||||
b.HasIndex("IsDeleted")
|
b.HasIndex("IsDeleted")
|
||||||
.HasDatabaseName("IX_track_is_deleted");
|
.HasDatabaseName("IX_track_is_deleted");
|
||||||
|
|
||||||
|
b.HasIndex("ReleaseId");
|
||||||
|
|
||||||
b.ToTable("track", (string)null);
|
b.ToTable("track", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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("Tracks");
|
||||||
|
});
|
||||||
#pragma warning restore 612, 618
|
#pragma warning restore 612, 618
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,18 +11,36 @@ namespace DeepDrftData.Repositories;
|
|||||||
|
|
||||||
public class TrackRepository : Repository<DeepDrftContext, TrackEntity>
|
public class TrackRepository : Repository<DeepDrftContext, TrackEntity>
|
||||||
{
|
{
|
||||||
|
// The base Repository<> exposes Query (soft-delete-filtered IQueryable<TrackEntity>) but no
|
||||||
|
// DbContext accessor, and release-cardinal queries need a second DbSet. Keep our own reference
|
||||||
|
// to the injected context rather than reaching for a service locator — it is the same scoped
|
||||||
|
// instance the base holds, so reads/writes stay in one unit of work.
|
||||||
|
private readonly DeepDrftContext _context;
|
||||||
|
|
||||||
public TrackRepository(
|
public TrackRepository(
|
||||||
DeepDrftContext context,
|
DeepDrftContext context,
|
||||||
ILogger<Repository<DeepDrftContext, TrackEntity>> logger,
|
ILogger<Repository<DeepDrftContext, TrackEntity>> logger,
|
||||||
IDbExceptionClassifier? classifier = null)
|
IDbExceptionClassifier? classifier = null)
|
||||||
: base(context, logger, classifier: classifier)
|
: base(context, logger, classifier: classifier)
|
||||||
{
|
{
|
||||||
|
_context = context;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Override base GetByIdAsync to include the Release navigation. Without this, the base
|
||||||
|
// Query has no .Include, so Release is null on every entity (no lazy-loading proxies).
|
||||||
|
public override async Task<TrackEntity?> GetByIdAsync(long id)
|
||||||
|
=> await Query.Include(t => t.Release).FirstOrDefaultAsync(e => e.Id == id);
|
||||||
|
|
||||||
|
// Override base GetAllAsync for the same reason — include Release so callers (e.g.
|
||||||
|
// TrackManager.GetAll) receive fully-populated entities without a separate query.
|
||||||
|
public override async Task<IEnumerable<TrackEntity>> GetAllAsync()
|
||||||
|
=> await Query.Include(t => t.Release).ToListAsync();
|
||||||
|
|
||||||
// Lookup by vault entry key. The base Repository<> only exposes id-based queries, so this
|
// Lookup by vault entry key. The base Repository<> only exposes id-based queries, so this
|
||||||
// uses Query (soft-delete filtered) rather than the raw DbSet.
|
// uses Query (soft-delete filtered) rather than the raw DbSet. Includes Release so the
|
||||||
|
// converter can project the release-cardinal fields.
|
||||||
public async Task<TrackEntity?> GetByEntryKeyAsync(string entryKey)
|
public async Task<TrackEntity?> GetByEntryKeyAsync(string entryKey)
|
||||||
=> await Query.FirstOrDefaultAsync(t => t.EntryKey == entryKey);
|
=> await Query.Include(t => t.Release).FirstOrDefaultAsync(t => t.EntryKey == entryKey);
|
||||||
|
|
||||||
// Picks one track uniformly at random. Two round-trips (count, then a single offset row)
|
// Picks one track uniformly at random. Two round-trips (count, then a single offset row)
|
||||||
// rather than ORDER BY random() so the database never sorts the whole table — the catalogue
|
// rather than ORDER BY random() so the database never sorts the whole table — the catalogue
|
||||||
@@ -37,6 +55,7 @@ public class TrackRepository : Repository<DeepDrftContext, TrackEntity>
|
|||||||
|
|
||||||
var index = Random.Shared.Next(count);
|
var index = Random.Shared.Next(count);
|
||||||
return await Query
|
return await Query
|
||||||
|
.Include(t => t.Release)
|
||||||
.OrderBy(t => t.Id)
|
.OrderBy(t => t.Id)
|
||||||
.Skip(index)
|
.Skip(index)
|
||||||
.Take(1)
|
.Take(1)
|
||||||
@@ -53,27 +72,29 @@ public class TrackRepository : Repository<DeepDrftContext, TrackEntity>
|
|||||||
TrackFilter? filter,
|
TrackFilter? filter,
|
||||||
CancellationToken ct = default)
|
CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
IQueryable<TrackEntity> query = Query;
|
// Include Release so both the filter predicates and the converter can read release-cardinal
|
||||||
|
// fields through the navigation.
|
||||||
|
IQueryable<TrackEntity> query = Query.Include(t => t.Release);
|
||||||
|
|
||||||
if (filter is not null)
|
if (filter is not null)
|
||||||
{
|
{
|
||||||
if (!string.IsNullOrWhiteSpace(filter.SearchText))
|
if (!string.IsNullOrWhiteSpace(filter.SearchText))
|
||||||
{
|
{
|
||||||
// Postgres case-insensitive LIKE. The '%' wraps make it a contains-match; ILike is
|
// Postgres case-insensitive LIKE. The '%' wraps make it a contains-match; ILike is
|
||||||
// EF-translatable where ToLower().Contains() is not. Album is nullable — ILike on a
|
// EF-translatable where ToLower().Contains() is not. Artist/Title live on the joined
|
||||||
// null column yields false, which is the desired "no match" behaviour.
|
// Release, which is null for loose tracks — guard the navigation before ILike.
|
||||||
var pattern = $"%{filter.SearchText}%";
|
var pattern = $"%{filter.SearchText}%";
|
||||||
query = query.Where(t =>
|
query = query.Where(t =>
|
||||||
EF.Functions.ILike(t.TrackName, pattern)
|
EF.Functions.ILike(t.TrackName, pattern)
|
||||||
|| EF.Functions.ILike(t.Artist, pattern)
|
|| (t.Release != null && EF.Functions.ILike(t.Release.Artist, pattern))
|
||||||
|| (t.Album != null && EF.Functions.ILike(t.Album, pattern)));
|
|| (t.Release != null && EF.Functions.ILike(t.Release.Title, pattern)));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(filter.Album))
|
if (!string.IsNullOrWhiteSpace(filter.Album))
|
||||||
query = query.Where(t => t.Album == filter.Album);
|
query = query.Where(t => t.Release != null && t.Release.Title == filter.Album);
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(filter.Genre))
|
if (!string.IsNullOrWhiteSpace(filter.Genre))
|
||||||
query = query.Where(t => t.Genre == filter.Genre);
|
query = query.Where(t => t.Release != null && t.Release.Genre == filter.Genre);
|
||||||
}
|
}
|
||||||
|
|
||||||
var totalCount = await query.CountAsync(ct);
|
var totalCount = await query.CountAsync(ct);
|
||||||
@@ -99,30 +120,21 @@ public class TrackRepository : Repository<DeepDrftContext, TrackEntity>
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Distinct albums (non-null) with track counts and a representative cover key. The cover is the
|
// All non-deleted releases, title-ascending, each carrying its count of non-deleted tracks.
|
||||||
// first non-null ImagePath in the group; GroupBy + projection keeps it a single round-trip.
|
// The TrackCount subquery keeps this a single round-trip; the manager projects to ReleaseDto.
|
||||||
public async Task<List<AlbumSummaryDto>> GetDistinctAlbumsAsync(CancellationToken ct = default)
|
public async Task<List<ReleaseEntity>> GetReleasesAsync(CancellationToken ct = default)
|
||||||
=> await Query
|
=> await _context.Set<ReleaseEntity>()
|
||||||
.Where(t => t.Album != null)
|
.Where(r => !r.IsDeleted)
|
||||||
.GroupBy(t => t.Album!)
|
.OrderBy(r => r.Title)
|
||||||
.Select(g => new AlbumSummaryDto
|
|
||||||
{
|
|
||||||
Album = g.Key,
|
|
||||||
TrackCount = g.Count(),
|
|
||||||
CoverImageKey = g
|
|
||||||
.Where(t => t.ImagePath != null)
|
|
||||||
.OrderBy(t => t.Id)
|
|
||||||
.Select(t => t.ImagePath)
|
|
||||||
.FirstOrDefault(),
|
|
||||||
})
|
|
||||||
.OrderBy(a => a.Album)
|
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
|
|
||||||
// Distinct genres (non-null) with track counts.
|
// Distinct genres (non-null) with track counts, sourced from the release join. Counting tracks
|
||||||
|
// (not releases) keeps the browse counts consistent with the track-level catalogue. Loose tracks
|
||||||
|
// (no release) carry no genre and are excluded.
|
||||||
public async Task<List<GenreSummaryDto>> GetDistinctGenresAsync(CancellationToken ct = default)
|
public async Task<List<GenreSummaryDto>> GetDistinctGenresAsync(CancellationToken ct = default)
|
||||||
=> await Query
|
=> await Query
|
||||||
.Where(t => t.Genre != null)
|
.Where(t => t.Release != null && t.Release.Genre != null)
|
||||||
.GroupBy(t => t.Genre!)
|
.GroupBy(t => t.Release!.Genre!)
|
||||||
.Select(g => new GenreSummaryDto
|
.Select(g => new GenreSummaryDto
|
||||||
{
|
{
|
||||||
Genre = g.Key,
|
Genre = g.Key,
|
||||||
@@ -131,16 +143,52 @@ public class TrackRepository : Repository<DeepDrftContext, TrackEntity>
|
|||||||
.OrderBy(g => g.Genre)
|
.OrderBy(g => g.Genre)
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
// Count of non-deleted tracks per release, keyed by ReleaseId. The manager joins this against
|
||||||
|
// GetReleasesAsync to populate ReleaseDto.TrackCount without an N+1 fan-out.
|
||||||
|
public async Task<Dictionary<long, int>> GetTrackCountsByReleaseAsync(CancellationToken ct = default)
|
||||||
|
=> await Query
|
||||||
|
.Where(t => t.ReleaseId != null)
|
||||||
|
.GroupBy(t => t.ReleaseId!.Value)
|
||||||
|
.Select(g => new { ReleaseId = g.Key, Count = g.Count() })
|
||||||
|
.ToDictionaryAsync(x => x.ReleaseId, x => x.Count, ct);
|
||||||
|
|
||||||
|
// Resolve an existing release by its natural key (title + artist). Returns null when no match,
|
||||||
|
// signalling the manager to create one. Soft-deleted releases never match.
|
||||||
|
public async Task<ReleaseEntity?> GetReleaseByTitleAndArtistAsync(
|
||||||
|
string title, string artist, CancellationToken ct = default)
|
||||||
|
=> await _context.Set<ReleaseEntity>()
|
||||||
|
.FirstOrDefaultAsync(r => r.Title == title && r.Artist == artist && !r.IsDeleted, ct);
|
||||||
|
|
||||||
|
// Persist a new release row and return it with its assigned Id. Lives here (not the manager)
|
||||||
|
// because the repository owns the DbContext — the manager stays free of direct context access.
|
||||||
|
public async Task<ReleaseEntity> AddReleaseAsync(ReleaseEntity release, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
_context.Set<ReleaseEntity>().Add(release);
|
||||||
|
await _context.SaveChangesAsync(ct);
|
||||||
|
return release;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load a tracked release by id so the manager can edit its fields in place and save. Returns
|
||||||
|
// null when the id does not resolve (or the release is soft-deleted).
|
||||||
|
public async Task<ReleaseEntity?> GetReleaseByIdAsync(long id, CancellationToken ct = default)
|
||||||
|
=> await _context.Set<ReleaseEntity>()
|
||||||
|
.FirstOrDefaultAsync(r => r.Id == id && !r.IsDeleted, ct);
|
||||||
|
|
||||||
|
// Persist edits to a release. Update marks the whole entity modified, so it works whether the
|
||||||
|
// instance is the change-tracked one from GetReleaseByIdAsync or a detached graph.
|
||||||
|
public async Task UpdateReleaseAsync(ReleaseEntity release, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
_context.Set<ReleaseEntity>().Update(release);
|
||||||
|
await _context.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
protected override void UpdateEntity(TrackEntity target, TrackEntity source)
|
protected override void UpdateEntity(TrackEntity target, TrackEntity source)
|
||||||
{
|
{
|
||||||
base.UpdateEntity(target, source); // copies CreatedAt, UpdatedAt, IsDeleted
|
base.UpdateEntity(target, source); // copies CreatedAt, UpdatedAt, IsDeleted
|
||||||
target.EntryKey = source.EntryKey;
|
target.EntryKey = source.EntryKey;
|
||||||
target.TrackName = source.TrackName;
|
target.TrackName = source.TrackName;
|
||||||
target.Artist = source.Artist;
|
target.TrackNumber = source.TrackNumber;
|
||||||
target.Album = source.Album;
|
target.OriginalFileName = source.OriginalFileName;
|
||||||
target.Genre = source.Genre;
|
target.ReleaseId = source.ReleaseId;
|
||||||
target.ReleaseDate = source.ReleaseDate;
|
|
||||||
target.ImagePath = source.ImagePath;
|
|
||||||
target.CreatedByUserId = source.CreatedByUserId;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,9 +9,40 @@ namespace DeepDrftData;
|
|||||||
/// The DTO side mirrors the entity field-for-field; the audit columns
|
/// The DTO side mirrors the entity field-for-field; the audit columns
|
||||||
/// (CreatedAt, UpdatedAt) come from BaseEntity / BaseModel.
|
/// (CreatedAt, UpdatedAt) come from BaseEntity / BaseModel.
|
||||||
/// IsDeleted does not round-trip — soft-deleted rows are not exposed via the model.
|
/// IsDeleted does not round-trip — soft-deleted rows are not exposed via the model.
|
||||||
|
///
|
||||||
|
/// Post Phase 8 §8.0: TrackEntity carries only track-cardinal fields plus a nullable
|
||||||
|
/// ReleaseId/Release. The release-cardinal data converts through the Release maps below.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class TrackConverter : IEntityToModelConverter<TrackEntity, TrackDto>
|
public class TrackConverter : IEntityToModelConverter<TrackEntity, TrackDto>
|
||||||
{
|
{
|
||||||
|
public static ReleaseDto Convert(ReleaseEntity entity) => new()
|
||||||
|
{
|
||||||
|
Id = entity.Id,
|
||||||
|
CreatedAt = entity.CreatedAt,
|
||||||
|
UpdatedAt = entity.UpdatedAt,
|
||||||
|
Title = entity.Title,
|
||||||
|
Artist = entity.Artist,
|
||||||
|
Genre = entity.Genre,
|
||||||
|
ReleaseDate = entity.ReleaseDate,
|
||||||
|
ImagePath = entity.ImagePath,
|
||||||
|
ReleaseType = entity.ReleaseType,
|
||||||
|
CreatedByUserId = entity.CreatedByUserId
|
||||||
|
};
|
||||||
|
|
||||||
|
public static ReleaseEntity Convert(ReleaseDto dto) => new()
|
||||||
|
{
|
||||||
|
Id = dto.Id,
|
||||||
|
CreatedAt = dto.CreatedAt,
|
||||||
|
UpdatedAt = dto.UpdatedAt,
|
||||||
|
Title = dto.Title,
|
||||||
|
Artist = dto.Artist,
|
||||||
|
Genre = dto.Genre,
|
||||||
|
ReleaseDate = dto.ReleaseDate,
|
||||||
|
ImagePath = dto.ImagePath,
|
||||||
|
ReleaseType = dto.ReleaseType,
|
||||||
|
CreatedByUserId = dto.CreatedByUserId
|
||||||
|
};
|
||||||
|
|
||||||
public static TrackDto Convert(TrackEntity entity) => new()
|
public static TrackDto Convert(TrackEntity entity) => new()
|
||||||
{
|
{
|
||||||
Id = entity.Id,
|
Id = entity.Id,
|
||||||
@@ -19,17 +50,15 @@ public class TrackConverter : IEntityToModelConverter<TrackEntity, TrackDto>
|
|||||||
UpdatedAt = entity.UpdatedAt,
|
UpdatedAt = entity.UpdatedAt,
|
||||||
EntryKey = entity.EntryKey,
|
EntryKey = entity.EntryKey,
|
||||||
TrackName = entity.TrackName,
|
TrackName = entity.TrackName,
|
||||||
Artist = entity.Artist,
|
|
||||||
Album = entity.Album,
|
|
||||||
Genre = entity.Genre,
|
|
||||||
ReleaseDate = entity.ReleaseDate,
|
|
||||||
ImagePath = entity.ImagePath,
|
|
||||||
CreatedByUserId = entity.CreatedByUserId,
|
|
||||||
OriginalFileName = entity.OriginalFileName,
|
OriginalFileName = entity.OriginalFileName,
|
||||||
ReleaseType = entity.ReleaseType,
|
TrackNumber = entity.TrackNumber,
|
||||||
TrackNumber = entity.TrackNumber
|
ReleaseId = entity.ReleaseId,
|
||||||
|
Release = entity.Release is null ? null : Convert(entity.Release)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// DTO → entity maps track-cardinal fields + ReleaseId only. The Release navigation is left
|
||||||
|
// unset: the manager resolves/attaches the release row against the tracked context so a detached
|
||||||
|
// graph never overwrites a shared release record.
|
||||||
public static TrackEntity Convert(TrackDto model) => new()
|
public static TrackEntity Convert(TrackDto model) => new()
|
||||||
{
|
{
|
||||||
Id = model.Id,
|
Id = model.Id,
|
||||||
@@ -37,14 +66,8 @@ public class TrackConverter : IEntityToModelConverter<TrackEntity, TrackDto>
|
|||||||
UpdatedAt = model.UpdatedAt,
|
UpdatedAt = model.UpdatedAt,
|
||||||
EntryKey = model.EntryKey,
|
EntryKey = model.EntryKey,
|
||||||
TrackName = model.TrackName,
|
TrackName = model.TrackName,
|
||||||
Artist = model.Artist,
|
|
||||||
Album = model.Album,
|
|
||||||
Genre = model.Genre,
|
|
||||||
ReleaseDate = model.ReleaseDate,
|
|
||||||
ImagePath = model.ImagePath,
|
|
||||||
CreatedByUserId = model.CreatedByUserId,
|
|
||||||
OriginalFileName = model.OriginalFileName,
|
OriginalFileName = model.OriginalFileName,
|
||||||
ReleaseType = model.ReleaseType,
|
TrackNumber = model.TrackNumber,
|
||||||
TrackNumber = model.TrackNumber
|
ReleaseId = model.ReleaseId
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+100
-13
@@ -1,3 +1,4 @@
|
|||||||
|
using Data.Errors;
|
||||||
using Data.Managers;
|
using Data.Managers;
|
||||||
using DeepDrftData.Repositories;
|
using DeepDrftData.Repositories;
|
||||||
using DeepDrftModels.DTOs;
|
using DeepDrftModels.DTOs;
|
||||||
@@ -107,24 +108,26 @@ public class TrackManager
|
|||||||
Page = pageNumber,
|
Page = pageNumber,
|
||||||
PageSize = pageSize,
|
PageSize = pageSize,
|
||||||
IsDescending = sortDescending,
|
IsDescending = sortDescending,
|
||||||
|
// Sorts navigate through the nullable Release relation; the null-coalescing
|
||||||
|
// sentinels push loose tracks (no release) to the end, matching the prior
|
||||||
|
// nulls-last behaviour on the flat columns.
|
||||||
OrderBy = sortColumn switch
|
OrderBy = sortColumn switch
|
||||||
{
|
{
|
||||||
"TrackName" => e => e.TrackName,
|
"TrackName" => e => e.TrackName,
|
||||||
"Artist" => e => e.Artist,
|
"Artist" => e => (object)(e.Release == null ? string.Empty : e.Release.Artist),
|
||||||
"Album" => e => (object)(e.Album ?? string.Empty),
|
"Album" => e => (object)(e.Release == null ? string.Empty : e.Release.Title),
|
||||||
"Genre" => e => (object)(e.Genre ?? string.Empty),
|
"Genre" => e => (object)(e.Release == null ? string.Empty : (e.Release.Genre ?? string.Empty)),
|
||||||
"ReleaseDate" => e => (object)(e.ReleaseDate ?? DateOnly.MaxValue),
|
"ReleaseDate" => e => (object)(e.Release == null ? DateOnly.MaxValue : (e.Release.ReleaseDate ?? DateOnly.MaxValue)),
|
||||||
_ => e => e.Id
|
_ => e => e.Id
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// An all-null filter must produce identical results to no filter, so collapse it to
|
// Always route through GetPagedFilteredAsync — it handles a null filter by skipping
|
||||||
// null and take the unfiltered base path (preserves backward compatibility).
|
// all Where predicates, and it always includes Release. This removes the base-class
|
||||||
|
// GetPagedAsync path, which has no .Include and would return entities with null Release.
|
||||||
var effectiveFilter = filter is null || filter.IsEmpty ? null : filter;
|
var effectiveFilter = filter is null || filter.IsEmpty ? null : filter;
|
||||||
|
|
||||||
var page = effectiveFilter is null
|
var page = await Repository.GetPagedFilteredAsync(parameters, effectiveFilter, cancellationToken);
|
||||||
? await Repository.GetPagedAsync(parameters)
|
|
||||||
: await Repository.GetPagedFilteredAsync(parameters, effectiveFilter, cancellationToken);
|
|
||||||
|
|
||||||
var dtoPage = PagedResult<TrackDto>.From(page, page.Items.Select(TrackConverter.Convert));
|
var dtoPage = PagedResult<TrackDto>.From(page, page.Items.Select(TrackConverter.Convert));
|
||||||
return ResultContainer<PagedResult<TrackDto>>.CreatePassResult(dtoPage);
|
return ResultContainer<PagedResult<TrackDto>>.CreatePassResult(dtoPage);
|
||||||
@@ -135,16 +138,64 @@ public class TrackManager
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<ResultContainer<List<AlbumSummaryDto>>> GetDistinctAlbums(CancellationToken cancellationToken = default)
|
public async Task<ResultContainer<List<ReleaseDto>>> GetReleases(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var albums = await Repository.GetDistinctAlbumsAsync(cancellationToken);
|
var releases = await Repository.GetReleasesAsync(cancellationToken);
|
||||||
return ResultContainer<List<AlbumSummaryDto>>.CreatePassResult(albums);
|
var counts = await Repository.GetTrackCountsByReleaseAsync(cancellationToken);
|
||||||
|
|
||||||
|
var dtos = releases
|
||||||
|
.Select(r =>
|
||||||
|
{
|
||||||
|
var dto = TrackConverter.Convert(r);
|
||||||
|
dto.TrackCount = counts.GetValueOrDefault(r.Id);
|
||||||
|
return dto;
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return ResultContainer<List<ReleaseDto>>.CreatePassResult(dtos);
|
||||||
}
|
}
|
||||||
catch (Exception e)
|
catch (Exception e)
|
||||||
{
|
{
|
||||||
return ResultContainer<List<AlbumSummaryDto>>.CreateFailResult(e.Message);
|
return ResultContainer<List<ReleaseDto>>.CreateFailResult(e.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ResultContainer<ReleaseDto>> FindOrCreateRelease(
|
||||||
|
string title, string artist, ReleaseDto releaseData, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var existing = await Repository.GetReleaseByTitleAndArtistAsync(title, artist, cancellationToken);
|
||||||
|
if (existing is not null)
|
||||||
|
return ResultContainer<ReleaseDto>.CreatePassResult(TrackConverter.Convert(existing));
|
||||||
|
|
||||||
|
// The natural key (title + artist) is authoritative — override whatever the caller put
|
||||||
|
// in releaseData so a typo upstream cannot create a release that won't be found again.
|
||||||
|
var entity = TrackConverter.Convert(releaseData);
|
||||||
|
entity.Id = 0;
|
||||||
|
entity.Title = title;
|
||||||
|
entity.Artist = artist;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var added = await Repository.AddReleaseAsync(entity, cancellationToken);
|
||||||
|
return ResultContainer<ReleaseDto>.CreatePassResult(TrackConverter.Convert(added));
|
||||||
|
}
|
||||||
|
catch (ClassifiedDbException ex) when (ex.Error.Category == DbErrorCategory.UniqueViolation)
|
||||||
|
{
|
||||||
|
// Concurrent upload inserted the same (title, artist) between our read and write.
|
||||||
|
// Re-query and return the winning row. Should not return null here since the
|
||||||
|
// constraint just fired, but re-throw if it does so the caller sees an error.
|
||||||
|
var race = await Repository.GetReleaseByTitleAndArtistAsync(title, artist, cancellationToken);
|
||||||
|
if (race is null) throw;
|
||||||
|
return ResultContainer<ReleaseDto>.CreatePassResult(TrackConverter.Convert(race));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
return ResultContainer<ReleaseDto>.CreateFailResult(e.Message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,6 +216,22 @@ public class TrackManager
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
// A track with release context resolves (or creates) the shared release first so the FK
|
||||||
|
// is set before insert. A standalone track (Release null) stays a loose track, ReleaseId
|
||||||
|
// null. Callers that already resolved the FK (UnifiedTrackService) pass Release null and
|
||||||
|
// a populated ReleaseId, which falls straight through.
|
||||||
|
if (newTrack.Release is { } release && !string.IsNullOrWhiteSpace(release.Title))
|
||||||
|
{
|
||||||
|
var resolved = await FindOrCreateRelease(release.Title, release.Artist, release);
|
||||||
|
if (!resolved.Success || resolved.Value is null)
|
||||||
|
{
|
||||||
|
var error = resolved.Messages.FirstOrDefault()?.Message ?? "Failed to resolve release.";
|
||||||
|
return ResultContainer<TrackDto>.CreateFailResult(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
newTrack.ReleaseId = resolved.Value.Id;
|
||||||
|
}
|
||||||
|
|
||||||
var added = await Repository.AddAsync(TrackConverter.Convert(newTrack));
|
var added = await Repository.AddAsync(TrackConverter.Convert(newTrack));
|
||||||
return ResultContainer<TrackDto>.CreatePassResult(TrackConverter.Convert(added));
|
return ResultContainer<TrackDto>.CreatePassResult(TrackConverter.Convert(added));
|
||||||
}
|
}
|
||||||
@@ -181,6 +248,26 @@ public class TrackManager
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
await Repository.UpdateAsync(TrackConverter.Convert(track));
|
await Repository.UpdateAsync(TrackConverter.Convert(track));
|
||||||
|
|
||||||
|
// Release-cardinal edits flow through the linked release row, not the track. When the
|
||||||
|
// track carries a Release payload and a resolved FK, load the tracked release, apply the
|
||||||
|
// edited fields, and save. EntryKey/track fields are already persisted above.
|
||||||
|
if (track.Release is { } release && track.ReleaseId is { } releaseId)
|
||||||
|
{
|
||||||
|
var releaseEntity = await Repository.GetReleaseByIdAsync(releaseId);
|
||||||
|
if (releaseEntity is not null)
|
||||||
|
{
|
||||||
|
releaseEntity.Title = release.Title;
|
||||||
|
releaseEntity.Artist = release.Artist;
|
||||||
|
releaseEntity.Genre = release.Genre;
|
||||||
|
releaseEntity.ReleaseDate = release.ReleaseDate;
|
||||||
|
releaseEntity.ImagePath = release.ImagePath;
|
||||||
|
releaseEntity.ReleaseType = release.ReleaseType;
|
||||||
|
releaseEntity.CreatedByUserId = release.CreatedByUserId;
|
||||||
|
await Repository.UpdateReleaseAsync(releaseEntity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var updated = await Repository.GetByIdAsync(track.Id);
|
var updated = await Repository.GetByIdAsync(track.Id);
|
||||||
return updated is not null
|
return updated is not null
|
||||||
? ResultContainer<TrackDto>.CreatePassResult(TrackConverter.Convert(updated))
|
? ResultContainer<TrackDto>.CreatePassResult(TrackConverter.Convert(updated))
|
||||||
|
|||||||
@@ -63,7 +63,7 @@
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var result = await CmsTrackService.GetAlbumSummariesAsync();
|
var result = await CmsTrackService.GetReleasesAsync();
|
||||||
_albumCount = result.Success && result.Value is not null ? result.Value.Count : null;
|
_albumCount = result.Success && result.Value is not null ? result.Value.Count : null;
|
||||||
if (!result.Success)
|
if (!result.Success)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -264,7 +264,7 @@
|
|||||||
|
|
||||||
var confirmed = await DialogService.ShowMessageBox(
|
var confirmed = await DialogService.ShowMessageBox(
|
||||||
"Delete track",
|
"Delete track",
|
||||||
$"Permanently delete \"{_track.TrackName}\" by {_track.Artist}? This cannot be undone.",
|
$"Permanently delete \"{_track.TrackName}\" by {_track.Release?.Artist ?? "Unknown"}? This cannot be undone.",
|
||||||
yesText: "Delete",
|
yesText: "Delete",
|
||||||
cancelText: "Cancel");
|
cancelText: "Cancel");
|
||||||
|
|
||||||
@@ -310,14 +310,14 @@
|
|||||||
public static TrackEditForm From(TrackDto track) => new()
|
public static TrackEditForm From(TrackDto track) => new()
|
||||||
{
|
{
|
||||||
TrackName = track.TrackName,
|
TrackName = track.TrackName,
|
||||||
Artist = track.Artist,
|
Artist = track.Release?.Artist ?? string.Empty,
|
||||||
Album = track.Album,
|
Album = track.Release?.Title,
|
||||||
Genre = track.Genre,
|
Genre = track.Release?.Genre,
|
||||||
ImagePath = track.ImagePath,
|
ImagePath = track.Release?.ImagePath,
|
||||||
ReleaseDate = track.ReleaseDate is { } d
|
ReleaseDate = track.Release?.ReleaseDate is { } d
|
||||||
? d.ToDateTime(TimeOnly.MinValue)
|
? d.ToDateTime(TimeOnly.MinValue)
|
||||||
: null,
|
: null,
|
||||||
ReleaseType = track.ReleaseType,
|
ReleaseType = track.Release?.ReleaseType ?? ReleaseType.Single,
|
||||||
TrackNumber = track.TrackNumber
|
TrackNumber = track.TrackNumber
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,10 +52,10 @@
|
|||||||
</HeaderContent>
|
</HeaderContent>
|
||||||
<RowTemplate>
|
<RowTemplate>
|
||||||
<MudTd DataLabel="Track Name">@context.TrackName</MudTd>
|
<MudTd DataLabel="Track Name">@context.TrackName</MudTd>
|
||||||
<MudTd DataLabel="Artist">@context.Artist</MudTd>
|
<MudTd DataLabel="Artist">@(context.Release?.Artist ?? "—")</MudTd>
|
||||||
<MudTd DataLabel="Album">@(context.Album ?? "—")</MudTd>
|
<MudTd DataLabel="Album">@(context.Release?.Title ?? "—")</MudTd>
|
||||||
<MudTd DataLabel="Genre">@(context.Genre ?? "—")</MudTd>
|
<MudTd DataLabel="Genre">@(context.Release?.Genre ?? "—")</MudTd>
|
||||||
<MudTd DataLabel="Release Date">@(context.ReleaseDate?.ToString("yyyy-MM-dd") ?? "—")</MudTd>
|
<MudTd DataLabel="Release Date">@(context.Release?.ReleaseDate?.ToString("yyyy-MM-dd") ?? "—")</MudTd>
|
||||||
<MudTd DataLabel="Entry Key"><MudText Typo="Typo.caption" Style="font-family: monospace;">@context.EntryKey</MudText></MudTd>
|
<MudTd DataLabel="Entry Key"><MudText Typo="Typo.caption" Style="font-family: monospace;">@context.EntryKey</MudText></MudTd>
|
||||||
<MudTd DataLabel="File Name"><MudText Typo="Typo.caption" Style="font-family: monospace;">@(context.OriginalFileName ?? "—")</MudText></MudTd>
|
<MudTd DataLabel="File Name"><MudText Typo="Typo.caption" Style="font-family: monospace;">@(context.OriginalFileName ?? "—")</MudText></MudTd>
|
||||||
<MudTd DataLabel="Actions">
|
<MudTd DataLabel="Actions">
|
||||||
@@ -216,7 +216,7 @@
|
|||||||
{
|
{
|
||||||
var confirmed = await DialogService.ShowMessageBox(
|
var confirmed = await DialogService.ShowMessageBox(
|
||||||
title: "Delete track",
|
title: "Delete track",
|
||||||
markupMessage: new MarkupString($"Delete <strong>{WebUtility.HtmlEncode(track.TrackName)}</strong> by {WebUtility.HtmlEncode(track.Artist)}? This removes both the metadata row and the underlying audio entry."),
|
markupMessage: new MarkupString($"Delete <strong>{WebUtility.HtmlEncode(track.TrackName)}</strong> by {WebUtility.HtmlEncode(track.Release?.Artist ?? "Unknown")}? This removes both the metadata row and the underlying audio entry."),
|
||||||
yesText: "Delete",
|
yesText: "Delete",
|
||||||
cancelText: "Cancel");
|
cancelText: "Cancel");
|
||||||
|
|
||||||
|
|||||||
@@ -450,7 +450,7 @@ public class CmsTrackService : ICmsTrackService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<ResultContainer<List<AlbumSummaryDto>>> GetAlbumSummariesAsync(CancellationToken ct = default)
|
public async Task<ResultContainer<List<ReleaseDto>>> GetReleasesAsync(CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var client = _httpClientFactory.CreateClient(ContentCmsClientName);
|
var client = _httpClientFactory.CreateClient(ContentCmsClientName);
|
||||||
|
|
||||||
@@ -461,36 +461,36 @@ public class CmsTrackService : ICmsTrackService
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "Content API call failed for album summaries");
|
_logger.LogError(ex, "Content API call failed for releases");
|
||||||
return ResultContainer<List<AlbumSummaryDto>>.CreateFailResult("Content API is unreachable.");
|
return ResultContainer<List<ReleaseDto>>.CreateFailResult("Content API is unreachable.");
|
||||||
}
|
}
|
||||||
|
|
||||||
using (response)
|
using (response)
|
||||||
{
|
{
|
||||||
if (!response.IsSuccessStatusCode)
|
if (!response.IsSuccessStatusCode)
|
||||||
{
|
{
|
||||||
_logger.LogError("Content API album summaries failed: {Status}", (int)response.StatusCode);
|
_logger.LogError("Content API releases failed: {Status}", (int)response.StatusCode);
|
||||||
return ResultContainer<List<AlbumSummaryDto>>.CreateFailResult("Failed to load albums.");
|
return ResultContainer<List<ReleaseDto>>.CreateFailResult("Failed to load albums.");
|
||||||
}
|
}
|
||||||
|
|
||||||
List<AlbumSummaryDto>? albums;
|
List<ReleaseDto>? releases;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
albums = await response.Content.ReadFromJsonAsync<List<AlbumSummaryDto>>(ct);
|
releases = await response.Content.ReadFromJsonAsync<List<ReleaseDto>>(ct);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "Failed to deserialize album summaries from Content API response");
|
_logger.LogError(ex, "Failed to deserialize releases from Content API response");
|
||||||
return ResultContainer<List<AlbumSummaryDto>>.CreateFailResult("Content API returned an unexpected response.");
|
return ResultContainer<List<ReleaseDto>>.CreateFailResult("Content API returned an unexpected response.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (albums is null)
|
if (releases is null)
|
||||||
{
|
{
|
||||||
_logger.LogError("Content API returned a null album summaries list");
|
_logger.LogError("Content API returned a null releases list");
|
||||||
return ResultContainer<List<AlbumSummaryDto>>.CreateFailResult("Content API returned an empty response.");
|
return ResultContainer<List<ReleaseDto>>.CreateFailResult("Content API returned an empty response.");
|
||||||
}
|
}
|
||||||
|
|
||||||
return ResultContainer<List<AlbumSummaryDto>>.CreatePassResult(albums);
|
return ResultContainer<List<ReleaseDto>>.CreatePassResult(releases);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -88,8 +88,8 @@ public interface ICmsTrackService
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
Task<Result> GenerateWaveformProfileAsync(string entryKey, CancellationToken ct = default);
|
Task<Result> GenerateWaveformProfileAsync(string entryKey, CancellationToken ct = default);
|
||||||
|
|
||||||
/// <summary>Returns all distinct albums with track counts from GET api/track/albums.</summary>
|
/// <summary>Returns all releases with track counts from GET api/track/albums.</summary>
|
||||||
Task<ResultContainer<List<AlbumSummaryDto>>> GetAlbumSummariesAsync(CancellationToken ct = default);
|
Task<ResultContainer<List<ReleaseDto>>> GetReleasesAsync(CancellationToken ct = default);
|
||||||
|
|
||||||
/// <summary>Returns all distinct genres with track counts from GET api/track/genres.</summary>
|
/// <summary>Returns all distinct genres with track counts from GET api/track/genres.</summary>
|
||||||
Task<ResultContainer<List<GenreSummaryDto>>> GetGenreSummariesAsync(CancellationToken ct = default);
|
Task<ResultContainer<List<GenreSummaryDto>>> GetGenreSummariesAsync(CancellationToken ct = default);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ namespace DeepDrftModels.DTOs;
|
|||||||
/// One distinct album with its track count and a representative cover image key. Backs the
|
/// One distinct album with its track count and a representative cover image key. Backs the
|
||||||
/// /albums browse grid.
|
/// /albums browse grid.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
[Obsolete("Replaced by ReleaseDto. Use ITrackService.GetReleases().")]
|
||||||
public class AlbumSummaryDto
|
public class AlbumSummaryDto
|
||||||
{
|
{
|
||||||
public required string Album { get; set; }
|
public required string Album { get; set; }
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
using DeepDrftModels.Enums;
|
||||||
|
using Models.Models;
|
||||||
|
|
||||||
|
namespace DeepDrftModels.DTOs;
|
||||||
|
|
||||||
|
// Mirror of ReleaseEntity (Phase 8 §8.0). Inherits Id, CreatedAt, UpdatedAt from BaseModel
|
||||||
|
// (Cerebellum.BlazorBlocks.Models). No `required` members — BlazorBlocks's Manager<> generic
|
||||||
|
// constraint requires `new()`, which does not compose with required members (see TrackDto header).
|
||||||
|
// TrackConverter assigns every field on the round-trip, so an empty default is never observable.
|
||||||
|
public class ReleaseDto : BaseModel
|
||||||
|
{
|
||||||
|
public string Title { get; set; } = string.Empty;
|
||||||
|
public string Artist { get; set; } = string.Empty;
|
||||||
|
public string? Genre { get; set; }
|
||||||
|
public DateOnly? ReleaseDate { get; set; }
|
||||||
|
public string? ImagePath { get; set; }
|
||||||
|
public ReleaseType ReleaseType { get; set; } = ReleaseType.Single;
|
||||||
|
public long? CreatedByUserId { get; set; }
|
||||||
|
|
||||||
|
// Read-model field: count of non-deleted tracks in this release. Not on ReleaseEntity — the
|
||||||
|
// service projects it from the joined Tracks collection so the /albums browse grid and the CMS
|
||||||
|
// dashboard can show a per-album track count. Defaults to 0 when not populated.
|
||||||
|
public int TrackCount { get; set; }
|
||||||
|
}
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
using DeepDrftModels.Enums;
|
|
||||||
using Models.Models;
|
using Models.Models;
|
||||||
|
|
||||||
namespace DeepDrftModels.DTOs;
|
namespace DeepDrftModels.DTOs;
|
||||||
@@ -6,20 +5,17 @@ namespace DeepDrftModels.DTOs;
|
|||||||
// Inherits Id, CreatedAt, UpdatedAt from BaseModel (Cerebellum.BlazorBlocks.Models).
|
// Inherits Id, CreatedAt, UpdatedAt from BaseModel (Cerebellum.BlazorBlocks.Models).
|
||||||
// BlazorBlocks's Manager<> generic constraint requires `new()` on the model type, which
|
// BlazorBlocks's Manager<> generic constraint requires `new()` on the model type, which
|
||||||
// disqualifies `required` properties (the `new()` constraint and required members do not
|
// disqualifies `required` properties (the `new()` constraint and required members do not
|
||||||
// compose). EntryKey/TrackName/Artist therefore drop `required` here — the TrackEntity
|
// compose). EntryKey/TrackName therefore drop `required` here — the TrackEntity side remains
|
||||||
// side remains required, and TrackConverter assigns every field on the round-trip so an
|
// required, and TrackConverter assigns every field on the round-trip so an empty default is
|
||||||
// empty default is never observable in production code paths.
|
// never observable in production code paths.
|
||||||
|
//
|
||||||
|
// Track-cardinal data only (Phase 8 §8.0). Release-cardinal fields are read via Release?.X.
|
||||||
public class TrackDto : BaseModel
|
public class TrackDto : BaseModel
|
||||||
{
|
{
|
||||||
public string EntryKey { get; set; } = string.Empty;
|
public string EntryKey { get; set; } = string.Empty;
|
||||||
public string TrackName { get; set; } = string.Empty;
|
public string TrackName { get; set; } = string.Empty;
|
||||||
public string Artist { get; set; } = string.Empty;
|
|
||||||
public string? Album { get; set; }
|
|
||||||
public string? Genre { get; set; }
|
|
||||||
public DateOnly? ReleaseDate { get; set; }
|
|
||||||
public string? ImagePath { get; set; }
|
|
||||||
public long? CreatedByUserId { get; set; }
|
|
||||||
public string? OriginalFileName { get; set; }
|
public string? OriginalFileName { get; set; }
|
||||||
public ReleaseType ReleaseType { get; set; } = ReleaseType.Single;
|
|
||||||
public int TrackNumber { get; set; } = 1;
|
public int TrackNumber { get; set; } = 1;
|
||||||
|
public long? ReleaseId { get; set; }
|
||||||
|
public ReleaseDto? Release { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
using DeepDrftModels.Enums;
|
||||||
|
using Models.Entities;
|
||||||
|
|
||||||
|
namespace DeepDrftModels.Entities;
|
||||||
|
|
||||||
|
// The release-cardinal half of the normalized track schema (Phase 8 §8.0). One ReleaseEntity is
|
||||||
|
// shared by every track on the same album; track-cardinal data stays on TrackEntity, which points
|
||||||
|
// back here via a nullable ReleaseId (singles and loose tracks have no release context).
|
||||||
|
//
|
||||||
|
// Inherits Id, CreatedAt, UpdatedAt, IsDeleted from BaseEntity (Cerebellum.BlazorBlocks.Models).
|
||||||
|
// BaseEntity ships the audit columns but does not declare IEntity itself, so subclasses declare it
|
||||||
|
// explicitly to satisfy the generic constraints on Repository<>/Manager<>/etc.
|
||||||
|
public class ReleaseEntity : BaseEntity, IEntity
|
||||||
|
{
|
||||||
|
public required string Title { get; set; }
|
||||||
|
public required string Artist { get; set; }
|
||||||
|
public string? Genre { get; set; }
|
||||||
|
public DateOnly? ReleaseDate { get; set; }
|
||||||
|
public string? ImagePath { get; set; }
|
||||||
|
public ReleaseType ReleaseType { get; set; } = ReleaseType.Single;
|
||||||
|
public long? CreatedByUserId { get; set; }
|
||||||
|
public ICollection<TrackEntity> Tracks { get; set; } = new List<TrackEntity>();
|
||||||
|
}
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
using DeepDrftModels.Enums;
|
|
||||||
using Models.Entities;
|
using Models.Entities;
|
||||||
|
|
||||||
namespace DeepDrftModels.Entities;
|
namespace DeepDrftModels.Entities;
|
||||||
@@ -6,17 +5,16 @@ namespace DeepDrftModels.Entities;
|
|||||||
// Inherits Id, CreatedAt, UpdatedAt, IsDeleted from BaseEntity (Cerebellum.BlazorBlocks.Models).
|
// Inherits Id, CreatedAt, UpdatedAt, IsDeleted from BaseEntity (Cerebellum.BlazorBlocks.Models).
|
||||||
// BaseEntity ships the audit columns but does not declare IEntity itself, so subclasses
|
// BaseEntity ships the audit columns but does not declare IEntity itself, so subclasses
|
||||||
// declare it explicitly to satisfy the generic constraints on Repository<>/Manager<>/etc.
|
// declare it explicitly to satisfy the generic constraints on Repository<>/Manager<>/etc.
|
||||||
|
//
|
||||||
|
// Track-cardinal data only (Phase 8 §8.0). Release-cardinal fields (Artist, Album→Title, Genre,
|
||||||
|
// ReleaseDate, ImagePath, ReleaseType, CreatedByUserId) live on ReleaseEntity, reached via the
|
||||||
|
// nullable Release navigation; ReleaseId is null for singles and loose tracks.
|
||||||
public class TrackEntity : BaseEntity, IEntity
|
public class TrackEntity : BaseEntity, IEntity
|
||||||
{
|
{
|
||||||
public required string EntryKey { get; set; }
|
public required string EntryKey { get; set; }
|
||||||
public required string TrackName { get; set; }
|
public required string TrackName { get; set; }
|
||||||
public required string Artist { get; set; }
|
|
||||||
public string? Album { get; set; }
|
|
||||||
public string? Genre { get; set; }
|
|
||||||
public DateOnly? ReleaseDate { get; set; }
|
|
||||||
public string? ImagePath { get; set; }
|
|
||||||
public long? CreatedByUserId { get; set; }
|
|
||||||
public string? OriginalFileName { get; set; }
|
public string? OriginalFileName { get; set; }
|
||||||
public ReleaseType ReleaseType { get; set; } = ReleaseType.Single;
|
|
||||||
public int TrackNumber { get; set; } = 1;
|
public int TrackNumber { get; set; } = 1;
|
||||||
|
public long? ReleaseId { get; set; }
|
||||||
|
public ReleaseEntity? Release { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,22 +89,22 @@ public class TrackClient
|
|||||||
: ApiResult<TrackDto?>.CreateFailResult("Failed to deserialize response");
|
: ApiResult<TrackDto?>.CreateFailResult("Failed to deserialize response");
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<ApiResult<List<AlbumSummaryDto>>> GetAlbums()
|
public async Task<ApiResult<List<ReleaseDto>>> GetAlbums()
|
||||||
{
|
{
|
||||||
var response = await _http.GetAsync("api/track/albums");
|
var response = await _http.GetAsync("api/track/albums");
|
||||||
|
|
||||||
if (!response.IsSuccessStatusCode)
|
if (!response.IsSuccessStatusCode)
|
||||||
return ApiResult<List<AlbumSummaryDto>>.CreateFailResult($"HTTP {(int)response.StatusCode}");
|
return ApiResult<List<ReleaseDto>>.CreateFailResult($"HTTP {(int)response.StatusCode}");
|
||||||
|
|
||||||
var json = await response.Content.ReadAsStringAsync();
|
var json = await response.Content.ReadAsStringAsync();
|
||||||
var albums = JsonSerializer.Deserialize<List<AlbumSummaryDto>>(json, new JsonSerializerOptions
|
var releases = JsonSerializer.Deserialize<List<ReleaseDto>>(json, new JsonSerializerOptions
|
||||||
{
|
{
|
||||||
PropertyNameCaseInsensitive = true
|
PropertyNameCaseInsensitive = true
|
||||||
});
|
});
|
||||||
|
|
||||||
return albums is not null
|
return releases is not null
|
||||||
? ApiResult<List<AlbumSummaryDto>>.CreatePassResult(albums)
|
? ApiResult<List<ReleaseDto>>.CreatePassResult(releases)
|
||||||
: ApiResult<List<AlbumSummaryDto>>.CreateFailResult("Failed to deserialize response");
|
: ApiResult<List<ReleaseDto>>.CreateFailResult("Failed to deserialize response");
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<ApiResult<List<GenreSummaryDto>>> GetGenres()
|
public async Task<ApiResult<List<GenreSummaryDto>>> GetGenres()
|
||||||
|
|||||||
@@ -13,26 +13,26 @@
|
|||||||
</a>
|
</a>
|
||||||
<MudText Typo="Typo.subtitle2" Class="track-meta-sep"> - </MudText>
|
<MudText Typo="Typo.subtitle2" Class="track-meta-sep"> - </MudText>
|
||||||
<MudText Typo="Typo.caption" Class="track-meta-artist text-truncate">
|
<MudText Typo="Typo.caption" Class="track-meta-artist text-truncate">
|
||||||
@Track.Artist
|
@Track.Release?.Artist
|
||||||
</MudText>
|
</MudText>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="track-meta-accents">
|
<div class="track-meta-accents">
|
||||||
@if (!string.IsNullOrEmpty(Track.Genre))
|
@if (!string.IsNullOrEmpty(Track.Release?.Genre))
|
||||||
{
|
{
|
||||||
<MudChip T="string"
|
<MudChip T="string"
|
||||||
Size="Size.Small"
|
Size="Size.Small"
|
||||||
Variant="Variant.Outlined"
|
Variant="Variant.Outlined"
|
||||||
Color="Color.Tertiary"
|
Color="Color.Tertiary"
|
||||||
Class="deepdrft-genre-chip">
|
Class="deepdrft-genre-chip">
|
||||||
@Track.Genre
|
@Track.Release.Genre
|
||||||
</MudChip>
|
</MudChip>
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (Track.ReleaseDate.HasValue)
|
@if (Track.Release?.ReleaseDate.HasValue == true)
|
||||||
{
|
{
|
||||||
<MudText Typo="Typo.caption" Class="track-meta-year">
|
<MudText Typo="Typo.caption" Class="track-meta-year">
|
||||||
@Track.ReleaseDate.Value.Year
|
@Track.Release.ReleaseDate.Value.Year
|
||||||
</MudText>
|
</MudText>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<div class="np-title">@(Player?.CurrentTrack?.TrackName ?? "Nothing playing")</div>
|
<div class="np-title">@(Player?.CurrentTrack?.TrackName ?? "Nothing playing")</div>
|
||||||
<div class="np-sub">
|
<div class="np-sub">
|
||||||
@(Player?.CurrentTrack != null
|
@(Player?.CurrentTrack != null
|
||||||
? $"{Player.CurrentTrack.Artist} · {Player.CurrentTrack.Album ?? "Single"}"
|
? $"{Player.CurrentTrack.Release?.Artist} · {Player.CurrentTrack.Release?.Title ?? "Single"}"
|
||||||
: "Select a track to begin")
|
: "Select a track to begin")
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
@{
|
@{
|
||||||
var hasLink = !string.IsNullOrEmpty(TrackModel?.EntryKey);
|
var hasLink = !string.IsNullOrEmpty(TrackModel?.EntryKey);
|
||||||
var trackHref = hasLink ? $"/track/{TrackModel!.EntryKey}" : null;
|
var trackHref = hasLink ? $"/track/{TrackModel!.EntryKey}" : null;
|
||||||
var hasArt = !string.IsNullOrEmpty(TrackModel?.ImagePath);
|
var hasArt = !string.IsNullOrEmpty(TrackModel?.Release?.ImagePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (ViewMode == GalleryViewMode.Grid)
|
@if (ViewMode == GalleryViewMode.Grid)
|
||||||
@@ -13,9 +13,9 @@
|
|||||||
@if (hasLink)
|
@if (hasLink)
|
||||||
{
|
{
|
||||||
<a href="@trackHref" class="deepdrft-track-card-link">
|
<a href="@trackHref" class="deepdrft-track-card-link">
|
||||||
@if (!string.IsNullOrEmpty(TrackModel?.ImagePath))
|
@if (!string.IsNullOrEmpty(TrackModel?.Release?.ImagePath))
|
||||||
{
|
{
|
||||||
<div class="deepdrft-track-card-bg" style="background-image: url('api/image/@Uri.EscapeDataString(TrackModel.ImagePath)');">
|
<div class="deepdrft-track-card-bg" style="background-image: url('api/image/@Uri.EscapeDataString(TrackModel.Release!.ImagePath)');">
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
@@ -24,9 +24,9 @@
|
|||||||
}
|
}
|
||||||
</a>
|
</a>
|
||||||
}
|
}
|
||||||
else if (!string.IsNullOrEmpty(TrackModel?.ImagePath))
|
else if (!string.IsNullOrEmpty(TrackModel?.Release?.ImagePath))
|
||||||
{
|
{
|
||||||
<div class="deepdrft-track-card-bg" style="background-image: url('api/image/@Uri.EscapeDataString(TrackModel.ImagePath)');">
|
<div class="deepdrft-track-card-bg" style="background-image: url('api/image/@Uri.EscapeDataString(TrackModel.Release!.ImagePath)');">
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
@@ -47,7 +47,7 @@
|
|||||||
|
|
||||||
<MudText Typo="Typo.caption"
|
<MudText Typo="Typo.caption"
|
||||||
Class="deepdrft-track-artist text-truncate mb-2">
|
Class="deepdrft-track-artist text-truncate mb-2">
|
||||||
@TrackModel?.Artist
|
@TrackModel?.Release?.Artist
|
||||||
</MudText>
|
</MudText>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
@@ -62,38 +62,38 @@
|
|||||||
|
|
||||||
<MudText Typo="Typo.caption"
|
<MudText Typo="Typo.caption"
|
||||||
Class="deepdrft-track-artist text-truncate mb-2">
|
Class="deepdrft-track-artist text-truncate mb-2">
|
||||||
@TrackModel?.Artist
|
@TrackModel?.Release?.Artist
|
||||||
</MudText>
|
</MudText>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
<div class="deepdrft-track-info-middle">
|
<div class="deepdrft-track-info-middle">
|
||||||
@if (!string.IsNullOrEmpty(TrackModel?.Album))
|
@if (!string.IsNullOrEmpty(TrackModel?.Release?.Title))
|
||||||
{
|
{
|
||||||
<MudText Typo="Typo.caption"
|
<MudText Typo="Typo.caption"
|
||||||
Class="deepdrft-track-meta text-truncate">
|
Class="deepdrft-track-meta text-truncate">
|
||||||
@TrackModel.Album
|
@TrackModel.Release!.Title
|
||||||
</MudText>
|
</MudText>
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (!string.IsNullOrEmpty(TrackModel?.Genre))
|
@if (!string.IsNullOrEmpty(TrackModel?.Release?.Genre))
|
||||||
{
|
{
|
||||||
<MudChip T="string"
|
<MudChip T="string"
|
||||||
Size="Size.Small"
|
Size="Size.Small"
|
||||||
Variant="Variant.Outlined"
|
Variant="Variant.Outlined"
|
||||||
Color="Color.Tertiary"
|
Color="Color.Tertiary"
|
||||||
Class="deepdrft-genre-chip">
|
Class="deepdrft-genre-chip">
|
||||||
@TrackModel.Genre
|
@TrackModel.Release!.Genre
|
||||||
</MudChip>
|
</MudChip>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="deepdrft-track-info-bottom">
|
<div class="deepdrft-track-info-bottom">
|
||||||
@if (TrackModel?.ReleaseDate.HasValue == true)
|
@if (TrackModel?.Release?.ReleaseDate.HasValue == true)
|
||||||
{
|
{
|
||||||
<MudText Typo="Typo.caption"
|
<MudText Typo="Typo.caption"
|
||||||
Class="deepdrft-track-meta">
|
Class="deepdrft-track-meta">
|
||||||
@TrackModel.ReleaseDate.Value.Year
|
@TrackModel.Release!.ReleaseDate!.Value.Year
|
||||||
</MudText>
|
</MudText>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
@@ -127,10 +127,10 @@ else
|
|||||||
{
|
{
|
||||||
<a href="@trackHref" class="deepdrft-track-row-link">
|
<a href="@trackHref" class="deepdrft-track-row-link">
|
||||||
@* art thumb *@
|
@* art thumb *@
|
||||||
@if (!string.IsNullOrEmpty(TrackModel?.ImagePath))
|
@if (!string.IsNullOrEmpty(TrackModel?.Release?.ImagePath))
|
||||||
{
|
{
|
||||||
<div class="deepdrft-track-row-thumb"
|
<div class="deepdrft-track-row-thumb"
|
||||||
style="background-image: url('api/image/@Uri.EscapeDataString(TrackModel.ImagePath)');">
|
style="background-image: url('api/image/@Uri.EscapeDataString(TrackModel.Release!.ImagePath)');">
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
@@ -141,7 +141,7 @@ else
|
|||||||
@* text block *@
|
@* text block *@
|
||||||
<div class="deepdrft-track-row-text">
|
<div class="deepdrft-track-row-text">
|
||||||
<MudText Typo="Typo.subtitle2" Class="deepdrft-track-title text-truncate">
|
<MudText Typo="Typo.subtitle2" Class="deepdrft-track-title text-truncate">
|
||||||
@TrackModel?.Artist
|
@TrackModel?.Release?.Artist
|
||||||
</MudText>
|
</MudText>
|
||||||
<MudText Typo="Typo.caption" Class="deepdrft-track-meta text-truncate">
|
<MudText Typo="Typo.caption" Class="deepdrft-track-meta text-truncate">
|
||||||
@TrackModel?.TrackName
|
@TrackModel?.TrackName
|
||||||
@@ -150,20 +150,20 @@ else
|
|||||||
|
|
||||||
@* right metadata *@
|
@* right metadata *@
|
||||||
<div class="deepdrft-track-row-meta">
|
<div class="deepdrft-track-row-meta">
|
||||||
@if (!string.IsNullOrEmpty(TrackModel?.Genre))
|
@if (!string.IsNullOrEmpty(TrackModel?.Release?.Genre))
|
||||||
{
|
{
|
||||||
<MudChip T="string"
|
<MudChip T="string"
|
||||||
Size="Size.Small"
|
Size="Size.Small"
|
||||||
Variant="Variant.Outlined"
|
Variant="Variant.Outlined"
|
||||||
Color="Color.Tertiary"
|
Color="Color.Tertiary"
|
||||||
Class="deepdrft-genre-chip">
|
Class="deepdrft-genre-chip">
|
||||||
@TrackModel.Genre
|
@TrackModel.Release!.Genre
|
||||||
</MudChip>
|
</MudChip>
|
||||||
}
|
}
|
||||||
@if (TrackModel?.ReleaseDate.HasValue == true)
|
@if (TrackModel?.Release?.ReleaseDate.HasValue == true)
|
||||||
{
|
{
|
||||||
<MudText Typo="Typo.caption" Class="deepdrft-track-meta">
|
<MudText Typo="Typo.caption" Class="deepdrft-track-meta">
|
||||||
@TrackModel.ReleaseDate.Value.Year
|
@TrackModel.Release!.ReleaseDate!.Value.Year
|
||||||
</MudText>
|
</MudText>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -172,10 +172,10 @@ else
|
|||||||
else
|
else
|
||||||
{
|
{
|
||||||
@* same structure without anchor *@
|
@* same structure without anchor *@
|
||||||
@if (!string.IsNullOrEmpty(TrackModel?.ImagePath))
|
@if (!string.IsNullOrEmpty(TrackModel?.Release?.ImagePath))
|
||||||
{
|
{
|
||||||
<div class="deepdrft-track-row-thumb"
|
<div class="deepdrft-track-row-thumb"
|
||||||
style="background-image: url('api/image/@Uri.EscapeDataString(TrackModel.ImagePath)');">
|
style="background-image: url('api/image/@Uri.EscapeDataString(TrackModel.Release!.ImagePath)');">
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
@@ -184,27 +184,27 @@ else
|
|||||||
}
|
}
|
||||||
<div class="deepdrft-track-row-text">
|
<div class="deepdrft-track-row-text">
|
||||||
<MudText Typo="Typo.subtitle2" Class="deepdrft-track-title text-truncate">
|
<MudText Typo="Typo.subtitle2" Class="deepdrft-track-title text-truncate">
|
||||||
@TrackModel?.Artist
|
@TrackModel?.Release?.Artist
|
||||||
</MudText>
|
</MudText>
|
||||||
<MudText Typo="Typo.caption" Class="deepdrft-track-meta text-truncate">
|
<MudText Typo="Typo.caption" Class="deepdrft-track-meta text-truncate">
|
||||||
@TrackModel?.TrackName
|
@TrackModel?.TrackName
|
||||||
</MudText>
|
</MudText>
|
||||||
</div>
|
</div>
|
||||||
<div class="deepdrft-track-row-meta">
|
<div class="deepdrft-track-row-meta">
|
||||||
@if (!string.IsNullOrEmpty(TrackModel?.Genre))
|
@if (!string.IsNullOrEmpty(TrackModel?.Release?.Genre))
|
||||||
{
|
{
|
||||||
<MudChip T="string"
|
<MudChip T="string"
|
||||||
Size="Size.Small"
|
Size="Size.Small"
|
||||||
Variant="Variant.Outlined"
|
Variant="Variant.Outlined"
|
||||||
Color="Color.Tertiary"
|
Color="Color.Tertiary"
|
||||||
Class="deepdrft-genre-chip">
|
Class="deepdrft-genre-chip">
|
||||||
@TrackModel.Genre
|
@TrackModel.Release!.Genre
|
||||||
</MudChip>
|
</MudChip>
|
||||||
}
|
}
|
||||||
@if (TrackModel?.ReleaseDate.HasValue == true)
|
@if (TrackModel?.Release?.ReleaseDate.HasValue == true)
|
||||||
{
|
{
|
||||||
<MudText Typo="Typo.caption" Class="deepdrft-track-meta">
|
<MudText Typo="Typo.caption" Class="deepdrft-track-meta">
|
||||||
@TrackModel.ReleaseDate.Value.Year
|
@TrackModel.Release!.ReleaseDate!.Value.Year
|
||||||
</MudText>
|
</MudText>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -33,11 +33,11 @@
|
|||||||
<div class="album-card"
|
<div class="album-card"
|
||||||
role="button"
|
role="button"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
@onclick="@(() => OpenAlbum(album.Album))">
|
@onclick="@(() => OpenAlbum(album.Title))">
|
||||||
@if (!string.IsNullOrEmpty(album.CoverImageKey))
|
@if (!string.IsNullOrEmpty(album.ImagePath))
|
||||||
{
|
{
|
||||||
<div class="album-card-cover"
|
<div class="album-card-cover"
|
||||||
style="background-image: url('api/image/@Uri.EscapeDataString(album.CoverImageKey)');">
|
style="background-image: url('api/image/@Uri.EscapeDataString(album.ImagePath)');">
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
@@ -47,7 +47,7 @@
|
|||||||
|
|
||||||
<div class="album-card-body">
|
<div class="album-card-body">
|
||||||
<MudText Typo="Typo.subtitle1" Class="album-card-title text-truncate">
|
<MudText Typo="Typo.subtitle1" Class="album-card-title text-truncate">
|
||||||
@album.Album
|
@album.Title
|
||||||
</MudText>
|
</MudText>
|
||||||
<MudText Typo="Typo.caption" Class="album-card-count">
|
<MudText Typo="Typo.caption" Class="album-card-count">
|
||||||
@album.TrackCount @(album.TrackCount == 1 ? "track" : "tracks")
|
@album.TrackCount @(album.TrackCount == 1 ? "track" : "tracks")
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ public partial class AlbumsView : ComponentBase
|
|||||||
[Inject] public required NavigationManager Navigation { get; set; }
|
[Inject] public required NavigationManager Navigation { get; set; }
|
||||||
|
|
||||||
private bool _loading = true;
|
private bool _loading = true;
|
||||||
private List<AlbumSummaryDto> _albums = [];
|
private List<ReleaseDto> _albums = [];
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -43,7 +43,9 @@ else if (ViewModel.Track is not null)
|
|||||||
var isThisTrackPlaying = PlayerService.CurrentTrack?.Id == track.Id
|
var isThisTrackPlaying = PlayerService.CurrentTrack?.Id == track.Id
|
||||||
&& PlayerService.IsPlaying
|
&& PlayerService.IsPlaying
|
||||||
&& !PlayerService.IsPaused;
|
&& !PlayerService.IsPaused;
|
||||||
var hasMeta = track.Album is not null || track.Genre is not null || track.ReleaseDate is not null;
|
var release = track.Release;
|
||||||
|
var hasMeta = release is not null
|
||||||
|
&& (release.Title is not null || release.Genre is not null || release.ReleaseDate is not null);
|
||||||
|
|
||||||
<div class="deepdrft-track-detail-container">
|
<div class="deepdrft-track-detail-container">
|
||||||
|
|
||||||
@@ -54,7 +56,7 @@ else if (ViewModel.Track is not null)
|
|||||||
<MudStack Row AlignItems="AlignItems.Start" Justify="Justify.SpaceBetween" Style="margin: 2rem 0 1.5rem;">
|
<MudStack Row AlignItems="AlignItems.Start" Justify="Justify.SpaceBetween" Style="margin: 2rem 0 1.5rem;">
|
||||||
<div class="deepdrft-track-detail-masthead">
|
<div class="deepdrft-track-detail-masthead">
|
||||||
<MudText Typo="Typo.h3">@track.TrackName</MudText>
|
<MudText Typo="Typo.h3">@track.TrackName</MudText>
|
||||||
<MudText Typo="Typo.h6" Color="Color.Primary">@track.Artist</MudText>
|
<MudText Typo="Typo.h6" Color="Color.Primary">@release?.Artist</MudText>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<MudStack Row AlignItems="AlignItems.Center" Spacing="1">
|
<MudStack Row AlignItems="AlignItems.Center" Spacing="1">
|
||||||
@@ -64,10 +66,10 @@ else if (ViewModel.Track is not null)
|
|||||||
</MudStack>
|
</MudStack>
|
||||||
|
|
||||||
<div class="deepdrft-track-detail-cover">
|
<div class="deepdrft-track-detail-cover">
|
||||||
@if (!string.IsNullOrEmpty(track.ImagePath))
|
@if (!string.IsNullOrEmpty(release?.ImagePath))
|
||||||
{
|
{
|
||||||
<MudPaper Elevation="2" Class="deepdrft-track-detail-cover-art"
|
<MudPaper Elevation="2" Class="deepdrft-track-detail-cover-art"
|
||||||
Style="@($"background-image: url('api/image/{Uri.EscapeDataString(track.ImagePath)}');")" />
|
Style="@($"background-image: url('api/image/{Uri.EscapeDataString(release.ImagePath)}');")" />
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -82,31 +84,31 @@ else if (ViewModel.Track is not null)
|
|||||||
<MudDivider />
|
<MudDivider />
|
||||||
|
|
||||||
<div class="deepdrft-track-detail-meta">
|
<div class="deepdrft-track-detail-meta">
|
||||||
@if (track.Album is not null)
|
@if (release?.Title is not null)
|
||||||
{
|
{
|
||||||
<div>
|
<div>
|
||||||
<MudText Typo="Typo.overline">Album</MudText>
|
<MudText Typo="Typo.overline">Album</MudText>
|
||||||
<MudText Typo="Typo.body1">@track.Album</MudText>
|
<MudText Typo="Typo.body1">@release.Title</MudText>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (track.Genre is not null)
|
@if (release?.Genre is not null)
|
||||||
{
|
{
|
||||||
<div>
|
<div>
|
||||||
<MudChip T="string"
|
<MudChip T="string"
|
||||||
Variant="Variant.Outlined"
|
Variant="Variant.Outlined"
|
||||||
Color="Color.Tertiary"
|
Color="Color.Tertiary"
|
||||||
Class="deepdrft-genre-chip">
|
Class="deepdrft-genre-chip">
|
||||||
@track.Genre
|
@release.Genre
|
||||||
</MudChip>
|
</MudChip>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (track.ReleaseDate is not null)
|
@if (release?.ReleaseDate is not null)
|
||||||
{
|
{
|
||||||
<div>
|
<div>
|
||||||
<MudText Typo="Typo.overline">Released</MudText>
|
<MudText Typo="Typo.overline">Released</MudText>
|
||||||
<MudText Typo="Typo.body1">@track.ReleaseDate.Value.ToString("MMMM yyyy")</MudText>
|
<MudText Typo="Typo.body1">@release.ReleaseDate.Value.ToString("MMMM yyyy")</MudText>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -21,8 +21,8 @@ public interface ITrackDataService
|
|||||||
string? album = null,
|
string? album = null,
|
||||||
string? genre = null);
|
string? genre = null);
|
||||||
|
|
||||||
/// <summary>Distinct non-null albums with track counts and a representative cover key.</summary>
|
/// <summary>All releases with track counts, title-ascending.</summary>
|
||||||
Task<ApiResult<List<AlbumSummaryDto>>> GetAlbums();
|
Task<ApiResult<List<ReleaseDto>>> GetAlbums();
|
||||||
|
|
||||||
/// <summary>Distinct non-null genres with track counts.</summary>
|
/// <summary>Distinct non-null genres with track counts.</summary>
|
||||||
Task<ApiResult<List<GenreSummaryDto>>> GetGenres();
|
Task<ApiResult<List<GenreSummaryDto>>> GetGenres();
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ public class TrackClientDataService : ITrackDataService
|
|||||||
string? genre = null)
|
string? genre = null)
|
||||||
=> _trackClient.GetPage(pageNumber, pageSize, sortColumn, sortDescending, searchText, album, genre);
|
=> _trackClient.GetPage(pageNumber, pageSize, sortColumn, sortDescending, searchText, album, genre);
|
||||||
|
|
||||||
public Task<ApiResult<List<AlbumSummaryDto>>> GetAlbums()
|
public Task<ApiResult<List<ReleaseDto>>> GetAlbums()
|
||||||
=> _trackClient.GetAlbums();
|
=> _trackClient.GetAlbums();
|
||||||
|
|
||||||
public Task<ApiResult<List<GenreSummaryDto>>> GetGenres()
|
public Task<ApiResult<List<GenreSummaryDto>>> GetGenres()
|
||||||
|
|||||||
@@ -10,15 +10,18 @@ using Models.Common;
|
|||||||
namespace DeepDrftTests;
|
namespace DeepDrftTests;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Query-shape tests for the Phase 2.2/2.3 filter and distinct-browse repository methods.
|
/// Query-shape tests for the filter and distinct-browse repository methods, updated for the
|
||||||
|
/// Phase 8 §8.0 normalized schema: release-cardinal data (Artist, Album→Title, Genre, ImagePath)
|
||||||
|
/// lives on <see cref="ReleaseEntity"/>, reached through the nullable Release navigation. Tracks
|
||||||
|
/// link via <c>ReleaseId</c>; loose tracks have a null release.
|
||||||
///
|
///
|
||||||
/// Provider note: these run on the EF in-memory provider, which executes LINQ in process. That
|
/// Provider note: these run on the EF in-memory provider, which executes LINQ in process. That
|
||||||
/// covers exact-match equality, null passthrough, GroupBy/Count, and ordering — every predicate
|
/// covers exact-match equality through the navigation, null passthrough, GroupBy/Count, and
|
||||||
/// in <see cref="TrackRepository.GetPagedFilteredAsync"/> except the free-text branch. That branch
|
/// ordering — every predicate in <see cref="TrackRepository.GetPagedFilteredAsync"/> except the
|
||||||
/// uses <c>EF.Functions.ILike</c>, an Npgsql-only relational function with no in-memory translation,
|
/// free-text branch. That branch uses <c>EF.Functions.ILike</c>, an Npgsql-only relational
|
||||||
/// so the SearchText case is a Postgres integration test gated on a DSN (see SearchText_*). It is
|
/// function with no in-memory translation, so the SearchText case is a Postgres integration test
|
||||||
/// ignored when no test database is configured rather than asserted against a provider that never
|
/// gated on a DSN (see SearchText_*). It is ignored when no test database is configured rather
|
||||||
/// runs the predicate.
|
/// than asserted against a provider that never runs the predicate.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[TestFixture]
|
[TestFixture]
|
||||||
public class TrackFilterQueryTests
|
public class TrackFilterQueryTests
|
||||||
@@ -43,16 +46,23 @@ public class TrackFilterQueryTests
|
|||||||
private TrackRepository CreateRepository()
|
private TrackRepository CreateRepository()
|
||||||
=> new(_context, NullLogger<Repository<DeepDrftContext, TrackEntity>>.Instance);
|
=> new(_context, NullLogger<Repository<DeepDrftContext, TrackEntity>>.Instance);
|
||||||
|
|
||||||
private static TrackEntity Track(
|
private static ReleaseEntity Release(
|
||||||
string name, string artist, string? album = null, string? genre = null, string? image = null)
|
string title, string artist, string? genre = null, string? image = null)
|
||||||
|
=> new()
|
||||||
|
{
|
||||||
|
Title = title,
|
||||||
|
Artist = artist,
|
||||||
|
Genre = genre,
|
||||||
|
ImagePath = image,
|
||||||
|
};
|
||||||
|
|
||||||
|
// A track linked to the given release (or loose when release is null).
|
||||||
|
private static TrackEntity Track(string name, ReleaseEntity? release = null)
|
||||||
=> new()
|
=> new()
|
||||||
{
|
{
|
||||||
EntryKey = Guid.NewGuid().ToString("N"),
|
EntryKey = Guid.NewGuid().ToString("N"),
|
||||||
TrackName = name,
|
TrackName = name,
|
||||||
Artist = artist,
|
Release = release,
|
||||||
Album = album,
|
|
||||||
Genre = genre,
|
|
||||||
ImagePath = image,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
private async Task SeedAsync(params TrackEntity[] tracks)
|
private async Task SeedAsync(params TrackEntity[] tracks)
|
||||||
@@ -64,16 +74,18 @@ public class TrackFilterQueryTests
|
|||||||
private static PagingParameters<TrackEntity> DefaultPaging()
|
private static PagingParameters<TrackEntity> DefaultPaging()
|
||||||
=> new() { Page = 1, PageSize = 20, OrderBy = t => t.Id, IsDescending = false };
|
=> new() { Page = 1, PageSize = 20, OrderBy = t => t.Id, IsDescending = false };
|
||||||
|
|
||||||
// Case 2 — exact album match: returns only rows whose Album equals the filter value, and
|
// Case 2 — exact album match: returns only rows whose linked release Title equals the filter
|
||||||
// TotalCount reflects the filtered set, not the table.
|
// value, and TotalCount reflects the filtered set, not the table.
|
||||||
[Test]
|
[Test]
|
||||||
public async Task GetPagedFilteredAsync_WithExactAlbum_ReturnsOnlyThatAlbum()
|
public async Task GetPagedFilteredAsync_WithExactAlbum_ReturnsOnlyThatAlbum()
|
||||||
{
|
{
|
||||||
|
var blue = Release("Blue", "A");
|
||||||
|
var red = Release("Red", "C");
|
||||||
await SeedAsync(
|
await SeedAsync(
|
||||||
Track("One", "A", album: "Blue"),
|
Track("One", blue),
|
||||||
Track("Two", "B", album: "Blue"),
|
Track("Two", blue),
|
||||||
Track("Three", "C", album: "Red"),
|
Track("Three", red),
|
||||||
Track("Four", "D", album: null));
|
Track("Four"));
|
||||||
|
|
||||||
var repo = CreateRepository();
|
var repo = CreateRepository();
|
||||||
var result = await repo.GetPagedFilteredAsync(DefaultPaging(), new TrackFilter { Album = "Blue" });
|
var result = await repo.GetPagedFilteredAsync(DefaultPaging(), new TrackFilter { Album = "Blue" });
|
||||||
@@ -82,14 +94,14 @@ public class TrackFilterQueryTests
|
|||||||
Assert.That(result.Items.Select(t => t.TrackName), Is.EquivalentTo(new[] { "One", "Two" }));
|
Assert.That(result.Items.Select(t => t.TrackName), Is.EquivalentTo(new[] { "One", "Two" }));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Case 2b — exact genre match composes the same way as album.
|
// Case 2b — exact genre match composes the same way as album, through the release join.
|
||||||
[Test]
|
[Test]
|
||||||
public async Task GetPagedFilteredAsync_WithExactGenre_ReturnsOnlyThatGenre()
|
public async Task GetPagedFilteredAsync_WithExactGenre_ReturnsOnlyThatGenre()
|
||||||
{
|
{
|
||||||
await SeedAsync(
|
await SeedAsync(
|
||||||
Track("One", "A", genre: "Techno"),
|
Track("One", Release("A1", "A", genre: "Techno")),
|
||||||
Track("Two", "B", genre: "House"),
|
Track("Two", Release("A2", "B", genre: "House")),
|
||||||
Track("Three", "C", genre: "Techno"));
|
Track("Three", Release("A3", "C", genre: "Techno")));
|
||||||
|
|
||||||
var repo = CreateRepository();
|
var repo = CreateRepository();
|
||||||
var result = await repo.GetPagedFilteredAsync(DefaultPaging(), new TrackFilter { Genre = "Techno" });
|
var result = await repo.GetPagedFilteredAsync(DefaultPaging(), new TrackFilter { Genre = "Techno" });
|
||||||
@@ -103,9 +115,9 @@ public class TrackFilterQueryTests
|
|||||||
public async Task GetPagedFilteredAsync_WithNullFilter_MatchesUnfilteredPagedQuery()
|
public async Task GetPagedFilteredAsync_WithNullFilter_MatchesUnfilteredPagedQuery()
|
||||||
{
|
{
|
||||||
await SeedAsync(
|
await SeedAsync(
|
||||||
Track("One", "A", album: "Blue"),
|
Track("One", Release("Blue", "A")),
|
||||||
Track("Two", "B", album: "Red"),
|
Track("Two", Release("Red", "B")),
|
||||||
Track("Three", "C"));
|
Track("Three"));
|
||||||
|
|
||||||
var repo = CreateRepository();
|
var repo = CreateRepository();
|
||||||
var baseline = await repo.GetPagedAsync(DefaultPaging());
|
var baseline = await repo.GetPagedAsync(DefaultPaging());
|
||||||
@@ -117,56 +129,98 @@ public class TrackFilterQueryTests
|
|||||||
Is.EqualTo(baseline.Items.Select(t => t.Id)).AsCollection);
|
Is.EqualTo(baseline.Items.Select(t => t.Id)).AsCollection);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Case 4 — distinct albums: excludes null-album rows, counts per group, and takes the cover from
|
// Case 4 — releases: returns every non-deleted release, title-ascending. Replaces the old
|
||||||
// the first track in the group that has a non-null ImagePath. Ordered by album ascending.
|
// distinct-albums grouping; one row per release rather than per distinct album string.
|
||||||
[Test]
|
[Test]
|
||||||
public async Task GetDistinctAlbumsAsync_GroupsCountsAndPicksCover()
|
public async Task GetReleasesAsync_ReturnsAllReleasesTitleAscending()
|
||||||
{
|
{
|
||||||
await SeedAsync(
|
await SeedAsync(
|
||||||
Track("One", "A", album: "Zephyr", image: null),
|
Track("One", Release("Zephyr", "A", image: "cover-z")),
|
||||||
Track("Two", "A", album: "Zephyr", image: "cover-z"),
|
Track("Two", Release("Aria", "B", image: "cover-a")),
|
||||||
Track("Three", "B", album: "Aria", image: "cover-a"),
|
Track("Three"));
|
||||||
Track("Four", "C", album: null, image: "ignored"));
|
|
||||||
|
|
||||||
var repo = CreateRepository();
|
var repo = CreateRepository();
|
||||||
var albums = await repo.GetDistinctAlbumsAsync();
|
var releases = await repo.GetReleasesAsync();
|
||||||
|
|
||||||
Assert.That(albums.Select(a => a.Album), Is.EqualTo(new[] { "Aria", "Zephyr" }).AsCollection,
|
Assert.That(releases.Select(r => r.Title), Is.EqualTo(new[] { "Aria", "Zephyr" }).AsCollection,
|
||||||
"albums sort ascending and the null-album track is excluded");
|
"releases sort by title ascending; the loose track contributes none");
|
||||||
|
Assert.That(releases.Single(r => r.Title == "Zephyr").ImagePath, Is.EqualTo("cover-z"));
|
||||||
var zephyr = albums.Single(a => a.Album == "Zephyr");
|
Assert.That(releases.Single(r => r.Title == "Aria").ImagePath, Is.EqualTo("cover-a"));
|
||||||
Assert.That(zephyr.TrackCount, Is.EqualTo(2));
|
|
||||||
Assert.That(zephyr.CoverImageKey, Is.EqualTo("cover-z"),
|
|
||||||
"cover is the first non-null ImagePath in the group");
|
|
||||||
|
|
||||||
var aria = albums.Single(a => a.Album == "Aria");
|
|
||||||
Assert.That(aria.TrackCount, Is.EqualTo(1));
|
|
||||||
Assert.That(aria.CoverImageKey, Is.EqualTo("cover-a"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Case 5 — distinct genres: excludes null-genre rows, counts per group, ordered genre ascending.
|
// Case 4b — per-release track counts: keyed by ReleaseId, counting only non-deleted tracks.
|
||||||
|
// Loose tracks (null ReleaseId) contribute no entry. Backs ReleaseDto.TrackCount.
|
||||||
|
[Test]
|
||||||
|
public async Task GetTrackCountsByReleaseAsync_CountsTracksPerRelease()
|
||||||
|
{
|
||||||
|
var zephyr = Release("Zephyr", "A");
|
||||||
|
var aria = Release("Aria", "B");
|
||||||
|
await SeedAsync(
|
||||||
|
Track("One", zephyr),
|
||||||
|
Track("Two", zephyr),
|
||||||
|
Track("Three", aria),
|
||||||
|
Track("Four"));
|
||||||
|
|
||||||
|
var repo = CreateRepository();
|
||||||
|
var counts = await repo.GetTrackCountsByReleaseAsync();
|
||||||
|
|
||||||
|
Assert.That(counts[zephyr.Id], Is.EqualTo(2));
|
||||||
|
Assert.That(counts[aria.Id], Is.EqualTo(1));
|
||||||
|
Assert.That(counts.Count, Is.EqualTo(2), "the loose track contributes no release key");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Case 5 — distinct genres: sourced from the release join, excludes releases with null genre,
|
||||||
|
// counts tracks per genre, ordered genre ascending.
|
||||||
[Test]
|
[Test]
|
||||||
public async Task GetDistinctGenresAsync_GroupsCountsAndExcludesNull()
|
public async Task GetDistinctGenresAsync_GroupsCountsAndExcludesNull()
|
||||||
{
|
{
|
||||||
await SeedAsync(
|
await SeedAsync(
|
||||||
Track("One", "A", genre: "Techno"),
|
Track("One", Release("A1", "A", genre: "Techno")),
|
||||||
Track("Two", "B", genre: "Ambient"),
|
Track("Two", Release("A2", "B", genre: "Ambient")),
|
||||||
Track("Three", "C", genre: "Techno"),
|
Track("Three", Release("A3", "C", genre: "Techno")),
|
||||||
Track("Four", "D", genre: null));
|
Track("Four", Release("A4", "D")));
|
||||||
|
|
||||||
var repo = CreateRepository();
|
var repo = CreateRepository();
|
||||||
var genres = await repo.GetDistinctGenresAsync();
|
var genres = await repo.GetDistinctGenresAsync();
|
||||||
|
|
||||||
Assert.That(genres.Select(g => g.Genre), Is.EqualTo(new[] { "Ambient", "Techno" }).AsCollection,
|
Assert.That(genres.Select(g => g.Genre), Is.EqualTo(new[] { "Ambient", "Techno" }).AsCollection,
|
||||||
"genres sort ascending and the null-genre track is excluded");
|
"genres sort ascending and the null-genre release is excluded");
|
||||||
Assert.That(genres.Single(g => g.Genre == "Techno").TrackCount, Is.EqualTo(2));
|
Assert.That(genres.Single(g => g.Genre == "Techno").TrackCount, Is.EqualTo(2));
|
||||||
Assert.That(genres.Single(g => g.Genre == "Ambient").TrackCount, Is.EqualTo(1));
|
Assert.That(genres.Single(g => g.Genre == "Ambient").TrackCount, Is.EqualTo(1));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Case 1 — free-text search across TrackName/Artist/Album, case-insensitive. EF.Functions.ILike
|
// Case 6 — find-or-create resolution: an existing (title, artist) returns the stored row, no
|
||||||
// is Npgsql-only and does not translate on the in-memory provider, so this runs only against a
|
// duplicate insert. Exercises the natural-key lookup that backs the upload FK resolution.
|
||||||
// real Postgres database supplied via the DEEPDRFT_TEST_PG environment variable. Without it the
|
[Test]
|
||||||
// test is ignored rather than asserted against a provider that cannot execute the predicate.
|
public async Task GetReleaseByTitleAndArtistAsync_ReturnsExistingMatch()
|
||||||
|
{
|
||||||
|
var blue = Release("Blue", "Artist A");
|
||||||
|
await SeedAsync(Track("One", blue));
|
||||||
|
|
||||||
|
var repo = CreateRepository();
|
||||||
|
var found = await repo.GetReleaseByTitleAndArtistAsync("Blue", "Artist A");
|
||||||
|
|
||||||
|
Assert.That(found, Is.Not.Null);
|
||||||
|
Assert.That(found!.Id, Is.EqualTo(blue.Id));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Case 6b — no match returns null so the manager creates a fresh release.
|
||||||
|
[Test]
|
||||||
|
public async Task GetReleaseByTitleAndArtistAsync_ReturnsNullWhenNoMatch()
|
||||||
|
{
|
||||||
|
await SeedAsync(Track("One", Release("Blue", "Artist A")));
|
||||||
|
|
||||||
|
var repo = CreateRepository();
|
||||||
|
var found = await repo.GetReleaseByTitleAndArtistAsync("Red", "Artist A");
|
||||||
|
|
||||||
|
Assert.That(found, Is.Null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Case 1 — free-text search across TrackName plus the joined release Artist/Title,
|
||||||
|
// case-insensitive. EF.Functions.ILike is Npgsql-only and does not translate on the in-memory
|
||||||
|
// provider, so this runs only against a real Postgres database supplied via the
|
||||||
|
// DEEPDRFT_TEST_PG environment variable. Without it the test is ignored rather than asserted
|
||||||
|
// against a provider that cannot execute the predicate.
|
||||||
[Test]
|
[Test]
|
||||||
public async Task GetPagedFilteredAsync_WithSearchText_MatchesNameArtistOrAlbumCaseInsensitive()
|
public async Task GetPagedFilteredAsync_WithSearchText_MatchesNameArtistOrAlbumCaseInsensitive()
|
||||||
{
|
{
|
||||||
@@ -183,10 +237,10 @@ public class TrackFilterQueryTests
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
pg.Tracks.AddRange(
|
pg.Tracks.AddRange(
|
||||||
Track("Jazz Odyssey", "Spinal Tap", album: "Smell the Glove"),
|
Track("Jazz Odyssey", Release("Smell the Glove", "Spinal Tap")),
|
||||||
Track("Quiet Storm", "jazzmin", album: "Nightfall"),
|
Track("Quiet Storm", Release("Nightfall", "jazzmin")),
|
||||||
Track("Loud Noises", "Brick", album: "All JAZZ Hands"),
|
Track("Loud Noises", Release("All JAZZ Hands", "Brick")),
|
||||||
Track("Unrelated", "Nobody", album: "Silence"));
|
Track("Unrelated", Release("Silence", "Nobody")));
|
||||||
await pg.SaveChangesAsync();
|
await pg.SaveChangesAsync();
|
||||||
|
|
||||||
var repo = new TrackRepository(
|
var repo = new TrackRepository(
|
||||||
@@ -195,7 +249,7 @@ public class TrackFilterQueryTests
|
|||||||
|
|
||||||
Assert.That(result.Items.Select(t => t.TrackName),
|
Assert.That(result.Items.Select(t => t.TrackName),
|
||||||
Is.EquivalentTo(new[] { "Jazz Odyssey", "Quiet Storm", "Loud Noises" }),
|
Is.EquivalentTo(new[] { "Jazz Odyssey", "Quiet Storm", "Loud Noises" }),
|
||||||
"ILike matches 'jazz' case-insensitively in TrackName, Artist, or Album");
|
"ILike matches 'jazz' case-insensitively in TrackName, release Artist, or release Title");
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user