diff --git a/DeepDrftAPI/CLAUDE.md b/DeepDrftAPI/CLAUDE.md index 840a58d..b0738f9 100644 --- a/DeepDrftAPI/CLAUDE.md +++ b/DeepDrftAPI/CLAUDE.md @@ -220,19 +220,19 @@ Paged release list, optionally filtered to one medium. Public browse data, same - `sortDescending` (bool, optional, default false): sort direction. - Returns 200 with `PagedResult` 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. -- **Response**: `ReleaseDto` with `Id`, `Title`, `Artist`, `Genre`, `ReleaseDate`, `Medium`, `ImagePath`, and media-specific metadata satellites (`MixMetadata` for Cut/Mix, `SessionMetadata` for Session; others null). +- **Route parameter `entryKey`** (string): the release's `EntryKey` (the public handle). +- **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. -### 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). - 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. diff --git a/DeepDrftAPI/Controllers/ReleaseController.cs b/DeepDrftAPI/Controllers/ReleaseController.cs index d566c72..654213b 100644 --- a/DeepDrftAPI/Controllers/ReleaseController.cs +++ b/DeepDrftAPI/Controllers/ReleaseController.cs @@ -66,18 +66,20 @@ public class ReleaseController : ControllerBase 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 // 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. - [HttpGet("{id:long}/mix/waveform")] - public async Task GetMixWaveform(long id, CancellationToken ct = default) + // or no datum is stored. Public read — addresses by the opaque EntryKey, not the int PK (§3e). The + // {entryKey} string segment cannot collide with the ApiKey-gated POST {id:long}/mix/waveform (different + // verb + constraint). Declared before the shorter "{entryKey}" route for clarity. + [HttpGet("{entryKey}/mix/waveform")] + public async Task GetMixWaveform(string entryKey, CancellationToken ct = default) { - var lookup = await _releaseService.GetByIdAsync(id, ct); + var lookup = await _releaseService.GetByEntryKeyAsync(entryKey, ct); if (!lookup.Success) { 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"); } @@ -85,14 +87,14 @@ public class ReleaseController : ControllerBase var waveformEntryKey = release?.MixMetadata?.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(); } var bytes = await _waveformProfileService.GetProfileAsync(waveformEntryKey, VaultConstants.MixWaveforms); 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(); } @@ -159,17 +161,18 @@ public class ReleaseController : ControllerBase return StatusCode(500, error); } - // GET api/release/{id} (unauthenticated) - // Single release with both metadata navs (nulls for non-matching media). Declared after the longer - // "{id:long}/mix/waveform" routes so the segmented routes resolve first. - [HttpGet("{id:long}")] - public async Task GetReleaseById(long id, CancellationToken ct = default) + // GET api/release/{entryKey} (unauthenticated) + // Single release with both metadata navs (nulls for non-matching media). Public read — addresses by + // the opaque EntryKey, not the int PK (§3e). Declared after the longer "{entryKey}/mix/waveform" + // route so the segmented route resolves first. + [HttpGet("{entryKey}")] + public async Task GetReleaseByEntryKey(string entryKey, CancellationToken ct = default) { - var result = await _releaseService.GetByIdAsync(id, ct); + var result = await _releaseService.GetByEntryKeyAsync(entryKey, ct); if (!result.Success) { 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"); } diff --git a/DeepDrftData/Data/Configurations/ReleaseConfiguration.cs b/DeepDrftData/Data/Configurations/ReleaseConfiguration.cs index 1e9ab4e..108f2af 100644 --- a/DeepDrftData/Data/Configurations/ReleaseConfiguration.cs +++ b/DeepDrftData/Data/Configurations/ReleaseConfiguration.cs @@ -21,6 +21,18 @@ public class ReleaseConfiguration : BaseEntityConfiguration builder.Property(e => e.UpdatedAt).HasColumnName("updated_at"); 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) .IsRequired() .HasMaxLength(200) diff --git a/DeepDrftData/IReleaseService.cs b/DeepDrftData/IReleaseService.cs index dc567db..4782d8d 100644 --- a/DeepDrftData/IReleaseService.cs +++ b/DeepDrftData/IReleaseService.cs @@ -20,6 +20,9 @@ public interface IReleaseService /// Single release with both metadata navs included (nulls for non-matching media). Task> GetByIdAsync(long id, CancellationToken cancellationToken = default); + /// The public addressing read: single release resolved by its opaque EntryKey (Phase 11 §3e). Both metadata navs included (nulls for non-matching media). + Task> GetByEntryKeyAsync(string entryKey, CancellationToken cancellationToken = default); + /// Track entry keys for a release. Single-entry for Session/Mix (enforced at upload); may be multiple for Cut. Task>> GetTrackEntryKeysAsync(long releaseId, CancellationToken cancellationToken = default); diff --git a/DeepDrftData/Migrations/20260616210143_AddReleaseEntryKey.Designer.cs b/DeepDrftData/Migrations/20260616210143_AddReleaseEntryKey.Designer.cs new file mode 100644 index 0000000..43974a9 --- /dev/null +++ b/DeepDrftData/Migrations/20260616210143_AddReleaseEntryKey.Designer.cs @@ -0,0 +1,318 @@ +// +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 + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("DeepDrftModels.Entities.MixMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_deleted"); + + b.Property("ReleaseId") + .HasColumnType("bigint") + .HasColumnName("release_id"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("WaveformEntryKey") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("waveform_entry_key"); + + b.HasKey("Id"); + + b.HasIndex("IsDeleted") + .HasDatabaseName("IX_mix_metadata_is_deleted"); + + b.HasIndex("ReleaseId") + .IsUnique() + .HasDatabaseName("IX_mix_metadata_release_id"); + + b.ToTable("mix_metadata", (string)null); + }); + + modelBuilder.Entity("DeepDrftModels.Entities.ReleaseEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Artist") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("artist"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedByUserId") + .HasColumnType("bigint") + .HasColumnName("created_by_user_id"); + + b.Property("Description") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)") + .HasColumnName("description"); + + b.Property("EntryKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("entry_key"); + + b.Property("Genre") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("genre"); + + b.Property("ImagePath") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("image_path"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_deleted"); + + b.Property("Medium") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("Cut") + .HasColumnName("medium"); + + b.Property("ReleaseDate") + .HasColumnType("date") + .HasColumnName("release_date"); + + b.Property("ReleaseType") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("Single") + .HasColumnName("release_type"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id"); + + b.HasIndex("EntryKey") + .IsUnique() + .HasDatabaseName("IX_release_entry_key"); + + b.HasIndex("IsDeleted") + .HasDatabaseName("IX_release_is_deleted"); + + b.HasIndex("Title", "Artist") + .IsUnique() + .HasDatabaseName("IX_release_title_artist") + .HasFilter("\"is_deleted\" = false"); + + b.ToTable("release", (string)null); + }); + + modelBuilder.Entity("DeepDrftModels.Entities.SessionMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("HeroImageEntryKey") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("hero_image_entry_key"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_deleted"); + + b.Property("ReleaseId") + .HasColumnType("bigint") + .HasColumnName("release_id"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id"); + + b.HasIndex("IsDeleted") + .HasDatabaseName("IX_session_metadata_is_deleted"); + + b.HasIndex("ReleaseId") + .IsUnique() + .HasDatabaseName("IX_session_metadata_release_id"); + + b.ToTable("session_metadata", (string)null); + }); + + modelBuilder.Entity("DeepDrftModels.Entities.TrackEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("EntryKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("entry_key"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_deleted"); + + b.Property("OriginalFileName") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("original_file_name"); + + b.Property("ReleaseId") + .HasColumnType("bigint") + .HasColumnName("release_id"); + + b.Property("TrackName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("track_name"); + + b.Property("TrackNumber") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(1) + .HasColumnName("track_number"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id"); + + b.HasIndex("IsDeleted") + .HasDatabaseName("IX_track_is_deleted"); + + b.HasIndex("ReleaseId"); + + b.ToTable("track", (string)null); + }); + + modelBuilder.Entity("DeepDrftModels.Entities.MixMetadata", b => + { + b.HasOne("DeepDrftModels.Entities.ReleaseEntity", "Release") + .WithOne("MixMetadata") + .HasForeignKey("DeepDrftModels.Entities.MixMetadata", "ReleaseId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Release"); + }); + + modelBuilder.Entity("DeepDrftModels.Entities.SessionMetadata", b => + { + b.HasOne("DeepDrftModels.Entities.ReleaseEntity", "Release") + .WithOne("SessionMetadata") + .HasForeignKey("DeepDrftModels.Entities.SessionMetadata", "ReleaseId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Release"); + }); + + modelBuilder.Entity("DeepDrftModels.Entities.TrackEntity", b => + { + b.HasOne("DeepDrftModels.Entities.ReleaseEntity", "Release") + .WithMany("Tracks") + .HasForeignKey("ReleaseId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Release"); + }); + + modelBuilder.Entity("DeepDrftModels.Entities.ReleaseEntity", b => + { + b.Navigation("MixMetadata"); + + b.Navigation("SessionMetadata"); + + b.Navigation("Tracks"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/DeepDrftData/Migrations/20260616210143_AddReleaseEntryKey.cs b/DeepDrftData/Migrations/20260616210143_AddReleaseEntryKey.cs new file mode 100644 index 0000000..0caa8a0 --- /dev/null +++ b/DeepDrftData/Migrations/20260616210143_AddReleaseEntryKey.cs @@ -0,0 +1,65 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace DeepDrftData.Migrations +{ + /// + public partial class AddReleaseEntryKey : Migration + { + /// + 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( + 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( + 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); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_release_entry_key", + table: "release"); + + migrationBuilder.DropColumn( + name: "entry_key", + table: "release"); + } + } +} diff --git a/DeepDrftData/Migrations/DeepDrftContextModelSnapshot.cs b/DeepDrftData/Migrations/DeepDrftContextModelSnapshot.cs index dbfb337..33b6111 100644 --- a/DeepDrftData/Migrations/DeepDrftContextModelSnapshot.cs +++ b/DeepDrftData/Migrations/DeepDrftContextModelSnapshot.cs @@ -95,6 +95,12 @@ namespace DeepDrftData.Migrations .HasColumnType("character varying(4000)") .HasColumnName("description"); + b.Property("EntryKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("entry_key"); + b.Property("Genre") .HasMaxLength(100) .HasColumnType("character varying(100)") @@ -143,6 +149,10 @@ namespace DeepDrftData.Migrations b.HasKey("Id"); + b.HasIndex("EntryKey") + .IsUnique() + .HasDatabaseName("IX_release_entry_key"); + b.HasIndex("IsDeleted") .HasDatabaseName("IX_release_is_deleted"); diff --git a/DeepDrftData/ReleaseManager.cs b/DeepDrftData/ReleaseManager.cs index 0763b51..d64c149 100644 --- a/DeepDrftData/ReleaseManager.cs +++ b/DeepDrftData/ReleaseManager.cs @@ -94,6 +94,20 @@ public class ReleaseManager : IReleaseService } } + public async Task> GetByEntryKeyAsync(string entryKey, CancellationToken cancellationToken = default) + { + try + { + var entity = await _repository.GetByEntryKeyWithMetadataAsync(entryKey, cancellationToken); + return ResultContainer.CreatePassResult( + entity is null ? null : TrackConverter.Convert(entity)); + } + catch (Exception e) + { + return ResultContainer.CreateFailResult(e.Message); + } + } + public async Task>> GetTrackEntryKeysAsync(long releaseId, CancellationToken cancellationToken = default) { try diff --git a/DeepDrftData/Repositories/ReleaseRepository.cs b/DeepDrftData/Repositories/ReleaseRepository.cs index 0bc1158..9509e8d 100644 --- a/DeepDrftData/Repositories/ReleaseRepository.cs +++ b/DeepDrftData/Repositories/ReleaseRepository.cs @@ -95,6 +95,16 @@ public class ReleaseRepository .Include(r => r.MixMetadata) .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 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 // list reads without an N+1 fan-out. Releases with zero live tracks are absent from the dictionary. public async Task> GetTrackCountsByReleaseIdsAsync( diff --git a/DeepDrftData/TrackConverter.cs b/DeepDrftData/TrackConverter.cs index 9de23ac..7827d9b 100644 --- a/DeepDrftData/TrackConverter.cs +++ b/DeepDrftData/TrackConverter.cs @@ -19,6 +19,7 @@ public class TrackConverter : IEntityToModelConverter public static ReleaseDto Convert(ReleaseEntity entity) => new() { Id = entity.Id, + EntryKey = entity.EntryKey, CreatedAt = entity.CreatedAt, UpdatedAt = entity.UpdatedAt, Title = entity.Title, @@ -51,6 +52,10 @@ public class TrackConverter : IEntityToModelConverter public static ReleaseEntity Convert(ReleaseDto dto) => new() { 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, UpdatedAt = dto.UpdatedAt, Title = dto.Title, diff --git a/DeepDrftData/TrackManager.cs b/DeepDrftData/TrackManager.cs index 643aa64..73c466a 100644 --- a/DeepDrftData/TrackManager.cs +++ b/DeepDrftData/TrackManager.cs @@ -177,6 +177,9 @@ public class TrackManager // in releaseData so a typo upstream cannot create a release that won't be found again. var entity = TrackConverter.Convert(releaseData); 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.Artist = artist; diff --git a/DeepDrftModels/DTOs/ReleaseDto.cs b/DeepDrftModels/DTOs/ReleaseDto.cs index 5df19f6..5fb09bd 100644 --- a/DeepDrftModels/DTOs/ReleaseDto.cs +++ b/DeepDrftModels/DTOs/ReleaseDto.cs @@ -9,6 +9,10 @@ namespace DeepDrftModels.DTOs; // TrackConverter assigns every field on the round-trip, so an empty default is never observable. 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 Artist { get; set; } = string.Empty; public string? Genre { get; set; } diff --git a/DeepDrftModels/Entities/ReleaseEntity.cs b/DeepDrftModels/Entities/ReleaseEntity.cs index 6cf3533..1009f77 100644 --- a/DeepDrftModels/Entities/ReleaseEntity.cs +++ b/DeepDrftModels/Entities/ReleaseEntity.cs @@ -12,6 +12,12 @@ namespace DeepDrftModels.Entities; // explicitly to satisfy the generic constraints on Repository<>/Manager<>/etc. 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 Artist { get; set; } public string? Genre { get; set; } diff --git a/DeepDrftPublic.Client/Clients/ReleaseClient.cs b/DeepDrftPublic.Client/Clients/ReleaseClient.cs index 3e89769..515f1c1 100644 --- a/DeepDrftPublic.Client/Clients/ReleaseClient.cs +++ b/DeepDrftPublic.Client/Clients/ReleaseClient.cs @@ -69,9 +69,9 @@ public class ReleaseClient : ApiResult>.CreateFailResult("Failed to deserialize response"); } - public async Task> GetById(long id) + public async Task> GetByEntryKey(string entryKey) { - var response = await _http.GetAsync($"api/release/{id}"); + var response = await _http.GetAsync($"api/release/{Uri.EscapeDataString(entryKey)}"); if (!response.IsSuccessStatusCode) return ApiResult.CreateFailResult($"HTTP {(int)response.StatusCode}"); @@ -85,13 +85,13 @@ public class ReleaseClient } /// - /// Fetches the high-res waveform datum for a Mix release. A 404 means no datum is stored - /// (not yet generated, or not a Mix) — a valid state, so it returns a pass result with a - /// null value. Any other non-success status is a genuine failure. + /// Fetches the high-res waveform datum for a Mix release, addressed by its public EntryKey. A 404 + /// means no datum is stored (not yet generated, or not a Mix) — a valid state, so it returns a pass + /// result with a null value. Any other non-success status is a genuine failure. /// - public async Task> GetMixWaveform(long id) + public async Task> 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) return ApiResult.CreatePassResult(null); diff --git a/DeepDrftPublic.Client/Common/ReleaseRoutes.cs b/DeepDrftPublic.Client/Common/ReleaseRoutes.cs index e857627..7ed08b0 100644 --- a/DeepDrftPublic.Client/Common/ReleaseRoutes.cs +++ b/DeepDrftPublic.Client/Common/ReleaseRoutes.cs @@ -14,17 +14,19 @@ namespace DeepDrftPublic.Client.Common; public static class ReleaseRoutes { /// - /// The dedicated detail route for a release: /cuts/{id}, /sessions/{id}, or - /// /mixes/{id}. 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. + /// The dedicated detail route for a release: /cuts/{entryKey}, /sessions/{entryKey}, + /// or /mixes/{entryKey}. The route carries the release's opaque public EntryKey (Phase 11 + /// §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. /// - 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.Mix => $"/mixes/{id}", - _ => $"/cuts/{id}", + ReleaseMedium.Session => $"/sessions/{entryKey}", + ReleaseMedium.Mix => $"/mixes/{entryKey}", + _ => $"/cuts/{entryKey}", }; /// Convenience overload for call sites holding a . - public static string DetailHref(ReleaseDto release) => DetailHref(release.Id, release.Medium); + public static string DetailHref(ReleaseDto release) => DetailHref(release.EntryKey, release.Medium); } diff --git a/DeepDrftPublic.Client/Controls/MixWaveformVisualizer.razor b/DeepDrftPublic.Client/Controls/MixWaveformVisualizer.razor index ea9c23c..a911279 100644 --- a/DeepDrftPublic.Client/Controls/MixWaveformVisualizer.razor +++ b/DeepDrftPublic.Client/Controls/MixWaveformVisualizer.razor @@ -3,7 +3,7 @@ @* 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 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 bridge that feeds it datum + playback + zoom + theme. Deliberately NOT the player-bar peak-bar idiom. *@ diff --git a/DeepDrftPublic.Client/Controls/MixWaveformVisualizer.razor.cs b/DeepDrftPublic.Client/Controls/MixWaveformVisualizer.razor.cs index 55085b4..ee0ca29 100644 --- a/DeepDrftPublic.Client/Controls/MixWaveformVisualizer.razor.cs +++ b/DeepDrftPublic.Client/Controls/MixWaveformVisualizer.razor.cs @@ -9,7 +9,7 @@ namespace DeepDrftPublic.Client.Controls; /// /// Full-page scrolling Mix waveform background. Standalone and reusable: give it a -/// and it fetches its own loudness datum. The rendering itself — a windowed, +/// 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 /// MixVisualizer.ts interop module; this component is the bridge that feeds it datum, playback /// 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. [CascadingParameter] public DarkModeSettings? DarkMode { get; set; } - /// The Mix release whose waveform datum to fetch and render. - [Parameter] public required long ReleaseId { get; set; } + /// The opaque public EntryKey of the Mix release whose waveform datum to fetch and render. + [Parameter] public required string ReleaseEntryKey { get; set; } /// /// 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 WaveformProfileDto? _profile; - private long? _loadedReleaseId; + private string? _loadedReleaseKey; // 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. @@ -114,21 +114,21 @@ public partial class MixWaveformVisualizer : ComponentBase, IAsyncDisposable _subscribedService.StateChanged -= OnPlayerStateChanged; PlayerService.StateChanged += OnPlayerStateChanged; _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) { - 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 - // but must not refetch, and a release with no datum must not refetch either — so the guard - // keys on the fetched id, not on whether a profile came back. - if (_loadedReleaseId == ReleaseId) return; - _loadedReleaseId = ReleaseId; + // ReleaseEntryKey is the only fetch input; fetch once per key. Position/zoom/theme changes + // re-render but must not refetch, and a release with no datum must not refetch either — so the + // guard keys on the fetched key, not on whether a profile came back. + if (_loadedReleaseKey == ReleaseEntryKey) return; + _loadedReleaseKey = ReleaseEntryKey; - DebugLog($"fetching mix waveform datum for ReleaseId={ReleaseId}…"); - var result = await ReleaseData.GetMixWaveform(ReleaseId); + DebugLog($"fetching mix waveform datum for ReleaseEntryKey={ReleaseEntryKey}…"); + var result = await ReleaseData.GetMixWaveform(ReleaseEntryKey); if (result is { Success: true, Value: { } profile } && profile.BucketCount > 0 && profile.Data.Length > 0) { _profile = profile; @@ -140,7 +140,7 @@ public partial class MixWaveformVisualizer : ComponentBase, IAsyncDisposable // renders its content over a plain background. _profile = null; 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."); } diff --git a/DeepDrftPublic.Client/Controls/ReleaseGallery.razor b/DeepDrftPublic.Client/Controls/ReleaseGallery.razor index 7d20bad..47ec7d9 100644 --- a/DeepDrftPublic.Client/Controls/ReleaseGallery.razor +++ b/DeepDrftPublic.Client/Controls/ReleaseGallery.razor @@ -5,7 +5,7 @@ the parent supplies it one of two ways: - 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 - 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 overrides it (Cuts shows a track count instead). Fully controlled by the parent: loading and item state are passed in. *@ @@ -82,7 +82,7 @@ /// /// Per-card href resolver. When supplied, a card links to its result instead of the /// -based href, letting Archive route each card by its own medium and - /// Cuts route to /cuts/{id} (both via ReleaseRoutes.DetailHref). + /// Cuts route to /cuts/{entryKey} (both via ReleaseRoutes.DetailHref). /// [Parameter] public Func? HrefResolver { get; set; } @@ -95,5 +95,5 @@ [Parameter] public string EmptyMessage { get; set; } = "Nothing here yet"; private string CardHref(DeepDrftModels.DTOs.ReleaseDto release) - => HrefResolver?.Invoke(release) ?? $"/{DetailRoute}/{release.Id}"; + => HrefResolver?.Invoke(release) ?? $"/{DetailRoute}/{release.EntryKey}"; } diff --git a/DeepDrftPublic.Client/Controls/SharePopover.razor.cs b/DeepDrftPublic.Client/Controls/SharePopover.razor.cs index a6217c3..268300d 100644 --- a/DeepDrftPublic.Client/Controls/SharePopover.razor.cs +++ b/DeepDrftPublic.Client/Controls/SharePopover.razor.cs @@ -8,7 +8,7 @@ namespace DeepDrftPublic.Client.Controls; /// /// Share affordance with two modes from one source of clipboard/popover-chrome logic /// (Phase 11 §3b). Track mode ( set) offers a canonical-link copy plus an -/// optional iframe embed snippet. Release mode ( set) is copy-link-only — +/// optional iframe embed snippet. Release mode ( set) is copy-link-only — /// 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 /// navigator.clipboard; each copy shows a transient "Copied!" confirmation that resets after a @@ -19,8 +19,8 @@ public partial class SharePopover : ComponentBase, IDisposable /// Track mode: the vault entry key of the track to share. Mutually exclusive with the release target. [Parameter] public string? EntryKey { get; set; } - /// Release mode: the release id to share. When set (with ), the popover shares the release detail URL and omits the embed option. - [Parameter] public long? ReleaseId { get; set; } + /// Release mode: the release's opaque public EntryKey to share. When set (with ), the popover shares the release detail URL and omits the embed option. + [Parameter] public string? ReleaseEntryKey { get; set; } /// Release mode: the medium of the release, used to resolve its canonical detail route. [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 IJSRuntime JS { get; set; } - private bool IsReleaseMode => ReleaseId is not null; + private bool IsReleaseMode => ReleaseEntryKey is not null; private bool _open; 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 // trailing slash) — trim one to avoid a doubled separator. private string LinkUrl => IsReleaseMode - ? $"{Navigation.BaseUri.TrimEnd('/')}{ReleaseRoutes.DetailHref(ReleaseId!.Value, ReleaseMedium)}" + ? $"{Navigation.BaseUri.TrimEnd('/')}{ReleaseRoutes.DetailHref(ReleaseEntryKey!, ReleaseMedium)}" : TrackUrl; private string TrackUrl => $"{Navigation.BaseUri}track/{EntryKey}"; diff --git a/DeepDrftPublic.Client/Pages/AlbumsView.razor b/DeepDrftPublic.Client/Pages/AlbumsView.razor index 536d130..071ee68 100644 --- a/DeepDrftPublic.Client/Pages/AlbumsView.razor +++ b/DeepDrftPublic.Client/Pages/AlbumsView.razor @@ -3,7 +3,7 @@ DeepDrft Cuts -@* 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 (Cut releases) and parameterized by /// so the same component can back any medium's card grid without a fork. /// Cards open the release's dedicated detail page via -/// (a Cut routes to /cuts/{id}), the single source for medium→route resolution (Phase 11 §2). +/// (a Cut routes to /cuts/{entryKey}), the single source for medium→route resolution (Phase 11 §2). /// public partial class AlbumsView : ComponentBase, IDisposable { diff --git a/DeepDrftPublic.Client/Pages/CutDetail.razor b/DeepDrftPublic.Client/Pages/CutDetail.razor index 2500049..0a6fff9 100644 --- a/DeepDrftPublic.Client/Pages/CutDetail.razor +++ b/DeepDrftPublic.Client/Pages/CutDetail.razor @@ -1,4 +1,4 @@ -@page "/cuts/{Id:long}" +@page "/cuts/{EntryKey}" @using DeepDrftModels.DTOs @using DeepDrftPublic.Client.Controls @using DeepDrftPublic.Client.Services @@ -79,8 +79,8 @@ else Play - @* 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). *@ + diff --git a/DeepDrftPublic.Client/Pages/CutDetailBase.cs b/DeepDrftPublic.Client/Pages/CutDetailBase.cs index 9979557..13020fb 100644 --- a/DeepDrftPublic.Client/Pages/CutDetailBase.cs +++ b/DeepDrftPublic.Client/Pages/CutDetailBase.cs @@ -5,7 +5,7 @@ using Microsoft.AspNetCore.Components; namespace DeepDrftPublic.Client.Pages; /// -/// 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 /// 's discipline (id-addressed load in OnParametersSetAsync, /// 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 @@ -15,16 +15,16 @@ public abstract class CutDetailBase : ComponentBase, IDisposable { 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 PersistentComponentState PersistentState { get; set; } private PersistingComponentStateSubscription _persistingSubscription; - // The release id the ViewModel currently holds — tracks param-only navigations (e.g. - // /cuts/5 -> /cuts/8) which reuse this component instance and fire OnParametersSet without + // The release EntryKey the ViewModel currently holds — tracks param-only navigations (e.g. + // /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. - private long _loadedId; + private string? _loadedKey; private bool _loaded; protected override void OnInitialized() @@ -32,25 +32,25 @@ public abstract class CutDetailBase : ComponentBase, IDisposable 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 - // re-render that changes Id while Load is in flight) sees the correct guard state. - _loadedId = Id; + // Capture the key synchronously before any await so a re-entrant call (rapid navigation or a + // re-render that changes EntryKey while Load is in flight) sees the correct guard state. + _loadedKey = EntryKey; _loaded = true; // 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). if (PersistentState.TryTakeFromJson(PersistKey, out var restored) && restored?.Release is not null - && restored.Release.Id == Id) + && restored.Release.EntryKey == EntryKey) { ViewModel.Restore(restored.Release, restored.Tracks); } else { - await ViewModel.Load(Id); + await ViewModel.Load(EntryKey); } } diff --git a/DeepDrftPublic.Client/Pages/MixDetail.razor b/DeepDrftPublic.Client/Pages/MixDetail.razor index 6b6f560..79a4178 100644 --- a/DeepDrftPublic.Client/Pages/MixDetail.razor +++ b/DeepDrftPublic.Client/Pages/MixDetail.razor @@ -1,4 +1,4 @@ -@page "/mixes/{Id:long}" +@page "/mixes/{EntryKey}" @using DeepDrftPublic.Client.Controls @inherits ReleaseDetailBase @@ -37,7 +37,7 @@ else @* 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 playback only when the player is on this mix's track. *@ - +
diff --git a/DeepDrftPublic.Client/Pages/ReleaseDetailBase.cs b/DeepDrftPublic.Client/Pages/ReleaseDetailBase.cs index 4e5d313..2dd9077 100644 --- a/DeepDrftPublic.Client/Pages/ReleaseDetailBase.cs +++ b/DeepDrftPublic.Client/Pages/ReleaseDetailBase.cs @@ -13,17 +13,17 @@ namespace DeepDrftPublic.Client.Pages; ///
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 PersistentComponentState PersistentState { get; set; } private PersistingComponentStateSubscription _persistingSubscription; - // The release id the ViewModel currently holds. Tracks param-only navigations (e.g. - // /mixes/5 -> /mixes/8) which reuse this component instance and fire OnParametersSet + // The release EntryKey the ViewModel currently holds. Tracks param-only navigations (e.g. + // /mixes/{a} -> /mixes/{b}) which reuse this component instance and fire OnParametersSet // without re-running OnInitialized — without this, the page would keep the prior // release's track and Play would stream the wrong audio. - private long _loadedId; + private string? _loadedKey; private bool _loaded; // 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() { - // 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 // 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 - // (rapid navigation or a re-render that changes Id while Load is in flight) + // Capture the key synchronously before any await so that a re-entrant call + // (rapid navigation or a re-render that changes EntryKey while Load is in flight) // 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. - _loadedId = Id; + _loadedKey = EntryKey; _loaded = true; // 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). if (PersistentState.TryTakeFromJson(PersistKey, out var restored) && restored?.Release is not null - && restored.Release.Id == Id) + && restored.Release.EntryKey == EntryKey) { ViewModel.Restore(restored.Release, restored.Track); } else { - await ViewModel.Load(Id); + await ViewModel.Load(EntryKey); } } diff --git a/DeepDrftPublic.Client/Pages/SessionDetail.razor b/DeepDrftPublic.Client/Pages/SessionDetail.razor index f9d23af..f23b4c6 100644 --- a/DeepDrftPublic.Client/Pages/SessionDetail.razor +++ b/DeepDrftPublic.Client/Pages/SessionDetail.razor @@ -1,4 +1,4 @@ -@page "/sessions/{Id:long}" +@page "/sessions/{EntryKey}" @using DeepDrftModels.DTOs @using DeepDrftPublic.Client.Controls @using DeepDrftPublic.Client.Services diff --git a/DeepDrftPublic.Client/Pages/TrackRedirect.razor b/DeepDrftPublic.Client/Pages/TrackRedirect.razor index 845f0ea..c285512 100644 --- a/DeepDrftPublic.Client/Pages/TrackRedirect.razor +++ b/DeepDrftPublic.Client/Pages/TrackRedirect.razor @@ -1,25 +1,26 @@ -@page "/tracks/{Id:long}" +@page "/tracks/{EntryKey}" @using DeepDrftPublic.Client.Services @inject IReleaseDataService ReleaseData @inject NavigationManager Navigation -@* Addressable deep-link fallback for a bare release id (Phase 11 §2, shape iii). Unlike the player - bar / Archive / AlbumsView call sites, an external /tracks/{id} link carries only the id, so this - page fetches the release to discover its medium, then forwards through the same ReleaseRoutes - resolver — one medium→route table, no second source. replace:true keeps the router out of history - so Back skips this hop. Capture Id before the await per the InteractiveAuto route-param convention. *@ +@* Addressable deep-link fallback for a bare release EntryKey (Phase 11 §2, shape iii). Unlike the + player bar / Archive / AlbumsView call sites, an external /tracks/{entryKey} link carries only the + key, so this page fetches the release to discover its medium, then forwards through the same + ReleaseRoutes resolver — one medium→route table, no second source. replace:true keeps the router + out of history so Back skips this hop. Capture EntryKey before the await per the InteractiveAuto + route-param convention. *@ @code { - [Parameter] public long Id { get; set; } + [Parameter] public string EntryKey { get; set; } = string.Empty; protected override async Task OnParametersSetAsync() { - var id = Id; - var result = await ReleaseData.GetById(id); + var entryKey = EntryKey; + var result = await ReleaseData.GetByEntryKey(entryKey); var target = result is { Success: true, Value: { } 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); } diff --git a/DeepDrftPublic.Client/Services/IReleaseDataService.cs b/DeepDrftPublic.Client/Services/IReleaseDataService.cs index 0ecee43..b1db0bc 100644 --- a/DeepDrftPublic.Client/Services/IReleaseDataService.cs +++ b/DeepDrftPublic.Client/Services/IReleaseDataService.cs @@ -22,12 +22,13 @@ public interface IReleaseDataService string? search = null, string? genre = null); - /// Single release with both metadata satellites (nulls for non-matching media). - Task> GetById(long id); + /// Single release resolved by its opaque public EntryKey, with both metadata satellites (nulls for non-matching media). + Task> GetByEntryKey(string entryKey); /// - /// The Mix waveform datum. Success with a value when present; success with a null value when - /// no datum is stored (a valid state, not a failure); failure on any other transport error. + /// The Mix waveform datum for a release addressed by its public EntryKey. Success with a value + /// when present; success with a null value when no datum is stored (a valid state, not a failure); + /// failure on any other transport error. /// - Task> GetMixWaveform(long id); + Task> GetMixWaveform(string entryKey); } diff --git a/DeepDrftPublic.Client/Services/ReleaseClientDataService.cs b/DeepDrftPublic.Client/Services/ReleaseClientDataService.cs index 637a2d0..4f85502 100644 --- a/DeepDrftPublic.Client/Services/ReleaseClientDataService.cs +++ b/DeepDrftPublic.Client/Services/ReleaseClientDataService.cs @@ -29,9 +29,9 @@ public class ReleaseClientDataService : IReleaseDataService string? genre = null) => _releaseClient.GetPaged(medium, page, pageSize, sortColumn, sortDescending, search, genre); - public Task> GetById(long id) - => _releaseClient.GetById(id); + public Task> GetByEntryKey(string entryKey) + => _releaseClient.GetByEntryKey(entryKey); - public Task> GetMixWaveform(long id) - => _releaseClient.GetMixWaveform(id); + public Task> GetMixWaveform(string entryKey) + => _releaseClient.GetMixWaveform(entryKey); } diff --git a/DeepDrftPublic.Client/ViewModels/CutDetailViewModel.cs b/DeepDrftPublic.Client/ViewModels/CutDetailViewModel.cs index 3fd1342..1578dca 100644 --- a/DeepDrftPublic.Client/ViewModels/CutDetailViewModel.cs +++ b/DeepDrftPublic.Client/ViewModels/CutDetailViewModel.cs @@ -4,7 +4,7 @@ using DeepDrftPublic.Client.Services; namespace DeepDrftPublic.Client.ViewModels; /// -/// State for the Cut album-detail page (/cuts/{id}). Unlike +/// State for the Cut album-detail page (/cuts/{entryKey}). Unlike /// (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 /// existing releaseId-filtered track page sorted by TrackNumber — the explicit 1-based ordinal @@ -40,7 +40,7 @@ public class CutDetailViewModel IsLoading = false; } - public async Task Load(long releaseId) + public async Task Load(string entryKey) { IsLoading = true; NotFound = false; @@ -49,7 +49,7 @@ public class CutDetailViewModel try { - var releaseResult = await _releaseData.GetById(releaseId); + var releaseResult = await _releaseData.GetByEntryKey(entryKey); if (releaseResult is not { Success: true, Value: { } release }) { NotFound = true; @@ -59,9 +59,11 @@ public class CutDetailViewModel Release = release; // 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 - // 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). + // (which collides across same-titled releases and breaks on rename). The public page + // addresses the release by EntryKey; the track→release join stays on the internal int FK + // (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( pageNumber: 1, pageSize: AlbumPageSize, diff --git a/DeepDrftPublic.Client/ViewModels/ReleaseDetailViewModel.cs b/DeepDrftPublic.Client/ViewModels/ReleaseDetailViewModel.cs index 4a88569..078ac98 100644 --- a/DeepDrftPublic.Client/ViewModels/ReleaseDetailViewModel.cs +++ b/DeepDrftPublic.Client/ViewModels/ReleaseDetailViewModel.cs @@ -35,7 +35,7 @@ public class ReleaseDetailViewModel IsLoading = false; } - public async Task Load(long releaseId) + public async Task Load(string entryKey) { IsLoading = true; NotFound = false; @@ -44,7 +44,7 @@ public class ReleaseDetailViewModel try { - var releaseResult = await _releaseData.GetById(releaseId); + var releaseResult = await _releaseData.GetByEntryKey(entryKey); if (releaseResult is not { Success: true, Value: { } release }) { NotFound = true; @@ -54,9 +54,11 @@ public class ReleaseDetailViewModel Release = release; // 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 - // 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). + // title string (which collides across same-titled releases and breaks on rename). The public + // page addresses the release by EntryKey; the track→release join stays on the internal int + // 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( pageNumber: 1, pageSize: 1, releaseId: release.Id); if (trackResult is { Success: true, Value: { Items: { } items } }) diff --git a/DeepDrftPublic/Controllers/ReleaseProxyController.cs b/DeepDrftPublic/Controllers/ReleaseProxyController.cs index 764d2d2..5e4f28d 100644 --- a/DeepDrftPublic/Controllers/ReleaseProxyController.cs +++ b/DeepDrftPublic/Controllers/ReleaseProxyController.cs @@ -47,15 +47,15 @@ public class ReleaseProxyController : ControllerBase return await RelayJson(query, "release list"); } - /// Proxies the Mix waveform datum. A 404 (no datum stored) passes through verbatim. - [HttpGet("{id:long}/mix/waveform")] - public async Task GetMixWaveform(long id, CancellationToken ct = default) - => await RelayJson($"api/release/{id}/mix/waveform", $"release {id} mix waveform", ct); + /// Proxies the Mix waveform datum, addressed by the release's opaque EntryKey. A 404 (no datum stored) passes through verbatim. + [HttpGet("{entryKey}/mix/waveform")] + public async Task GetMixWaveform(string entryKey, CancellationToken ct = default) + => await RelayJson($"api/release/{Uri.EscapeDataString(entryKey)}/mix/waveform", $"release {entryKey} mix waveform", ct); - /// Proxies a single release. A 404 (no such release) passes through verbatim. - [HttpGet("{id:long}")] - public async Task GetReleaseById(long id, CancellationToken ct = default) - => await RelayJson($"api/release/{id}", $"release {id}", ct); + /// Proxies a single release, addressed by its opaque EntryKey. A 404 (no such release) passes through verbatim. + [HttpGet("{entryKey}")] + public async Task GetReleaseByEntryKey(string entryKey, CancellationToken ct = default) + => await RelayJson($"api/release/{Uri.EscapeDataString(entryKey)}", $"release {entryKey}", ct); // 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. diff --git a/DeepDrftTests/CutDetailTrackOrderingTests.cs b/DeepDrftTests/CutDetailTrackOrderingTests.cs index 0e8e966..f3cd85c 100644 --- a/DeepDrftTests/CutDetailTrackOrderingTests.cs +++ b/DeepDrftTests/CutDetailTrackOrderingTests.cs @@ -43,7 +43,7 @@ public class CutDetailTrackOrderingTests => new(_context, NullLogger>.Instance); 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. private static TrackEntity Track(string name, int trackNumber, ReleaseEntity? release = null) diff --git a/DeepDrftTests/MediumWritePathTests.cs b/DeepDrftTests/MediumWritePathTests.cs index a56e647..e672d94 100644 --- a/DeepDrftTests/MediumWritePathTests.cs +++ b/DeepDrftTests/MediumWritePathTests.cs @@ -122,7 +122,7 @@ public class MediumWritePathTests 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, }; var track = new TrackEntity { EntryKey = "ek-1", TrackName = "Track", Release = release }; @@ -151,7 +151,7 @@ public class MediumWritePathTests { var sessionWithStaleType = new ReleaseEntity { - Title = "Session", Artist = "A", + EntryKey = "rk-stale", Title = "Session", Artist = "A", Medium = ReleaseMedium.Session, ReleaseType = ReleaseType.Album, }; @@ -169,7 +169,7 @@ public class MediumWritePathTests const string prose = "A late-night set\nrecorded at the Vault."; 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, }; @@ -184,7 +184,7 @@ public class MediumWritePathTests [Test] 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); Assert.That(dto.Description, Is.Null); @@ -222,7 +222,7 @@ public class MediumWritePathTests var repo = CreateRepository(); 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 }; _context.Tracks.Add(track); await _context.SaveChangesAsync(); @@ -242,8 +242,8 @@ public class MediumWritePathTests [Test] public async Task GetPagedFilteredAsync_WithReleaseId_ReturnsOnlyThatReleasesTracks() { - var first = new ReleaseEntity { Title = "Untitled", Artist = "Artist A" }; - var second = new ReleaseEntity { Title = "Untitled", Artist = "Artist B" }; + var first = new ReleaseEntity { EntryKey = "rk-first", Title = "Untitled", Artist = "Artist A" }; + var second = new ReleaseEntity { EntryKey = "rk-second", Title = "Untitled", Artist = "Artist B" }; _context.Tracks.AddRange( new TrackEntity { EntryKey = "a1", TrackName = "A-One", Release = first }, new TrackEntity { EntryKey = "a2", TrackName = "A-Two", Release = first }, @@ -264,8 +264,8 @@ public class MediumWritePathTests [Test] public async Task GetPagedFilteredAsync_SameTitledReleases_ResolveDistinctlyById() { - var first = new ReleaseEntity { Title = "Untitled", Artist = "Artist A" }; - var second = new ReleaseEntity { Title = "Untitled", Artist = "Artist B" }; + var first = new ReleaseEntity { EntryKey = "rk-first2", Title = "Untitled", Artist = "Artist A" }; + var second = new ReleaseEntity { EntryKey = "rk-second2", Title = "Untitled", Artist = "Artist B" }; _context.Tracks.AddRange( new TrackEntity { EntryKey = "a1", TrackName = "A-One", Release = first }, new TrackEntity { EntryKey = "b1", TrackName = "B-One", Release = second }); @@ -333,7 +333,7 @@ public class MediumWritePathTests var repo = CreateRepository(); 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 }); await _context.SaveChangesAsync(); @@ -370,7 +370,7 @@ public class MediumWritePathTests var repo = CreateRepository(); 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 }); await _context.SaveChangesAsync(); @@ -387,7 +387,7 @@ public class MediumWritePathTests var repo = CreateRepository(); 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 }); await _context.SaveChangesAsync(); @@ -404,7 +404,7 @@ public class MediumWritePathTests var repo = CreateRepository(); 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( new TrackEntity { EntryKey = "c1", TrackName = "One", Release = release }, new TrackEntity { EntryKey = "c2", TrackName = "Two", Release = release }, diff --git a/DeepDrftTests/ReleaseBrowseQueryTests.cs b/DeepDrftTests/ReleaseBrowseQueryTests.cs index ddcd7ec..e2045c6 100644 --- a/DeepDrftTests/ReleaseBrowseQueryTests.cs +++ b/DeepDrftTests/ReleaseBrowseQueryTests.cs @@ -46,6 +46,7 @@ public class ReleaseBrowseQueryTests string title, string artist, ReleaseMedium medium = ReleaseMedium.Cut, string? genre = null) => new() { + EntryKey = Guid.NewGuid().ToString("N"), Title = title, Artist = artist, Medium = medium, diff --git a/DeepDrftTests/ReleaseRoutesTests.cs b/DeepDrftTests/ReleaseRoutesTests.cs index f94957b..b84c2e5 100644 --- a/DeepDrftTests/ReleaseRoutesTests.cs +++ b/DeepDrftTests/ReleaseRoutesTests.cs @@ -6,31 +6,35 @@ namespace DeepDrftTests; /// /// 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 -/// all resolve through . These tests pin -/// each medium to its dedicated route and confirm the DTO overload (the call shape used everywhere -/// but the redirect page) agrees with the primitive overload (the shape the redirect page uses -/// after fetching the release by id). +/// §2, §3e): Archive cards, AlbumsView cards, the player-bar title, and the /tracks/{entryKey} +/// redirect page all resolve through . +/// The route now carries the release's opaque public EntryKey (a GUID string), never the int PK. +/// These tests pin each medium to its dedicated route and confirm the DTO overload (the call shape +/// used everywhere but the redirect page) agrees with the primitive overload (the shape the redirect +/// page uses after fetching the release by EntryKey). /// [TestFixture] public class ReleaseRoutesTests { - [TestCase(ReleaseMedium.Cut, "/cuts/42")] - [TestCase(ReleaseMedium.Session, "/sessions/42")] - [TestCase(ReleaseMedium.Mix, "/mixes/42")] + private const string Key = "9f8a3c2e-key"; + + [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) { - 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.Session, "/sessions/7")] - [TestCase(ReleaseMedium.Mix, "/mixes/7")] + [TestCase(ReleaseMedium.Cut, "/cuts/9f8a3c2e-key")] + [TestCase(ReleaseMedium.Session, "/sessions/9f8a3c2e-key")] + [TestCase(ReleaseMedium.Mix, "/mixes/9f8a3c2e-key")] public void DetailHref_DtoOverload_AgreesWithPrimitiveOverload(ReleaseMedium medium, string expected) { // 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. - var release = new ReleaseDto { Id = 7, Medium = medium }; + // site does too. It must read EntryKey and produce the same route as the (entryKey, 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)); } @@ -43,7 +47,7 @@ public class ReleaseRoutesTests // route — a fourth medium lacking a route arm fails here rather than mis-routing to /cuts. foreach (var medium in Enum.GetValues().Where(m => m != ReleaseMedium.Cut)) { - var href = ReleaseRoutes.DetailHref(1, medium); + var href = ReleaseRoutes.DetailHref(Key, medium); Assert.That(href, Does.Not.StartWith("/cuts/"), $"Medium {medium} fell through to the Cut default arm ('{href}') — it needs its own route."); } diff --git a/DeepDrftTests/TrackFilterQueryTests.cs b/DeepDrftTests/TrackFilterQueryTests.cs index 43146e6..3a71b98 100644 --- a/DeepDrftTests/TrackFilterQueryTests.cs +++ b/DeepDrftTests/TrackFilterQueryTests.cs @@ -50,6 +50,7 @@ public class TrackFilterQueryTests string title, string artist, string? genre = null, string? image = null) => new() { + EntryKey = Guid.NewGuid().ToString("N"), Title = title, Artist = artist, Genre = genre,