feat(release): front int PK with app-minted GUID EntryKey on the public addressing surface (P11 W5, 11.H)

This commit is contained in:
daniel-c-harvey
2026-06-16 17:11:55 -04:00
parent fe28573b68
commit f07d29cdcf
37 changed files with 627 additions and 160 deletions
+7 -7
View File
@@ -220,19 +220,19 @@ Paged release list, optionally filtered to one medium. Public browse data, same
- `sortDescending` (bool, optional, default false): sort direction. - `sortDescending` (bool, optional, default false): sort direction.
- Returns 200 with `PagedResult<ReleaseDto>` on success. Returns 400 if `medium` is unrecognized. Returns 500 on query error. - Returns 200 with `PagedResult<ReleaseDto>` on success. Returns 400 if `medium` is unrecognized. Returns 500 on query error.
### GET api/release/{id:long} (unauthenticated) ### GET api/release/{entryKey} (unauthenticated)
Single release with both metadata navs (nulls for non-matching media). Public, same auth posture as `GET api/release`. Single release with both metadata navs (nulls for non-matching media). Public, same auth posture as `GET api/release`. Addresses releases by their opaque public `EntryKey` (GUID string), never the int PK (Phase 11 §3e).
- **Route parameter `id`** (long): the SQL release ID. - **Route parameter `entryKey`** (string): the release's `EntryKey` (the public handle).
- **Response**: `ReleaseDto` with `Id`, `Title`, `Artist`, `Genre`, `ReleaseDate`, `Medium`, `ImagePath`, and media-specific metadata satellites (`MixMetadata` for Cut/Mix, `SessionMetadata` for Session; others null). - **Response**: `ReleaseDto` with `Id`, `EntryKey`, `Title`, `Artist`, `Genre`, `ReleaseDate`, `Medium`, `ImagePath`, and media-specific metadata satellites (`MixMetadata` for Cut/Mix, `SessionMetadata` for Session; others null).
- Returns 200 on success. Returns 404 if not found. Returns 500 on query error. - Returns 200 on success. Returns 404 if not found. Returns 500 on query error.
### GET api/release/{id:long}/mix/waveform (unauthenticated) ### GET api/release/{entryKey}/mix/waveform (unauthenticated)
Serves the high-res waveform datum for a Mix release as base64-encoded bytes. Mirrors `GET api/track/{id}/waveform` but reads from the `mix-waveforms` vault. Serves the high-res waveform datum for a Mix release as base64-encoded bytes. Mirrors `GET api/track/{id}/waveform` but reads from the `mix-waveforms` vault. Public read — addresses by the release `EntryKey` (§3e).
- **Route parameter `id`** (long): the SQL release ID. - **Route parameter `entryKey`** (string): the release's `EntryKey`.
- **Response**: `WaveformProfileDto` with `BucketCount` and `Data` (base64). - **Response**: `WaveformProfileDto` with `BucketCount` and `Data` (base64).
- Returns 200 on success. Returns 404 if the release is not a Mix, carries no waveform key, or no datum is stored. Returns 500 on query/vault error. - Returns 200 on success. Returns 404 if the release is not a Mix, carries no waveform key, or no datum is stored. Returns 500 on query/vault error.
+18 -15
View File
@@ -66,18 +66,20 @@ public class ReleaseController : ControllerBase
return Ok(result.Value); return Ok(result.Value);
} }
// GET api/release/{id}/mix/waveform (unauthenticated) // GET api/release/{entryKey}/mix/waveform (unauthenticated)
// Serves the high-res waveform datum for a Mix release as base64. Mirrors GET api/track/{id}/waveform // Serves the high-res waveform datum for a Mix release as base64. Mirrors GET api/track/{id}/waveform
// but reads from the mix-waveforms vault. 404 when the release is not a Mix, carries no waveform key, // but reads from the mix-waveforms vault. 404 when the release is not a Mix, carries no waveform key,
// or no datum is stored. Declared before the shorter "{id:long}" route for clarity. // or no datum is stored. Public read — addresses by the opaque EntryKey, not the int PK (§3e). The
[HttpGet("{id:long}/mix/waveform")] // {entryKey} string segment cannot collide with the ApiKey-gated POST {id:long}/mix/waveform (different
public async Task<ActionResult> GetMixWaveform(long id, CancellationToken ct = default) // verb + constraint). Declared before the shorter "{entryKey}" route for clarity.
[HttpGet("{entryKey}/mix/waveform")]
public async Task<ActionResult> GetMixWaveform(string entryKey, CancellationToken ct = default)
{ {
var lookup = await _releaseService.GetByIdAsync(id, ct); var lookup = await _releaseService.GetByEntryKeyAsync(entryKey, ct);
if (!lookup.Success) if (!lookup.Success)
{ {
var error = lookup.Messages.FirstOrDefault()?.Message ?? "Unknown error"; var error = lookup.Messages.FirstOrDefault()?.Message ?? "Unknown error";
_logger.LogError("GetMixWaveform lookup failed for {ReleaseId}: {Error}", id, error); _logger.LogError("GetMixWaveform lookup failed for {EntryKey}: {Error}", entryKey, error);
return StatusCode(500, "Failed to load release"); return StatusCode(500, "Failed to load release");
} }
@@ -85,14 +87,14 @@ public class ReleaseController : ControllerBase
var waveformEntryKey = release?.MixMetadata?.WaveformEntryKey; var waveformEntryKey = release?.MixMetadata?.WaveformEntryKey;
if (release is null || release.Medium != ReleaseMedium.Mix || string.IsNullOrEmpty(waveformEntryKey)) if (release is null || release.Medium != ReleaseMedium.Mix || string.IsNullOrEmpty(waveformEntryKey))
{ {
_logger.LogInformation("No mix waveform datum for release: {ReleaseId}", id); _logger.LogInformation("No mix waveform datum for release: {EntryKey}", entryKey);
return NotFound(); return NotFound();
} }
var bytes = await _waveformProfileService.GetProfileAsync(waveformEntryKey, VaultConstants.MixWaveforms); var bytes = await _waveformProfileService.GetProfileAsync(waveformEntryKey, VaultConstants.MixWaveforms);
if (bytes is null) if (bytes is null)
{ {
_logger.LogInformation("Mix waveform key set but no datum stored for release: {ReleaseId}", id); _logger.LogInformation("Mix waveform key set but no datum stored for release: {EntryKey}", entryKey);
return NotFound(); return NotFound();
} }
@@ -159,17 +161,18 @@ public class ReleaseController : ControllerBase
return StatusCode(500, error); return StatusCode(500, error);
} }
// GET api/release/{id} (unauthenticated) // GET api/release/{entryKey} (unauthenticated)
// Single release with both metadata navs (nulls for non-matching media). Declared after the longer // Single release with both metadata navs (nulls for non-matching media). Public read — addresses by
// "{id:long}/mix/waveform" routes so the segmented routes resolve first. // the opaque EntryKey, not the int PK (§3e). Declared after the longer "{entryKey}/mix/waveform"
[HttpGet("{id:long}")] // route so the segmented route resolves first.
public async Task<ActionResult> GetReleaseById(long id, CancellationToken ct = default) [HttpGet("{entryKey}")]
public async Task<ActionResult> GetReleaseByEntryKey(string entryKey, CancellationToken ct = default)
{ {
var result = await _releaseService.GetByIdAsync(id, ct); var result = await _releaseService.GetByEntryKeyAsync(entryKey, ct);
if (!result.Success) if (!result.Success)
{ {
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error"; var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
_logger.LogError("GetReleaseById failed for {ReleaseId}: {Error}", id, error); _logger.LogError("GetReleaseByEntryKey failed for {EntryKey}: {Error}", entryKey, error);
return StatusCode(500, "Failed to load release"); return StatusCode(500, "Failed to load release");
} }
@@ -21,6 +21,18 @@ public class ReleaseConfiguration : BaseEntityConfiguration<ReleaseEntity>
builder.Property(e => e.UpdatedAt).HasColumnName("updated_at"); builder.Property(e => e.UpdatedAt).HasColumnName("updated_at");
builder.Property(e => e.IsDeleted).HasColumnName("is_deleted"); builder.Property(e => e.IsDeleted).HasColumnName("is_deleted");
// App-minted GUID-string public handle, configured exactly like TrackConfiguration's
// entry_key: required, max 100, snake_case column. The unique index guarantees a release
// resolves to one row by its public key.
builder.Property(e => e.EntryKey)
.IsRequired()
.HasMaxLength(100)
.HasColumnName("entry_key");
builder.HasIndex(e => e.EntryKey)
.IsUnique()
.HasDatabaseName("IX_release_entry_key");
builder.Property(e => e.Title) builder.Property(e => e.Title)
.IsRequired() .IsRequired()
.HasMaxLength(200) .HasMaxLength(200)
+3
View File
@@ -20,6 +20,9 @@ public interface IReleaseService
/// <summary>Single release with both metadata navs included (nulls for non-matching media).</summary> /// <summary>Single release with both metadata navs included (nulls for non-matching media).</summary>
Task<ResultContainer<ReleaseDto?>> GetByIdAsync(long id, CancellationToken cancellationToken = default); Task<ResultContainer<ReleaseDto?>> GetByIdAsync(long id, CancellationToken cancellationToken = default);
/// <summary>The public addressing read: single release resolved by its opaque EntryKey (Phase 11 §3e). Both metadata navs included (nulls for non-matching media).</summary>
Task<ResultContainer<ReleaseDto?>> GetByEntryKeyAsync(string entryKey, CancellationToken cancellationToken = default);
/// <summary>Track entry keys for a release. Single-entry for Session/Mix (enforced at upload); may be multiple for Cut.</summary> /// <summary>Track entry keys for a release. Single-entry for Session/Mix (enforced at upload); may be multiple for Cut.</summary>
Task<ResultContainer<List<string>>> GetTrackEntryKeysAsync(long releaseId, CancellationToken cancellationToken = default); Task<ResultContainer<List<string>>> GetTrackEntryKeysAsync(long releaseId, CancellationToken cancellationToken = default);
@@ -0,0 +1,318 @@
// <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("20260616210143_AddReleaseEntryKey")]
partial class AddReleaseEntryKey
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.7")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("DeepDrftModels.Entities.MixMetadata", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<bool>("IsDeleted")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false)
.HasColumnName("is_deleted");
b.Property<long>("ReleaseId")
.HasColumnType("bigint")
.HasColumnName("release_id");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.Property<string>("WaveformEntryKey")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("waveform_entry_key");
b.HasKey("Id");
b.HasIndex("IsDeleted")
.HasDatabaseName("IX_mix_metadata_is_deleted");
b.HasIndex("ReleaseId")
.IsUnique()
.HasDatabaseName("IX_mix_metadata_release_id");
b.ToTable("mix_metadata", (string)null);
});
modelBuilder.Entity("DeepDrftModels.Entities.ReleaseEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<string>("Artist")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("artist");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<long?>("CreatedByUserId")
.HasColumnType("bigint")
.HasColumnName("created_by_user_id");
b.Property<string>("Description")
.HasMaxLength(4000)
.HasColumnType("character varying(4000)")
.HasColumnName("description");
b.Property<string>("EntryKey")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("entry_key");
b.Property<string>("Genre")
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("genre");
b.Property<string>("ImagePath")
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("image_path");
b.Property<bool>("IsDeleted")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false)
.HasColumnName("is_deleted");
b.Property<string>("Medium")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasDefaultValue("Cut")
.HasColumnName("medium");
b.Property<DateOnly?>("ReleaseDate")
.HasColumnType("date")
.HasColumnName("release_date");
b.Property<string>("ReleaseType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasDefaultValue("Single")
.HasColumnName("release_type");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("title");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id");
b.HasIndex("EntryKey")
.IsUnique()
.HasDatabaseName("IX_release_entry_key");
b.HasIndex("IsDeleted")
.HasDatabaseName("IX_release_is_deleted");
b.HasIndex("Title", "Artist")
.IsUnique()
.HasDatabaseName("IX_release_title_artist")
.HasFilter("\"is_deleted\" = false");
b.ToTable("release", (string)null);
});
modelBuilder.Entity("DeepDrftModels.Entities.SessionMetadata", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<string>("HeroImageEntryKey")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("hero_image_entry_key");
b.Property<bool>("IsDeleted")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false)
.HasColumnName("is_deleted");
b.Property<long>("ReleaseId")
.HasColumnType("bigint")
.HasColumnName("release_id");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id");
b.HasIndex("IsDeleted")
.HasDatabaseName("IX_session_metadata_is_deleted");
b.HasIndex("ReleaseId")
.IsUnique()
.HasDatabaseName("IX_session_metadata_release_id");
b.ToTable("session_metadata", (string)null);
});
modelBuilder.Entity("DeepDrftModels.Entities.TrackEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<string>("EntryKey")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("entry_key");
b.Property<bool>("IsDeleted")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false)
.HasColumnName("is_deleted");
b.Property<string>("OriginalFileName")
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("original_file_name");
b.Property<long?>("ReleaseId")
.HasColumnType("bigint")
.HasColumnName("release_id");
b.Property<string>("TrackName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("track_name");
b.Property<int>("TrackNumber")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasDefaultValue(1)
.HasColumnName("track_number");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id");
b.HasIndex("IsDeleted")
.HasDatabaseName("IX_track_is_deleted");
b.HasIndex("ReleaseId");
b.ToTable("track", (string)null);
});
modelBuilder.Entity("DeepDrftModels.Entities.MixMetadata", b =>
{
b.HasOne("DeepDrftModels.Entities.ReleaseEntity", "Release")
.WithOne("MixMetadata")
.HasForeignKey("DeepDrftModels.Entities.MixMetadata", "ReleaseId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Release");
});
modelBuilder.Entity("DeepDrftModels.Entities.SessionMetadata", b =>
{
b.HasOne("DeepDrftModels.Entities.ReleaseEntity", "Release")
.WithOne("SessionMetadata")
.HasForeignKey("DeepDrftModels.Entities.SessionMetadata", "ReleaseId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Release");
});
modelBuilder.Entity("DeepDrftModels.Entities.TrackEntity", b =>
{
b.HasOne("DeepDrftModels.Entities.ReleaseEntity", "Release")
.WithMany("Tracks")
.HasForeignKey("ReleaseId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Release");
});
modelBuilder.Entity("DeepDrftModels.Entities.ReleaseEntity", b =>
{
b.Navigation("MixMetadata");
b.Navigation("SessionMetadata");
b.Navigation("Tracks");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,65 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DeepDrftData.Migrations
{
/// <inheritdoc />
public partial class AddReleaseEntryKey : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// 11.H — front the int PK with an app-minted GUID-string EntryKey (Phase 11 §3e). The
// scaffolded single non-null-with-"" add is hand-edited into the three-step backfill the
// spec requires (§3e.5(2)): existing rows must each get a UNIQUE, non-null key, so a shared
// "" default would collide on the unique index. Add nullable → backfill a GUID per row →
// mark non-null. No DB default is set on the final column: new rows are app-populated by
// FindOrCreateRelease (Guid.NewGuid().ToString()), exactly as tracks mint their EntryKey.
// 1) Add the column nullable so the backfill can run before the NOT NULL constraint.
migrationBuilder.AddColumn<string>(
name: "entry_key",
table: "release",
type: "character varying(100)",
maxLength: 100,
nullable: true);
// 2) Backfill a unique GUID string per existing row. gen_random_uuid()::text yields the
// lowercase 36-char hyphenated shape Guid.NewGuid().ToString() produces, so migrated and
// app-minted keys are indistinguishable. Per-row evaluation → each row gets a distinct key.
migrationBuilder.Sql(
"UPDATE \"release\" SET \"entry_key\" = gen_random_uuid()::text WHERE \"entry_key\" IS NULL;");
// 3) Now every row is populated and unique — enforce NOT NULL.
migrationBuilder.AlterColumn<string>(
name: "entry_key",
table: "release",
type: "character varying(100)",
maxLength: 100,
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(100)",
oldMaxLength: 100,
oldNullable: true);
migrationBuilder.CreateIndex(
name: "IX_release_entry_key",
table: "release",
column: "entry_key",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_release_entry_key",
table: "release");
migrationBuilder.DropColumn(
name: "entry_key",
table: "release");
}
}
}
@@ -95,6 +95,12 @@ namespace DeepDrftData.Migrations
.HasColumnType("character varying(4000)") .HasColumnType("character varying(4000)")
.HasColumnName("description"); .HasColumnName("description");
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)")
@@ -143,6 +149,10 @@ namespace DeepDrftData.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("EntryKey")
.IsUnique()
.HasDatabaseName("IX_release_entry_key");
b.HasIndex("IsDeleted") b.HasIndex("IsDeleted")
.HasDatabaseName("IX_release_is_deleted"); .HasDatabaseName("IX_release_is_deleted");
+14
View File
@@ -94,6 +94,20 @@ public class ReleaseManager : IReleaseService
} }
} }
public async Task<ResultContainer<ReleaseDto?>> GetByEntryKeyAsync(string entryKey, CancellationToken cancellationToken = default)
{
try
{
var entity = await _repository.GetByEntryKeyWithMetadataAsync(entryKey, cancellationToken);
return ResultContainer<ReleaseDto?>.CreatePassResult(
entity is null ? null : TrackConverter.Convert(entity));
}
catch (Exception e)
{
return ResultContainer<ReleaseDto?>.CreateFailResult(e.Message);
}
}
public async Task<ResultContainer<List<string>>> GetTrackEntryKeysAsync(long releaseId, CancellationToken cancellationToken = default) public async Task<ResultContainer<List<string>>> GetTrackEntryKeysAsync(long releaseId, CancellationToken cancellationToken = default)
{ {
try try
@@ -95,6 +95,16 @@ public class ReleaseRepository
.Include(r => r.MixMetadata) .Include(r => r.MixMetadata)
.FirstOrDefaultAsync(ct); .FirstOrDefaultAsync(ct);
// The public addressing read: resolve a release by its opaque EntryKey (Phase 11 §3e). Mirrors
// GetByIdWithMetadataAsync but keys on the unique entry_key column — the int PK never reaches the
// public surface. The resolved entity still carries its int Id for internal joins (track page).
public async Task<ReleaseEntity?> GetByEntryKeyWithMetadataAsync(string entryKey, CancellationToken ct)
=> await _context.Releases
.Where(r => r.EntryKey == entryKey && !r.IsDeleted)
.Include(r => r.SessionMetadata)
.Include(r => r.MixMetadata)
.FirstOrDefaultAsync(ct);
// Non-deleted track counts for a specific set of releases, for populating ReleaseDto.TrackCount on // Non-deleted track counts for a specific set of releases, for populating ReleaseDto.TrackCount on
// list reads without an N+1 fan-out. Releases with zero live tracks are absent from the dictionary. // list reads without an N+1 fan-out. Releases with zero live tracks are absent from the dictionary.
public async Task<Dictionary<long, int>> GetTrackCountsByReleaseIdsAsync( public async Task<Dictionary<long, int>> GetTrackCountsByReleaseIdsAsync(
+5
View File
@@ -19,6 +19,7 @@ public class TrackConverter : IEntityToModelConverter<TrackEntity, TrackDto>
public static ReleaseDto Convert(ReleaseEntity entity) => new() public static ReleaseDto Convert(ReleaseEntity entity) => new()
{ {
Id = entity.Id, Id = entity.Id,
EntryKey = entity.EntryKey,
CreatedAt = entity.CreatedAt, CreatedAt = entity.CreatedAt,
UpdatedAt = entity.UpdatedAt, UpdatedAt = entity.UpdatedAt,
Title = entity.Title, Title = entity.Title,
@@ -51,6 +52,10 @@ public class TrackConverter : IEntityToModelConverter<TrackEntity, TrackDto>
public static ReleaseEntity Convert(ReleaseDto dto) => new() public static ReleaseEntity Convert(ReleaseDto dto) => new()
{ {
Id = dto.Id, Id = dto.Id,
// Round-trips the public handle. On the create path (FindOrCreateRelease) the DTO carries no
// EntryKey yet, so that path overrides this with a freshly minted GUID — the same shape as the
// natural-key (Title/Artist) override there.
EntryKey = dto.EntryKey,
CreatedAt = dto.CreatedAt, CreatedAt = dto.CreatedAt,
UpdatedAt = dto.UpdatedAt, UpdatedAt = dto.UpdatedAt,
Title = dto.Title, Title = dto.Title,
+3
View File
@@ -177,6 +177,9 @@ public class TrackManager
// in releaseData so a typo upstream cannot create a release that won't be found again. // in releaseData so a typo upstream cannot create a release that won't be found again.
var entity = TrackConverter.Convert(releaseData); var entity = TrackConverter.Convert(releaseData);
entity.Id = 0; entity.Id = 0;
// Mint the public EntryKey app-side at creation — the identical call tracks make in
// TrackContentService (Phase 11 §3e.4). The incoming DTO carries no key on the create path.
entity.EntryKey = Guid.NewGuid().ToString();
entity.Title = title; entity.Title = title;
entity.Artist = artist; entity.Artist = artist;
+4
View File
@@ -9,6 +9,10 @@ namespace DeepDrftModels.DTOs;
// TrackConverter assigns every field on the round-trip, so an empty default is never observable. // TrackConverter assigns every field on the round-trip, so an empty default is never observable.
public class ReleaseDto : BaseModel public class ReleaseDto : BaseModel
{ {
// The release's public handle — the GUID string the public site/API address it by (Phase 11 §3e).
// Mirrors TrackDto's EntryKey. No `required` (BaseModel/Manager<> need new()); TrackConverter
// assigns it on every round-trip, so an empty default is never observable.
public string EntryKey { get; set; } = string.Empty;
public string Title { get; set; } = string.Empty; public string Title { get; set; } = string.Empty;
public string Artist { get; set; } = string.Empty; public string Artist { get; set; } = string.Empty;
public string? Genre { get; set; } public string? Genre { get; set; }
+6
View File
@@ -12,6 +12,12 @@ namespace DeepDrftModels.Entities;
// explicitly to satisfy the generic constraints on Repository<>/Manager<>/etc. // explicitly to satisfy the generic constraints on Repository<>/Manager<>/etc.
public class ReleaseEntity : BaseEntity, IEntity public class ReleaseEntity : BaseEntity, IEntity
{ {
// App-minted GUID-string public handle, mirroring TrackEntity.EntryKey exactly: required string,
// entry_key column, unique index, born app-side as Guid.NewGuid().ToString() at release creation
// (the FindOrCreateRelease path). Fronts the int Id (the DB-only PK, unused by the app) so the
// public site/API address releases by an opaque key, never the transparent sequential id
// (Phase 11 §3e). Unlike a track's EntryKey it has no vault job — it is purely an identifier.
public required string EntryKey { get; set; }
public required string Title { get; set; } public required string Title { get; set; }
public required string Artist { get; set; } public required string Artist { get; set; }
public string? Genre { get; set; } public string? Genre { get; set; }
@@ -69,9 +69,9 @@ public class ReleaseClient
: ApiResult<PagedResult<ReleaseDto>>.CreateFailResult("Failed to deserialize response"); : ApiResult<PagedResult<ReleaseDto>>.CreateFailResult("Failed to deserialize response");
} }
public async Task<ApiResult<ReleaseDto>> GetById(long id) public async Task<ApiResult<ReleaseDto>> GetByEntryKey(string entryKey)
{ {
var response = await _http.GetAsync($"api/release/{id}"); var response = await _http.GetAsync($"api/release/{Uri.EscapeDataString(entryKey)}");
if (!response.IsSuccessStatusCode) if (!response.IsSuccessStatusCode)
return ApiResult<ReleaseDto>.CreateFailResult($"HTTP {(int)response.StatusCode}"); return ApiResult<ReleaseDto>.CreateFailResult($"HTTP {(int)response.StatusCode}");
@@ -85,13 +85,13 @@ public class ReleaseClient
} }
/// <summary> /// <summary>
/// Fetches the high-res waveform datum for a Mix release. A 404 means no datum is stored /// Fetches the high-res waveform datum for a Mix release, addressed by its public EntryKey. A 404
/// (not yet generated, or not a Mix) — a valid state, so it returns a pass result with a /// means no datum is stored (not yet generated, or not a Mix) — a valid state, so it returns a pass
/// null value. Any other non-success status is a genuine failure. /// result with a null value. Any other non-success status is a genuine failure.
/// </summary> /// </summary>
public async Task<ApiResult<WaveformProfileDto?>> GetMixWaveform(long id) public async Task<ApiResult<WaveformProfileDto?>> GetMixWaveform(string entryKey)
{ {
var response = await _http.GetAsync($"api/release/{id}/mix/waveform"); var response = await _http.GetAsync($"api/release/{Uri.EscapeDataString(entryKey)}/mix/waveform");
if (response.StatusCode == System.Net.HttpStatusCode.NotFound) if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
return ApiResult<WaveformProfileDto?>.CreatePassResult(null); return ApiResult<WaveformProfileDto?>.CreatePassResult(null);
+10 -8
View File
@@ -14,17 +14,19 @@ namespace DeepDrftPublic.Client.Common;
public static class ReleaseRoutes public static class ReleaseRoutes
{ {
/// <summary> /// <summary>
/// The dedicated detail route for a release: <c>/cuts/{id}</c>, <c>/sessions/{id}</c>, or /// The dedicated detail route for a release: <c>/cuts/{entryKey}</c>, <c>/sessions/{entryKey}</c>,
/// <c>/mixes/{id}</c>. Cut is the default arm so a new medium without an entry here surfaces a /// or <c>/mixes/{entryKey}</c>. The route carries the release's opaque public EntryKey (Phase 11
/// build-visible gap rather than a silent fallthrough — extend the switch when a fourth medium lands. /// §3e) — never the transparent int PK. Cut is the default arm so a new medium without an entry
/// here surfaces a build-visible gap rather than a silent fallthrough — extend the switch when a
/// fourth medium lands.
/// </summary> /// </summary>
public static string DetailHref(long id, ReleaseMedium medium) => medium switch public static string DetailHref(string entryKey, ReleaseMedium medium) => medium switch
{ {
ReleaseMedium.Session => $"/sessions/{id}", ReleaseMedium.Session => $"/sessions/{entryKey}",
ReleaseMedium.Mix => $"/mixes/{id}", ReleaseMedium.Mix => $"/mixes/{entryKey}",
_ => $"/cuts/{id}", _ => $"/cuts/{entryKey}",
}; };
/// <summary>Convenience overload for call sites holding a <see cref="ReleaseDto"/>.</summary> /// <summary>Convenience overload for call sites holding a <see cref="ReleaseDto"/>.</summary>
public static string DetailHref(ReleaseDto release) => DetailHref(release.Id, release.Medium); public static string DetailHref(ReleaseDto release) => DetailHref(release.EntryKey, release.Medium);
} }
@@ -3,7 +3,7 @@
@* Full-page scrolling Mix waveform background (Phase 9, 8.K). A windowed slice of the mix's loudness @* Full-page scrolling Mix waveform background (Phase 9, 8.K). A windowed slice of the mix's loudness
datum scrolls bottom-to-top, coupled to playback; a zoom slider controls the visible time-span (and datum scrolls bottom-to-top, coupled to playback; a zoom slider controls the visible time-span (and
so the apparent scroll speed, Guitar-Hero style). Strictly read-only: it self-fetches its datum from so the apparent scroll speed, Guitar-Hero style). Strictly read-only: it self-fetches its datum from
ReleaseId, takes playback as one-way input only, and never seeks or writes back. The rAF loop and all ReleaseEntryKey, takes playback as one-way input only, and never seeks or writes back. The rAF loop and all
scroll/zoom/compositing math live in the MixVisualizer.ts interop module; this component is a thin scroll/zoom/compositing math live in the MixVisualizer.ts interop module; this component is a thin
bridge that feeds it datum + playback + zoom + theme. Deliberately NOT the player-bar peak-bar idiom. *@ bridge that feeds it datum + playback + zoom + theme. Deliberately NOT the player-bar peak-bar idiom. *@
@@ -9,7 +9,7 @@ namespace DeepDrftPublic.Client.Controls;
/// <summary> /// <summary>
/// Full-page scrolling Mix waveform background. Standalone and reusable: give it a /// Full-page scrolling Mix waveform background. Standalone and reusable: give it a
/// <see cref="ReleaseId"/> and it fetches its own loudness datum. The rendering itself — a windowed, /// <see cref="ReleaseEntryKey"/> and it fetches its own loudness datum. The rendering itself — a windowed,
/// bottom-to-top, playback-coupled scroll with a glassy theme-aware gradient — lives in the /// bottom-to-top, playback-coupled scroll with a glassy theme-aware gradient — lives in the
/// MixVisualizer.ts interop module; this component is the bridge that feeds it datum, playback /// MixVisualizer.ts interop module; this component is the bridge that feeds it datum, playback
/// position, zoom, and theme, and owns the module lifecycle. /// position, zoom, and theme, and owns the module lifecycle.
@@ -36,8 +36,8 @@ public partial class MixWaveformVisualizer : ComponentBase, IAsyncDisposable
// us, and OnAfterRender pushes fresh palette colours into the module. // us, and OnAfterRender pushes fresh palette colours into the module.
[CascadingParameter] public DarkModeSettings? DarkMode { get; set; } [CascadingParameter] public DarkModeSettings? DarkMode { get; set; }
/// <summary>The Mix release whose waveform datum to fetch and render.</summary> /// <summary>The opaque public EntryKey of the Mix release whose waveform datum to fetch and render.</summary>
[Parameter] public required long ReleaseId { get; set; } [Parameter] public required string ReleaseEntryKey { get; set; }
/// <summary> /// <summary>
/// The id of this mix's playable track. Used to gate the cascaded player as the live source: we /// The id of this mix's playable track. Used to gate the cascaded player as the live source: we
@@ -75,7 +75,7 @@ public partial class MixWaveformVisualizer : ComponentBase, IAsyncDisposable
private IStreamingPlayerService? _subscribedService; private IStreamingPlayerService? _subscribedService;
private WaveformProfileDto? _profile; private WaveformProfileDto? _profile;
private long? _loadedReleaseId; private string? _loadedReleaseKey;
// Whether we are subscribed to the shared control state's Changed event. The controls row (a // Whether we are subscribed to the shared control state's Changed event. The controls row (a
// sibling component) mutates ControlState and raises Changed; we push the affected uniforms. // sibling component) mutates ControlState and raises Changed; we push the affected uniforms.
@@ -114,21 +114,21 @@ public partial class MixWaveformVisualizer : ComponentBase, IAsyncDisposable
_subscribedService.StateChanged -= OnPlayerStateChanged; _subscribedService.StateChanged -= OnPlayerStateChanged;
PlayerService.StateChanged += OnPlayerStateChanged; PlayerService.StateChanged += OnPlayerStateChanged;
_subscribedService = PlayerService; _subscribedService = PlayerService;
DebugLog($"subscribed to player StateChanged. ReleaseId={ReleaseId}, TrackId={TrackId?.ToString() ?? "null"}."); DebugLog($"subscribed to player StateChanged. ReleaseEntryKey={ReleaseEntryKey}, TrackId={TrackId?.ToString() ?? "null"}.");
} }
else if (PlayerService is null) else if (PlayerService is null)
{ {
DebugLog($"NO player cascade — playback will never couple. ReleaseId={ReleaseId}, TrackId={TrackId?.ToString() ?? "null"}."); DebugLog($"NO player cascade — playback will never couple. ReleaseEntryKey={ReleaseEntryKey}, TrackId={TrackId?.ToString() ?? "null"}.");
} }
// ReleaseId is the only fetch input; fetch once per id. Position/zoom/theme changes re-render // ReleaseEntryKey is the only fetch input; fetch once per key. Position/zoom/theme changes
// but must not refetch, and a release with no datum must not refetch either — so the guard // re-render but must not refetch, and a release with no datum must not refetch either — so the
// keys on the fetched id, not on whether a profile came back. // guard keys on the fetched key, not on whether a profile came back.
if (_loadedReleaseId == ReleaseId) return; if (_loadedReleaseKey == ReleaseEntryKey) return;
_loadedReleaseId = ReleaseId; _loadedReleaseKey = ReleaseEntryKey;
DebugLog($"fetching mix waveform datum for ReleaseId={ReleaseId}…"); DebugLog($"fetching mix waveform datum for ReleaseEntryKey={ReleaseEntryKey}…");
var result = await ReleaseData.GetMixWaveform(ReleaseId); var result = await ReleaseData.GetMixWaveform(ReleaseEntryKey);
if (result is { Success: true, Value: { } profile } && profile.BucketCount > 0 && profile.Data.Length > 0) if (result is { Success: true, Value: { } profile } && profile.BucketCount > 0 && profile.Data.Length > 0)
{ {
_profile = profile; _profile = profile;
@@ -140,7 +140,7 @@ public partial class MixWaveformVisualizer : ComponentBase, IAsyncDisposable
// renders its content over a plain background. // renders its content over a plain background.
_profile = null; _profile = null;
DebugLog(result.Success DebugLog(result.Success
? $"datum fetch returned EMPTY/absent (no stored datum for ReleaseId={ReleaseId}) — backdrop stays blank." ? $"datum fetch returned EMPTY/absent (no stored datum for ReleaseEntryKey={ReleaseEntryKey}) — backdrop stays blank."
: $"datum fetch FAILED ({result.GetMessage() ?? "unknown error"}) — backdrop stays blank."); : $"datum fetch FAILED ({result.GetMessage() ?? "unknown error"}) — backdrop stays blank.");
} }
@@ -5,7 +5,7 @@
the parent supplies it one of two ways: the parent supplies it one of two ways:
- DetailRoute (the simple default): every card links /{DetailRoute}/{id} (Sessions, Mixes). - DetailRoute (the simple default): every card links /{DetailRoute}/{id} (Sessions, Mixes).
- HrefResolver (per-card): each card links HrefResolver(release), so Archive routes each card by - HrefResolver (per-card): each card links HrefResolver(release), so Archive routes each card by
its own medium through the one ReleaseRoutes table, and Cuts routes to /cuts/{id}. its own medium through the one ReleaseRoutes table, and Cuts routes to /cuts/{entryKey}.
HrefResolver wins when both are supplied. The card subtitle defaults to the artist; SubtitleResolver HrefResolver wins when both are supplied. The card subtitle defaults to the artist; SubtitleResolver
overrides it (Cuts shows a track count instead). Fully controlled by the parent: loading and item overrides it (Cuts shows a track count instead). Fully controlled by the parent: loading and item
state are passed in. *@ state are passed in. *@
@@ -82,7 +82,7 @@
/// <summary> /// <summary>
/// Per-card href resolver. When supplied, a card links to its result instead of the /// Per-card href resolver. When supplied, a card links to its result instead of the
/// <see cref="DetailRoute"/>-based href, letting Archive route each card by its own medium and /// <see cref="DetailRoute"/>-based href, letting Archive route each card by its own medium and
/// Cuts route to /cuts/{id} (both via <c>ReleaseRoutes.DetailHref</c>). /// Cuts route to /cuts/{entryKey} (both via <c>ReleaseRoutes.DetailHref</c>).
/// </summary> /// </summary>
[Parameter] public Func<DeepDrftModels.DTOs.ReleaseDto, string>? HrefResolver { get; set; } [Parameter] public Func<DeepDrftModels.DTOs.ReleaseDto, string>? HrefResolver { get; set; }
@@ -95,5 +95,5 @@
[Parameter] public string EmptyMessage { get; set; } = "Nothing here yet"; [Parameter] public string EmptyMessage { get; set; } = "Nothing here yet";
private string CardHref(DeepDrftModels.DTOs.ReleaseDto release) private string CardHref(DeepDrftModels.DTOs.ReleaseDto release)
=> HrefResolver?.Invoke(release) ?? $"/{DetailRoute}/{release.Id}"; => HrefResolver?.Invoke(release) ?? $"/{DetailRoute}/{release.EntryKey}";
} }
@@ -8,7 +8,7 @@ namespace DeepDrftPublic.Client.Controls;
/// <summary> /// <summary>
/// Share affordance with two modes from one source of clipboard/popover-chrome logic /// Share affordance with two modes from one source of clipboard/popover-chrome logic
/// (Phase 11 §3b). Track mode (<see cref="EntryKey"/> set) offers a canonical-link copy plus an /// (Phase 11 §3b). Track mode (<see cref="EntryKey"/> set) offers a canonical-link copy plus an
/// optional iframe embed snippet. Release mode (<see cref="ReleaseId"/> set) is copy-link-only — /// optional iframe embed snippet. Release mode (<see cref="ReleaseEntryKey"/> set) is copy-link-only —
/// it copies the absolute form of the release's canonical detail URL and hides the embed /// it copies the absolute form of the release's canonical detail URL and hides the embed
/// affordance, since a release page is not a single-track embed. Clipboard writes go through /// affordance, since a release page is not a single-track embed. Clipboard writes go through
/// navigator.clipboard; each copy shows a transient "Copied!" confirmation that resets after a /// navigator.clipboard; each copy shows a transient "Copied!" confirmation that resets after a
@@ -19,8 +19,8 @@ public partial class SharePopover : ComponentBase, IDisposable
/// <summary>Track mode: the vault entry key of the track to share. Mutually exclusive with the release target.</summary> /// <summary>Track mode: the vault entry key of the track to share. Mutually exclusive with the release target.</summary>
[Parameter] public string? EntryKey { get; set; } [Parameter] public string? EntryKey { get; set; }
/// <summary>Release mode: the release id to share. When set (with <see cref="ReleaseMedium"/>), the popover shares the release detail URL and omits the embed option.</summary> /// <summary>Release mode: the release's opaque public EntryKey to share. When set (with <see cref="ReleaseMedium"/>), the popover shares the release detail URL and omits the embed option.</summary>
[Parameter] public long? ReleaseId { get; set; } [Parameter] public string? ReleaseEntryKey { get; set; }
/// <summary>Release mode: the medium of the release, used to resolve its canonical detail route.</summary> /// <summary>Release mode: the medium of the release, used to resolve its canonical detail route.</summary>
[Parameter] public ReleaseMedium ReleaseMedium { get; set; } [Parameter] public ReleaseMedium ReleaseMedium { get; set; }
@@ -28,7 +28,7 @@ public partial class SharePopover : ComponentBase, IDisposable
[Inject] public required NavigationManager Navigation { get; set; } [Inject] public required NavigationManager Navigation { get; set; }
[Inject] public required IJSRuntime JS { get; set; } [Inject] public required IJSRuntime JS { get; set; }
private bool IsReleaseMode => ReleaseId is not null; private bool IsReleaseMode => ReleaseEntryKey is not null;
private bool _open; private bool _open;
private bool _embed; private bool _embed;
@@ -51,7 +51,7 @@ public partial class SharePopover : ComponentBase, IDisposable
// route (which carries a leading slash) and composes it against BaseUri (which carries a // route (which carries a leading slash) and composes it against BaseUri (which carries a
// trailing slash) — trim one to avoid a doubled separator. // trailing slash) — trim one to avoid a doubled separator.
private string LinkUrl => IsReleaseMode private string LinkUrl => IsReleaseMode
? $"{Navigation.BaseUri.TrimEnd('/')}{ReleaseRoutes.DetailHref(ReleaseId!.Value, ReleaseMedium)}" ? $"{Navigation.BaseUri.TrimEnd('/')}{ReleaseRoutes.DetailHref(ReleaseEntryKey!, ReleaseMedium)}"
: TrackUrl; : TrackUrl;
private string TrackUrl => $"{Navigation.BaseUri}track/{EntryKey}"; private string TrackUrl => $"{Navigation.BaseUri}track/{EntryKey}";
+1 -1
View File
@@ -3,7 +3,7 @@
<PageTitle>DeepDrft Cuts</PageTitle> <PageTitle>DeepDrft Cuts</PageTitle>
@* The shared release-card grid; each card routes to /cuts/{id} via the one ReleaseRoutes table. @* The shared release-card grid; each card routes to /cuts/{entryKey} via the one ReleaseRoutes table.
Cuts show a track count where other media show the artist, supplied via SubtitleResolver. *@ Cuts show a track count where other media show the artist, supplied via SubtitleResolver. *@
<ReleaseGallery Releases="@_albums" <ReleaseGallery Releases="@_albums"
Loading="@_loading" Loading="@_loading"
@@ -11,7 +11,7 @@ namespace DeepDrftPublic.Client.Pages;
/// Medium-filtered release gallery. Routed at <c>/cuts</c> (Cut releases) and parameterized by /// Medium-filtered release gallery. Routed at <c>/cuts</c> (Cut releases) and parameterized by
/// <see cref="Medium"/> so the same component can back any medium's card grid without a fork. /// <see cref="Medium"/> so the same component can back any medium's card grid without a fork.
/// Cards open the release's dedicated detail page via <see cref="ReleaseRoutes.DetailHref(ReleaseDto)"/> /// Cards open the release's dedicated detail page via <see cref="ReleaseRoutes.DetailHref(ReleaseDto)"/>
/// (a Cut routes to <c>/cuts/{id}</c>), the single source for medium→route resolution (Phase 11 §2). /// (a Cut routes to <c>/cuts/{entryKey}</c>), the single source for medium→route resolution (Phase 11 §2).
/// </summary> /// </summary>
public partial class AlbumsView : ComponentBase, IDisposable public partial class AlbumsView : ComponentBase, IDisposable
{ {
+3 -3
View File
@@ -1,4 +1,4 @@
@page "/cuts/{Id:long}" @page "/cuts/{EntryKey}"
@using DeepDrftModels.DTOs @using DeepDrftModels.DTOs
@using DeepDrftPublic.Client.Controls @using DeepDrftPublic.Client.Controls
@using DeepDrftPublic.Client.Services @using DeepDrftPublic.Client.Services
@@ -79,8 +79,8 @@ else
Play Play
</MudButton> </MudButton>
@* Release-mode share: copies the canonical /cuts/{id} URL, not a single track (§3b). *@ @* Release-mode share: copies the canonical /cuts/{entryKey} URL, not a single track (§3b). *@
<SharePopover ReleaseId="@release.Id" ReleaseMedium="@release.Medium" /> <SharePopover ReleaseEntryKey="@release.EntryKey" ReleaseMedium="@release.Medium" />
</div> </div>
</div> </div>
+12 -12
View File
@@ -5,7 +5,7 @@ using Microsoft.AspNetCore.Components;
namespace DeepDrftPublic.Client.Pages; namespace DeepDrftPublic.Client.Pages;
/// <summary> /// <summary>
/// Load + prerender-bridge logic for the Cut album-detail page (/cuts/{id}). Mirrors /// Load + prerender-bridge logic for the Cut album-detail page (/cuts/{entryKey}). Mirrors
/// <see cref="ReleaseDetailBase"/>'s discipline (id-addressed load in OnParametersSetAsync, /// <see cref="ReleaseDetailBase"/>'s discipline (id-addressed load in OnParametersSetAsync,
/// PersistentComponentState bridge guarded on id) but carries the multi-track payload (release + /// PersistentComponentState bridge guarded on id) but carries the multi-track payload (release +
/// ordered track list) the Cut page needs. Kept separate from the single-track base so neither /// ordered track list) the Cut page needs. Kept separate from the single-track base so neither
@@ -15,16 +15,16 @@ public abstract class CutDetailBase : ComponentBase, IDisposable
{ {
private const string PersistKey = "cut-detail"; private const string PersistKey = "cut-detail";
[Parameter] public long Id { get; set; } [Parameter] public string EntryKey { get; set; } = string.Empty;
[Inject] public required CutDetailViewModel ViewModel { get; set; } [Inject] public required CutDetailViewModel ViewModel { get; set; }
[Inject] public required PersistentComponentState PersistentState { get; set; } [Inject] public required PersistentComponentState PersistentState { get; set; }
private PersistingComponentStateSubscription _persistingSubscription; private PersistingComponentStateSubscription _persistingSubscription;
// The release id the ViewModel currently holds — tracks param-only navigations (e.g. // The release EntryKey the ViewModel currently holds — tracks param-only navigations (e.g.
// /cuts/5 -> /cuts/8) which reuse this component instance and fire OnParametersSet without // /cuts/{a} -> /cuts/{b}) which reuse this component instance and fire OnParametersSet without
// re-running OnInitialized. Without it the page would keep the prior album's tracks. // re-running OnInitialized. Without it the page would keep the prior album's tracks.
private long _loadedId; private string? _loadedKey;
private bool _loaded; private bool _loaded;
protected override void OnInitialized() protected override void OnInitialized()
@@ -32,25 +32,25 @@ public abstract class CutDetailBase : ComponentBase, IDisposable
protected override async Task OnParametersSetAsync() protected override async Task OnParametersSetAsync()
{ {
if (_loaded && _loadedId == Id) return; if (_loaded && _loadedKey == EntryKey) return;
// Capture the id synchronously before any await so a re-entrant call (rapid navigation or a // Capture the key synchronously before any await so a re-entrant call (rapid navigation or a
// re-render that changes Id while Load is in flight) sees the correct guard state. // re-render that changes EntryKey while Load is in flight) sees the correct guard state.
_loadedId = Id; _loadedKey = EntryKey;
_loaded = true; _loaded = true;
// The bridged payload carries the release and its ordered tracks so the interactive pass // The bridged payload carries the release and its ordered tracks so the interactive pass
// renders identically without a second round-trip. Guard on the id: a payload for a different // renders identically without a second round-trip. Guard on the key: a payload for a different
// release must not seed this page (stale-bridge bleed across navigation). // release must not seed this page (stale-bridge bleed across navigation).
if (PersistentState.TryTakeFromJson<BridgedCut>(PersistKey, out var restored) if (PersistentState.TryTakeFromJson<BridgedCut>(PersistKey, out var restored)
&& restored?.Release is not null && restored?.Release is not null
&& restored.Release.Id == Id) && restored.Release.EntryKey == EntryKey)
{ {
ViewModel.Restore(restored.Release, restored.Tracks); ViewModel.Restore(restored.Release, restored.Tracks);
} }
else else
{ {
await ViewModel.Load(Id); await ViewModel.Load(EntryKey);
} }
} }
+2 -2
View File
@@ -1,4 +1,4 @@
@page "/mixes/{Id:long}" @page "/mixes/{EntryKey}"
@using DeepDrftPublic.Client.Controls @using DeepDrftPublic.Client.Controls
@inherits ReleaseDetailBase @inherits ReleaseDetailBase
@@ -37,7 +37,7 @@ else
@* Full-page waveform sits behind the scaffold content. The scaffold's container is positioned @* Full-page waveform sits behind the scaffold content. The scaffold's container is positioned
above it via the mix-detail-foreground stacking context. TrackId lets the visualizer couple to above it via the mix-detail-foreground stacking context. TrackId lets the visualizer couple to
playback only when the player is on this mix's track. *@ playback only when the player is on this mix's track. *@
<MixWaveformVisualizer ReleaseId="@release.Id" TrackId="@ViewModel.Track?.Id" /> <MixWaveformVisualizer ReleaseEntryKey="@release.EntryKey" TrackId="@ViewModel.Track?.Id" />
<div class="mix-detail-foreground"> <div class="mix-detail-foreground">
<MudContainer MaxWidth="MaxWidth.Large" Class="mix-detail-container"> <MudContainer MaxWidth="MaxWidth.Large" Class="mix-detail-container">
@@ -13,17 +13,17 @@ namespace DeepDrftPublic.Client.Pages;
/// </summary> /// </summary>
public abstract class ReleaseDetailBase : ComponentBase, IDisposable public abstract class ReleaseDetailBase : ComponentBase, IDisposable
{ {
[Parameter] public long Id { get; set; } [Parameter] public string EntryKey { get; set; } = string.Empty;
[Inject] public required ReleaseDetailViewModel ViewModel { get; set; } [Inject] public required ReleaseDetailViewModel ViewModel { get; set; }
[Inject] public required PersistentComponentState PersistentState { get; set; } [Inject] public required PersistentComponentState PersistentState { get; set; }
private PersistingComponentStateSubscription _persistingSubscription; private PersistingComponentStateSubscription _persistingSubscription;
// The release id the ViewModel currently holds. Tracks param-only navigations (e.g. // The release EntryKey the ViewModel currently holds. Tracks param-only navigations (e.g.
// /mixes/5 -> /mixes/8) which reuse this component instance and fire OnParametersSet // /mixes/{a} -> /mixes/{b}) which reuse this component instance and fire OnParametersSet
// without re-running OnInitialized — without this, the page would keep the prior // without re-running OnInitialized — without this, the page would keep the prior
// release's track and Play would stream the wrong audio. // release's track and Play would stream the wrong audio.
private long _loadedId; private string? _loadedKey;
private bool _loaded; private bool _loaded;
// Distinct keys per medium so a Session restore never lands on a Mix page. // Distinct keys per medium so a Session restore never lands on a Mix page.
@@ -34,31 +34,31 @@ public abstract class ReleaseDetailBase : ComponentBase, IDisposable
protected override async Task OnParametersSetAsync() protected override async Task OnParametersSetAsync()
{ {
// Re-run whenever the route id changes. Component instances are reused across // Re-run whenever the route key changes. Component instances are reused across
// same-template navigations, so the load decision must live here, not in // same-template navigations, so the load decision must live here, not in
// OnInitialized (which fires once per instance). // OnInitialized (which fires once per instance).
if (_loaded && _loadedId == Id) return; if (_loaded && _loadedKey == EntryKey) return;
// Capture the id synchronously before any await so that a re-entrant call // Capture the key synchronously before any await so that a re-entrant call
// (rapid navigation or a re-render that changes Id while Load is in flight) // (rapid navigation or a re-render that changes EntryKey while Load is in flight)
// sees the correct guard state. Without this, a second OnParametersSetAsync // sees the correct guard state. Without this, a second OnParametersSetAsync
// for the same Id would bypass the guard above and start a second Load, // for the same key would bypass the guard above and start a second Load,
// causing two ViewModel.Load calls to race on the single scoped instance. // causing two ViewModel.Load calls to race on the single scoped instance.
_loadedId = Id; _loadedKey = EntryKey;
_loaded = true; _loaded = true;
// The bridged payload carries both the release and its resolved track so the interactive // The bridged payload carries both the release and its resolved track so the interactive
// pass renders identically without a second round-trip. Guard on the id: a payload for a // pass renders identically without a second round-trip. Guard on the key: a payload for a
// different release must not seed this page (stale-bridge bleed across navigation). // different release must not seed this page (stale-bridge bleed across navigation).
if (PersistentState.TryTakeFromJson<BridgedDetail>(PersistKey, out var restored) if (PersistentState.TryTakeFromJson<BridgedDetail>(PersistKey, out var restored)
&& restored?.Release is not null && restored?.Release is not null
&& restored.Release.Id == Id) && restored.Release.EntryKey == EntryKey)
{ {
ViewModel.Restore(restored.Release, restored.Track); ViewModel.Restore(restored.Release, restored.Track);
} }
else else
{ {
await ViewModel.Load(Id); await ViewModel.Load(EntryKey);
} }
} }
@@ -1,4 +1,4 @@
@page "/sessions/{Id:long}" @page "/sessions/{EntryKey}"
@using DeepDrftModels.DTOs @using DeepDrftModels.DTOs
@using DeepDrftPublic.Client.Controls @using DeepDrftPublic.Client.Controls
@using DeepDrftPublic.Client.Services @using DeepDrftPublic.Client.Services
+11 -10
View File
@@ -1,25 +1,26 @@
@page "/tracks/{Id:long}" @page "/tracks/{EntryKey}"
@using DeepDrftPublic.Client.Services @using DeepDrftPublic.Client.Services
@inject IReleaseDataService ReleaseData @inject IReleaseDataService ReleaseData
@inject NavigationManager Navigation @inject NavigationManager Navigation
@* Addressable deep-link fallback for a bare release id (Phase 11 §2, shape iii). Unlike the player @* Addressable deep-link fallback for a bare release EntryKey (Phase 11 §2, shape iii). Unlike the
bar / Archive / AlbumsView call sites, an external /tracks/{id} link carries only the id, so this player bar / Archive / AlbumsView call sites, an external /tracks/{entryKey} link carries only the
page fetches the release to discover its medium, then forwards through the same ReleaseRoutes key, so this page fetches the release to discover its medium, then forwards through the same
resolver — one medium→route table, no second source. replace:true keeps the router out of history ReleaseRoutes resolver — one medium→route table, no second source. replace:true keeps the router
so Back skips this hop. Capture Id before the await per the InteractiveAuto route-param convention. *@ out of history so Back skips this hop. Capture EntryKey before the await per the InteractiveAuto
route-param convention. *@
@code { @code {
[Parameter] public long Id { get; set; } [Parameter] public string EntryKey { get; set; } = string.Empty;
protected override async Task OnParametersSetAsync() protected override async Task OnParametersSetAsync()
{ {
var id = Id; var entryKey = EntryKey;
var result = await ReleaseData.GetById(id); var result = await ReleaseData.GetByEntryKey(entryKey);
var target = result is { Success: true, Value: { } release } var target = result is { Success: true, Value: { } release }
? ReleaseRoutes.DetailHref(release) ? ReleaseRoutes.DetailHref(release)
: "/cuts"; // Unknown id: fall back to the Cuts gallery rather than 404. : "/cuts"; // Unknown key: fall back to the Cuts gallery rather than 404.
Navigation.NavigateTo(target, forceLoad: false, replace: true); Navigation.NavigateTo(target, forceLoad: false, replace: true);
} }
@@ -22,12 +22,13 @@ public interface IReleaseDataService
string? search = null, string? search = null,
string? genre = null); string? genre = null);
/// <summary>Single release with both metadata satellites (nulls for non-matching media).</summary> /// <summary>Single release resolved by its opaque public EntryKey, with both metadata satellites (nulls for non-matching media).</summary>
Task<ApiResult<ReleaseDto>> GetById(long id); Task<ApiResult<ReleaseDto>> GetByEntryKey(string entryKey);
/// <summary> /// <summary>
/// The Mix waveform datum. Success with a value when present; success with a null value when /// The Mix waveform datum for a release addressed by its public EntryKey. Success with a value
/// no datum is stored (a valid state, not a failure); failure on any other transport error. /// when present; success with a null value when no datum is stored (a valid state, not a failure);
/// failure on any other transport error.
/// </summary> /// </summary>
Task<ApiResult<WaveformProfileDto?>> GetMixWaveform(long id); Task<ApiResult<WaveformProfileDto?>> GetMixWaveform(string entryKey);
} }
@@ -29,9 +29,9 @@ public class ReleaseClientDataService : IReleaseDataService
string? genre = null) string? genre = null)
=> _releaseClient.GetPaged(medium, page, pageSize, sortColumn, sortDescending, search, genre); => _releaseClient.GetPaged(medium, page, pageSize, sortColumn, sortDescending, search, genre);
public Task<ApiResult<ReleaseDto>> GetById(long id) public Task<ApiResult<ReleaseDto>> GetByEntryKey(string entryKey)
=> _releaseClient.GetById(id); => _releaseClient.GetByEntryKey(entryKey);
public Task<ApiResult<WaveformProfileDto?>> GetMixWaveform(long id) public Task<ApiResult<WaveformProfileDto?>> GetMixWaveform(string entryKey)
=> _releaseClient.GetMixWaveform(id); => _releaseClient.GetMixWaveform(entryKey);
} }
@@ -4,7 +4,7 @@ using DeepDrftPublic.Client.Services;
namespace DeepDrftPublic.Client.ViewModels; namespace DeepDrftPublic.Client.ViewModels;
/// <summary> /// <summary>
/// State for the Cut album-detail page (/cuts/{id}). Unlike <see cref="ReleaseDetailViewModel"/> /// State for the Cut album-detail page (/cuts/{entryKey}). Unlike <see cref="ReleaseDetailViewModel"/>
/// (which resolves a single playable track for Session/Mix), a Cut is multi-track: it loads the /// (which resolves a single playable track for Session/Mix), a Cut is multi-track: it loads the
/// release and the full ordered track list for that release. The list is fetched through the /// release and the full ordered track list for that release. The list is fetched through the
/// existing releaseId-filtered track page sorted by TrackNumber — the explicit 1-based ordinal /// existing releaseId-filtered track page sorted by TrackNumber — the explicit 1-based ordinal
@@ -40,7 +40,7 @@ public class CutDetailViewModel
IsLoading = false; IsLoading = false;
} }
public async Task Load(long releaseId) public async Task Load(string entryKey)
{ {
IsLoading = true; IsLoading = true;
NotFound = false; NotFound = false;
@@ -49,7 +49,7 @@ public class CutDetailViewModel
try try
{ {
var releaseResult = await _releaseData.GetById(releaseId); var releaseResult = await _releaseData.GetByEntryKey(entryKey);
if (releaseResult is not { Success: true, Value: { } release }) if (releaseResult is not { Success: true, Value: { } release })
{ {
NotFound = true; NotFound = true;
@@ -59,9 +59,11 @@ public class CutDetailViewModel
Release = release; Release = release;
// The album's tracks via the releaseId-filtered page — an exact join, not a title string // The album's tracks via the releaseId-filtered page — an exact join, not a title string
// (which collides across same-titled releases and breaks on rename). Sorted by TrackNumber // (which collides across same-titled releases and breaks on rename). The public page
// so rows render in saved order. A Cut with no streamable tracks simply leaves the list // addresses the release by EntryKey; the track→release join stays on the internal int FK
// empty (the page renders the header with no rows). // (Phase 11 §3e), so use the resolved release.Id here. Sorted by TrackNumber so rows render
// in saved order. A Cut with no streamable tracks simply leaves the list empty (the page
// renders the header with no rows).
var trackResult = await _trackData.GetPage( var trackResult = await _trackData.GetPage(
pageNumber: 1, pageNumber: 1,
pageSize: AlbumPageSize, pageSize: AlbumPageSize,
@@ -35,7 +35,7 @@ public class ReleaseDetailViewModel
IsLoading = false; IsLoading = false;
} }
public async Task Load(long releaseId) public async Task Load(string entryKey)
{ {
IsLoading = true; IsLoading = true;
NotFound = false; NotFound = false;
@@ -44,7 +44,7 @@ public class ReleaseDetailViewModel
try try
{ {
var releaseResult = await _releaseData.GetById(releaseId); var releaseResult = await _releaseData.GetByEntryKey(entryKey);
if (releaseResult is not { Success: true, Value: { } release }) if (releaseResult is not { Success: true, Value: { } release })
{ {
NotFound = true; NotFound = true;
@@ -54,9 +54,11 @@ public class ReleaseDetailViewModel
Release = release; Release = release;
// Resolve the playable track via the releaseId-filtered track page — an exact join, not a // Resolve the playable track via the releaseId-filtered track page — an exact join, not a
// title string (which collides across same-titled releases and breaks on rename). Session/Mix // title string (which collides across same-titled releases and breaks on rename). The public
// releases carry a single track; take the first. A release with no streamable track simply // page addresses the release by EntryKey; the track→release join stays on the internal int
// leaves Track null (the detail page hides the play affordance). // FK (Phase 11 §3e leaves internal joins on the int PK), so use the resolved release.Id here.
// Session/Mix releases carry a single track; take the first. A release with no streamable
// track simply leaves Track null (the detail page hides the play affordance).
var trackResult = await _trackData.GetPage( var trackResult = await _trackData.GetPage(
pageNumber: 1, pageSize: 1, releaseId: release.Id); pageNumber: 1, pageSize: 1, releaseId: release.Id);
if (trackResult is { Success: true, Value: { Items: { } items } }) if (trackResult is { Success: true, Value: { Items: { } items } })
@@ -47,15 +47,15 @@ public class ReleaseProxyController : ControllerBase
return await RelayJson(query, "release list"); return await RelayJson(query, "release list");
} }
/// <summary>Proxies the Mix waveform datum. A 404 (no datum stored) passes through verbatim.</summary> /// <summary>Proxies the Mix waveform datum, addressed by the release's opaque EntryKey. A 404 (no datum stored) passes through verbatim.</summary>
[HttpGet("{id:long}/mix/waveform")] [HttpGet("{entryKey}/mix/waveform")]
public async Task<ActionResult> GetMixWaveform(long id, CancellationToken ct = default) public async Task<ActionResult> GetMixWaveform(string entryKey, CancellationToken ct = default)
=> await RelayJson($"api/release/{id}/mix/waveform", $"release {id} mix waveform", ct); => await RelayJson($"api/release/{Uri.EscapeDataString(entryKey)}/mix/waveform", $"release {entryKey} mix waveform", ct);
/// <summary>Proxies a single release. A 404 (no such release) passes through verbatim.</summary> /// <summary>Proxies a single release, addressed by its opaque EntryKey. A 404 (no such release) passes through verbatim.</summary>
[HttpGet("{id:long}")] [HttpGet("{entryKey}")]
public async Task<ActionResult> GetReleaseById(long id, CancellationToken ct = default) public async Task<ActionResult> GetReleaseByEntryKey(string entryKey, CancellationToken ct = default)
=> await RelayJson($"api/release/{id}", $"release {id}", ct); => await RelayJson($"api/release/{Uri.EscapeDataString(entryKey)}", $"release {entryKey}", ct);
// Small JSON payloads, buffered and relayed. Non-success statuses (notably 404) pass through // Small JSON payloads, buffered and relayed. Non-success statuses (notably 404) pass through
// so the client renders them as valid states rather than collapsing to a 502. // so the client renders them as valid states rather than collapsing to a 502.
+1 -1
View File
@@ -43,7 +43,7 @@ public class CutDetailTrackOrderingTests
=> new(_context, NullLogger<Repository<DeepDrftContext, TrackEntity>>.Instance); => new(_context, NullLogger<Repository<DeepDrftContext, TrackEntity>>.Instance);
private static ReleaseEntity Release(string title, string artist) private static ReleaseEntity Release(string title, string artist)
=> new() { Title = title, Artist = artist }; => new() { EntryKey = Guid.NewGuid().ToString("N"), Title = title, Artist = artist };
// A track linked to the given release with an explicit ordinal. // A track linked to the given release with an explicit ordinal.
private static TrackEntity Track(string name, int trackNumber, ReleaseEntity? release = null) private static TrackEntity Track(string name, int trackNumber, ReleaseEntity? release = null)
+13 -13
View File
@@ -122,7 +122,7 @@ public class MediumWritePathTests
var release = new ReleaseEntity var release = new ReleaseEntity
{ {
Title = "Originally a Cut", Artist = "Artist A", EntryKey = "rk-flip", Title = "Originally a Cut", Artist = "Artist A",
Medium = ReleaseMedium.Cut, ReleaseType = ReleaseType.EP, Medium = ReleaseMedium.Cut, ReleaseType = ReleaseType.EP,
}; };
var track = new TrackEntity { EntryKey = "ek-1", TrackName = "Track", Release = release }; var track = new TrackEntity { EntryKey = "ek-1", TrackName = "Track", Release = release };
@@ -151,7 +151,7 @@ public class MediumWritePathTests
{ {
var sessionWithStaleType = new ReleaseEntity var sessionWithStaleType = new ReleaseEntity
{ {
Title = "Session", Artist = "A", EntryKey = "rk-stale", Title = "Session", Artist = "A",
Medium = ReleaseMedium.Session, ReleaseType = ReleaseType.Album, Medium = ReleaseMedium.Session, ReleaseType = ReleaseType.Album,
}; };
@@ -169,7 +169,7 @@ public class MediumWritePathTests
const string prose = "A late-night set\nrecorded at the Vault."; const string prose = "A late-night set\nrecorded at the Vault.";
var entity = new ReleaseEntity var entity = new ReleaseEntity
{ {
Title = "Live at the Vault", Artist = "Artist A", EntryKey = "rk-desc", Title = "Live at the Vault", Artist = "Artist A",
Medium = ReleaseMedium.Session, Description = prose, Medium = ReleaseMedium.Session, Description = prose,
}; };
@@ -184,7 +184,7 @@ public class MediumWritePathTests
[Test] [Test]
public void Convert_NullDescription_RoundTripsAsNull() public void Convert_NullDescription_RoundTripsAsNull()
{ {
var entity = new ReleaseEntity { Title = "Studio Album", Artist = "Artist C", Description = null }; var entity = new ReleaseEntity { EntryKey = "rk-nulldesc", Title = "Studio Album", Artist = "Artist C", Description = null };
var dto = TrackConverter.Convert(entity); var dto = TrackConverter.Convert(entity);
Assert.That(dto.Description, Is.Null); Assert.That(dto.Description, Is.Null);
@@ -222,7 +222,7 @@ public class MediumWritePathTests
var repo = CreateRepository(); var repo = CreateRepository();
ITrackService manager = CreateManager(repo); ITrackService manager = CreateManager(repo);
var release = new ReleaseEntity { Title = "Studio Album", Artist = "Artist C", Medium = ReleaseMedium.Cut }; var release = new ReleaseEntity { EntryKey = "rk-editdesc", Title = "Studio Album", Artist = "Artist C", Medium = ReleaseMedium.Cut };
var track = new TrackEntity { EntryKey = "ek-1", TrackName = "Track", Release = release }; var track = new TrackEntity { EntryKey = "ek-1", TrackName = "Track", Release = release };
_context.Tracks.Add(track); _context.Tracks.Add(track);
await _context.SaveChangesAsync(); await _context.SaveChangesAsync();
@@ -242,8 +242,8 @@ public class MediumWritePathTests
[Test] [Test]
public async Task GetPagedFilteredAsync_WithReleaseId_ReturnsOnlyThatReleasesTracks() public async Task GetPagedFilteredAsync_WithReleaseId_ReturnsOnlyThatReleasesTracks()
{ {
var first = new ReleaseEntity { Title = "Untitled", Artist = "Artist A" }; var first = new ReleaseEntity { EntryKey = "rk-first", Title = "Untitled", Artist = "Artist A" };
var second = new ReleaseEntity { Title = "Untitled", Artist = "Artist B" }; var second = new ReleaseEntity { EntryKey = "rk-second", Title = "Untitled", Artist = "Artist B" };
_context.Tracks.AddRange( _context.Tracks.AddRange(
new TrackEntity { EntryKey = "a1", TrackName = "A-One", Release = first }, new TrackEntity { EntryKey = "a1", TrackName = "A-One", Release = first },
new TrackEntity { EntryKey = "a2", TrackName = "A-Two", Release = first }, new TrackEntity { EntryKey = "a2", TrackName = "A-Two", Release = first },
@@ -264,8 +264,8 @@ public class MediumWritePathTests
[Test] [Test]
public async Task GetPagedFilteredAsync_SameTitledReleases_ResolveDistinctlyById() public async Task GetPagedFilteredAsync_SameTitledReleases_ResolveDistinctlyById()
{ {
var first = new ReleaseEntity { Title = "Untitled", Artist = "Artist A" }; var first = new ReleaseEntity { EntryKey = "rk-first2", Title = "Untitled", Artist = "Artist A" };
var second = new ReleaseEntity { Title = "Untitled", Artist = "Artist B" }; var second = new ReleaseEntity { EntryKey = "rk-second2", Title = "Untitled", Artist = "Artist B" };
_context.Tracks.AddRange( _context.Tracks.AddRange(
new TrackEntity { EntryKey = "a1", TrackName = "A-One", Release = first }, new TrackEntity { EntryKey = "a1", TrackName = "A-One", Release = first },
new TrackEntity { EntryKey = "b1", TrackName = "B-One", Release = second }); new TrackEntity { EntryKey = "b1", TrackName = "B-One", Release = second });
@@ -333,7 +333,7 @@ public class MediumWritePathTests
var repo = CreateRepository(); var repo = CreateRepository();
ITrackService manager = CreateManager(repo); ITrackService manager = CreateManager(repo);
var release = new ReleaseEntity { Title = "Live at the Vault", Artist = "Artist A", Medium = ReleaseMedium.Session }; var release = new ReleaseEntity { EntryKey = "rk-peek", Title = "Live at the Vault", Artist = "Artist A", Medium = ReleaseMedium.Session };
_context.Tracks.Add(new TrackEntity { EntryKey = "ek-1", TrackName = "Track One", Release = release }); _context.Tracks.Add(new TrackEntity { EntryKey = "ek-1", TrackName = "Track One", Release = release });
await _context.SaveChangesAsync(); await _context.SaveChangesAsync();
@@ -370,7 +370,7 @@ public class MediumWritePathTests
var repo = CreateRepository(); var repo = CreateRepository();
ITrackService manager = CreateManager(repo); ITrackService manager = CreateManager(repo);
var release = new ReleaseEntity { Title = "Live at the Vault", Artist = "Artist A", Medium = ReleaseMedium.Session }; var release = new ReleaseEntity { EntryKey = "rk-cardses", Title = "Live at the Vault", Artist = "Artist A", Medium = ReleaseMedium.Session };
_context.Tracks.Add(new TrackEntity { EntryKey = "ek-1", TrackName = "Track One", Release = release }); _context.Tracks.Add(new TrackEntity { EntryKey = "ek-1", TrackName = "Track One", Release = release });
await _context.SaveChangesAsync(); await _context.SaveChangesAsync();
@@ -387,7 +387,7 @@ public class MediumWritePathTests
var repo = CreateRepository(); var repo = CreateRepository();
ITrackService manager = CreateManager(repo); ITrackService manager = CreateManager(repo);
var release = new ReleaseEntity { Title = "Sunset Set", Artist = "DJ B", Medium = ReleaseMedium.Mix }; var release = new ReleaseEntity { EntryKey = "rk-cardmix", Title = "Sunset Set", Artist = "DJ B", Medium = ReleaseMedium.Mix };
_context.Tracks.Add(new TrackEntity { EntryKey = "ek-1", TrackName = "The Set", Release = release }); _context.Tracks.Add(new TrackEntity { EntryKey = "ek-1", TrackName = "The Set", Release = release });
await _context.SaveChangesAsync(); await _context.SaveChangesAsync();
@@ -404,7 +404,7 @@ public class MediumWritePathTests
var repo = CreateRepository(); var repo = CreateRepository();
ITrackService manager = CreateManager(repo); ITrackService manager = CreateManager(repo);
var release = new ReleaseEntity { Title = "Studio Album", Artist = "Artist C", Medium = ReleaseMedium.Cut }; var release = new ReleaseEntity { EntryKey = "rk-cardcut", Title = "Studio Album", Artist = "Artist C", Medium = ReleaseMedium.Cut };
_context.Tracks.AddRange( _context.Tracks.AddRange(
new TrackEntity { EntryKey = "c1", TrackName = "One", Release = release }, new TrackEntity { EntryKey = "c1", TrackName = "One", Release = release },
new TrackEntity { EntryKey = "c2", TrackName = "Two", Release = release }, new TrackEntity { EntryKey = "c2", TrackName = "Two", Release = release },
+1
View File
@@ -46,6 +46,7 @@ public class ReleaseBrowseQueryTests
string title, string artist, ReleaseMedium medium = ReleaseMedium.Cut, string? genre = null) string title, string artist, ReleaseMedium medium = ReleaseMedium.Cut, string? genre = null)
=> new() => new()
{ {
EntryKey = Guid.NewGuid().ToString("N"),
Title = title, Title = title,
Artist = artist, Artist = artist,
Medium = medium, Medium = medium,
+19 -15
View File
@@ -6,31 +6,35 @@ namespace DeepDrftTests;
/// <summary> /// <summary>
/// The medium→detail-route table is the single source of truth for release navigation (Phase 11 /// The medium→detail-route table is the single source of truth for release navigation (Phase 11
/// §2): Archive cards, AlbumsView cards, the player-bar title, and the /tracks/{id} redirect page /// §2, §3e): Archive cards, AlbumsView cards, the player-bar title, and the /tracks/{entryKey}
/// all resolve through <see cref="ReleaseRoutes.DetailHref(long, ReleaseMedium)"/>. These tests pin /// redirect page all resolve through <see cref="ReleaseRoutes.DetailHref(string, ReleaseMedium)"/>.
/// each medium to its dedicated route and confirm the DTO overload (the call shape used everywhere /// The route now carries the release's opaque public EntryKey (a GUID string), never the int PK.
/// but the redirect page) agrees with the primitive overload (the shape the redirect page uses /// These tests pin each medium to its dedicated route and confirm the DTO overload (the call shape
/// after fetching the release by id). /// used everywhere but the redirect page) agrees with the primitive overload (the shape the redirect
/// page uses after fetching the release by EntryKey).
/// </summary> /// </summary>
[TestFixture] [TestFixture]
public class ReleaseRoutesTests public class ReleaseRoutesTests
{ {
[TestCase(ReleaseMedium.Cut, "/cuts/42")] private const string Key = "9f8a3c2e-key";
[TestCase(ReleaseMedium.Session, "/sessions/42")]
[TestCase(ReleaseMedium.Mix, "/mixes/42")] [TestCase(ReleaseMedium.Cut, "/cuts/9f8a3c2e-key")]
[TestCase(ReleaseMedium.Session, "/sessions/9f8a3c2e-key")]
[TestCase(ReleaseMedium.Mix, "/mixes/9f8a3c2e-key")]
public void DetailHref_ResolvesEachMediumToItsDedicatedRoute(ReleaseMedium medium, string expected) public void DetailHref_ResolvesEachMediumToItsDedicatedRoute(ReleaseMedium medium, string expected)
{ {
Assert.That(ReleaseRoutes.DetailHref(42, medium), Is.EqualTo(expected)); Assert.That(ReleaseRoutes.DetailHref(Key, medium), Is.EqualTo(expected));
} }
[TestCase(ReleaseMedium.Cut, "/cuts/7")] [TestCase(ReleaseMedium.Cut, "/cuts/9f8a3c2e-key")]
[TestCase(ReleaseMedium.Session, "/sessions/7")] [TestCase(ReleaseMedium.Session, "/sessions/9f8a3c2e-key")]
[TestCase(ReleaseMedium.Mix, "/mixes/7")] [TestCase(ReleaseMedium.Mix, "/mixes/9f8a3c2e-key")]
public void DetailHref_DtoOverload_AgreesWithPrimitiveOverload(ReleaseMedium medium, string expected) public void DetailHref_DtoOverload_AgreesWithPrimitiveOverload(ReleaseMedium medium, string expected)
{ {
// The redirect page resolves a fetched ReleaseDto through this overload; every other call // The redirect page resolves a fetched ReleaseDto through this overload; every other call
// site does too. It must produce the same route as the (id, medium) primitive. // site does too. It must read EntryKey and produce the same route as the (entryKey, medium)
var release = new ReleaseDto { Id = 7, Medium = medium }; // primitive — a regression to release.Id here would re-expose the transparent int (§3e).
var release = new ReleaseDto { EntryKey = Key, Medium = medium };
Assert.That(ReleaseRoutes.DetailHref(release), Is.EqualTo(expected)); Assert.That(ReleaseRoutes.DetailHref(release), Is.EqualTo(expected));
} }
@@ -43,7 +47,7 @@ public class ReleaseRoutesTests
// route — a fourth medium lacking a route arm fails here rather than mis-routing to /cuts. // route — a fourth medium lacking a route arm fails here rather than mis-routing to /cuts.
foreach (var medium in Enum.GetValues<ReleaseMedium>().Where(m => m != ReleaseMedium.Cut)) foreach (var medium in Enum.GetValues<ReleaseMedium>().Where(m => m != ReleaseMedium.Cut))
{ {
var href = ReleaseRoutes.DetailHref(1, medium); var href = ReleaseRoutes.DetailHref(Key, medium);
Assert.That(href, Does.Not.StartWith("/cuts/"), Assert.That(href, Does.Not.StartWith("/cuts/"),
$"Medium {medium} fell through to the Cut default arm ('{href}') — it needs its own route."); $"Medium {medium} fell through to the Cut default arm ('{href}') — it needs its own route.");
} }
+1
View File
@@ -50,6 +50,7 @@ public class TrackFilterQueryTests
string title, string artist, string? genre = null, string? image = null) string title, string artist, string? genre = null, string? image = null)
=> new() => new()
{ {
EntryKey = Guid.NewGuid().ToString("N"),
Title = title, Title = title,
Artist = artist, Artist = artist,
Genre = genre, Genre = genre,