feat: normalize release-cardinal fields out of track into a Release entity (Phase 8 §8.0)

This commit is contained in:
daniel-c-harvey
2026-06-11 12:51:21 -04:00
parent 16f356a760
commit f767d288c5
33 changed files with 1032 additions and 297 deletions
+83 -8
View File
@@ -107,13 +107,16 @@ public class TrackManager
Page = pageNumber,
PageSize = pageSize,
IsDescending = sortDescending,
// Sorts navigate through the nullable Release relation; the null-coalescing
// sentinels push loose tracks (no release) to the end, matching the prior
// nulls-last behaviour on the flat columns.
OrderBy = sortColumn switch
{
"TrackName" => e => e.TrackName,
"Artist" => e => e.Artist,
"Album" => e => (object)(e.Album ?? string.Empty),
"Genre" => e => (object)(e.Genre ?? string.Empty),
"ReleaseDate" => e => (object)(e.ReleaseDate ?? DateOnly.MaxValue),
"Artist" => e => (object)(e.Release == null ? string.Empty : e.Release.Artist),
"Album" => e => (object)(e.Release == null ? string.Empty : e.Release.Title),
"Genre" => e => (object)(e.Release == null ? string.Empty : (e.Release.Genre ?? string.Empty)),
"ReleaseDate" => e => (object)(e.Release == null ? DateOnly.MaxValue : (e.Release.ReleaseDate ?? DateOnly.MaxValue)),
_ => e => e.Id
}
};
@@ -135,16 +138,52 @@ public class TrackManager
}
}
public async Task<ResultContainer<List<AlbumSummaryDto>>> GetDistinctAlbums(CancellationToken cancellationToken = default)
public async Task<ResultContainer<List<ReleaseDto>>> GetReleases(CancellationToken cancellationToken = default)
{
try
{
var albums = await Repository.GetDistinctAlbumsAsync(cancellationToken);
return ResultContainer<List<AlbumSummaryDto>>.CreatePassResult(albums);
var releases = await Repository.GetReleasesAsync(cancellationToken);
var counts = await Repository.GetTrackCountsByReleaseAsync(cancellationToken);
var dtos = releases
.Select(r =>
{
var dto = TrackConverter.Convert(r);
dto.TrackCount = counts.GetValueOrDefault(r.Id);
return dto;
})
.ToList();
return ResultContainer<List<ReleaseDto>>.CreatePassResult(dtos);
}
catch (Exception e)
{
return ResultContainer<List<AlbumSummaryDto>>.CreateFailResult(e.Message);
return ResultContainer<List<ReleaseDto>>.CreateFailResult(e.Message);
}
}
public async Task<ResultContainer<ReleaseDto>> FindOrCreateRelease(
string title, string artist, ReleaseDto releaseData, CancellationToken cancellationToken = default)
{
try
{
var existing = await Repository.GetReleaseByTitleAndArtistAsync(title, artist, cancellationToken);
if (existing is not null)
return ResultContainer<ReleaseDto>.CreatePassResult(TrackConverter.Convert(existing));
// The natural key (title + artist) is authoritative — override whatever the caller put
// in releaseData so a typo upstream cannot create a release that won't be found again.
var entity = TrackConverter.Convert(releaseData);
entity.Id = 0;
entity.Title = title;
entity.Artist = artist;
var added = await Repository.AddReleaseAsync(entity, cancellationToken);
return ResultContainer<ReleaseDto>.CreatePassResult(TrackConverter.Convert(added));
}
catch (Exception e)
{
return ResultContainer<ReleaseDto>.CreateFailResult(e.Message);
}
}
@@ -165,6 +204,22 @@ public class TrackManager
{
try
{
// A track with release context resolves (or creates) the shared release first so the FK
// is set before insert. A standalone track (Release null) stays a loose track, ReleaseId
// null. Callers that already resolved the FK (UnifiedTrackService) pass Release null and
// a populated ReleaseId, which falls straight through.
if (newTrack.Release is { } release && !string.IsNullOrWhiteSpace(release.Title))
{
var resolved = await FindOrCreateRelease(release.Title, release.Artist, release);
if (!resolved.Success || resolved.Value is null)
{
var error = resolved.Messages.FirstOrDefault()?.Message ?? "Failed to resolve release.";
return ResultContainer<TrackDto>.CreateFailResult(error);
}
newTrack.ReleaseId = resolved.Value.Id;
}
var added = await Repository.AddAsync(TrackConverter.Convert(newTrack));
return ResultContainer<TrackDto>.CreatePassResult(TrackConverter.Convert(added));
}
@@ -181,6 +236,26 @@ public class TrackManager
try
{
await Repository.UpdateAsync(TrackConverter.Convert(track));
// Release-cardinal edits flow through the linked release row, not the track. When the
// track carries a Release payload and a resolved FK, load the tracked release, apply the
// edited fields, and save. EntryKey/track fields are already persisted above.
if (track.Release is { } release && track.ReleaseId is { } releaseId)
{
var releaseEntity = await Repository.GetReleaseByIdAsync(releaseId);
if (releaseEntity is not null)
{
releaseEntity.Title = release.Title;
releaseEntity.Artist = release.Artist;
releaseEntity.Genre = release.Genre;
releaseEntity.ReleaseDate = release.ReleaseDate;
releaseEntity.ImagePath = release.ImagePath;
releaseEntity.ReleaseType = release.ReleaseType;
releaseEntity.CreatedByUserId = release.CreatedByUserId;
await Repository.UpdateReleaseAsync(releaseEntity);
}
}
var updated = await Repository.GetByIdAsync(track.Id);
return updated is not null
? ResultContainer<TrackDto>.CreatePassResult(TrackConverter.Convert(updated))