Merge upload-duplicate-detection into dev (block duplicate-release uploads by title+artist)

This commit is contained in:
daniel-c-harvey
2026-06-19 16:22:28 -04:00
12 changed files with 588 additions and 51 deletions
+5 -1
View File
@@ -59,8 +59,12 @@ public interface ITrackService
/// Resolve the release matching <paramref name="title"/> + <paramref name="artist"/>, creating
/// one from <paramref name="releaseData"/> when none exists. Backs the upload flow's FK
/// resolution so a track lands on a shared release rather than duplicating release-cardinal data.
/// The <c>WasCreated</c> flag in the result is <see langword="true"/> when a new row was inserted
/// and <see langword="false"/> when an existing row was found (including after a lost concurrent-insert
/// race). The CREATE path in <c>UnifiedTrackService.UploadAsync</c> uses this to turn a
/// "found existing" outcome into a duplicate rejection rather than a silent attach.
/// </summary>
Task<ResultContainer<ReleaseDto>> FindOrCreateRelease(
Task<ResultContainer<(ReleaseDto Release, bool WasCreated)>> FindOrCreateRelease(
string title, string artist, ReleaseDto releaseData, CancellationToken cancellationToken = default);
/// <summary>
+9 -9
View File
@@ -164,14 +164,14 @@ public class TrackManager
}
}
public async Task<ResultContainer<ReleaseDto>> FindOrCreateRelease(
public async Task<ResultContainer<(ReleaseDto Release, bool WasCreated)>> 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));
return ResultContainer<(ReleaseDto, bool)>.CreatePassResult((TrackConverter.Convert(existing), false));
// 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.
@@ -186,21 +186,21 @@ public class TrackManager
try
{
var added = await Repository.AddReleaseAsync(entity, cancellationToken);
return ResultContainer<ReleaseDto>.CreatePassResult(TrackConverter.Convert(added));
return ResultContainer<(ReleaseDto, bool)>.CreatePassResult((TrackConverter.Convert(added), true));
}
catch (ClassifiedDbException ex) when (ex.Error.Category == DbErrorCategory.UniqueViolation)
{
// Concurrent upload inserted the same (title, artist) between our read and write.
// Re-query and return the winning row. Should not return null here since the
// constraint just fired, but re-throw if it does so the caller sees an error.
// Re-query and return the winning row as WasCreated=false so the caller (UploadAsync
// CREATE path) treats the lost race as a duplicate rather than silently attaching.
var race = await Repository.GetReleaseByTitleAndArtistAsync(title, artist, cancellationToken);
if (race is null) throw;
return ResultContainer<ReleaseDto>.CreatePassResult(TrackConverter.Convert(race));
return ResultContainer<(ReleaseDto, bool)>.CreatePassResult((TrackConverter.Convert(race), false));
}
}
catch (Exception e)
{
return ResultContainer<ReleaseDto>.CreateFailResult(e.Message);
return ResultContainer<(ReleaseDto, bool)>.CreateFailResult(e.Message);
}
}
@@ -302,13 +302,13 @@ public class TrackManager
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)
if (!resolved.Success)
{
var error = resolved.Messages.FirstOrDefault()?.Message ?? "Failed to resolve release.";
return ResultContainer<TrackDto>.CreateFailResult(error);
}
newTrack.ReleaseId = resolved.Value.Id;
newTrack.ReleaseId = resolved.Value.Release.Id;
}
var added = await Repository.AddAsync(TrackConverter.Convert(newTrack));