Block duplicate-release uploads by (title, artist): pre-flight check + server 409 backstop, with within-batch Cut attach via releaseId

This commit is contained in:
daniel-c-harvey
2026-06-19 15:44:41 -04:00
parent cfcc2693f2
commit bd85507308
9 changed files with 505 additions and 31 deletions
@@ -68,6 +68,7 @@ public class CmsTrackService : ICmsTrackService
ReleaseType releaseType,
int trackNumber,
ReleaseMedium medium = ReleaseMedium.Cut,
long? releaseId = null,
IProgress<long>? progress = null,
CancellationToken ct = default)
{
@@ -91,6 +92,9 @@ public class CmsTrackService : ICmsTrackService
// The upload endpoint binds "medium" to the created release's ReleaseMedium (defaulting to Cut
// for an unrecognised value). Authoritative only when this upload creates the release.
multipart.Add(new StringContent(medium.ToString()), "medium");
// releaseId present → ATTACH (rows 2..N of a within-batch Cut); absent → CREATE (server rejects a
// pre-existing (title, artist) as a duplicate). Only sent when set so the form omits it on row 1.
if (releaseId is { } rid) multipart.Add(new StringContent(rid.ToString()), "releaseId");
var send = await phase.SendAsync(UploadPath, multipart, $"upload of {trackName}");
if (send.Response is not { } response)
@@ -474,6 +478,53 @@ public class CmsTrackService : ICmsTrackService
}
}
public async Task<ResultContainer<ReleaseDto?>> GetExistingReleaseAsync(
string title, string artist, CancellationToken ct = default)
{
var client = _httpClientFactory.CreateClient(ContentCmsClientName);
var query = $"api/track/release/exists?title={Uri.EscapeDataString(title)}&artist={Uri.EscapeDataString(artist)}";
HttpResponseMessage response;
try
{
response = await client.GetAsync(query, ct);
}
catch (Exception ex)
{
_logger.LogError(ex, "Content API call failed for release existence check ({Title}, {Artist})", title, artist);
return ResultContainer<ReleaseDto?>.CreateFailResult("Content API is unreachable.");
}
using (response)
{
// 404 is the not-found (null) case, not a failure — no release matches this (title, artist).
if (response.StatusCode == HttpStatusCode.NotFound)
{
return ResultContainer<ReleaseDto?>.CreatePassResult(null);
}
if (!response.IsSuccessStatusCode)
{
_logger.LogError("Content API release existence check failed for ({Title}, {Artist}): {Status}",
title, artist, (int)response.StatusCode);
return ResultContainer<ReleaseDto?>.CreateFailResult("Failed to check for an existing release.");
}
ReleaseDto? release;
try
{
release = await response.Content.ReadFromJsonAsync<ReleaseDto>(ct);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to deserialize ReleaseDto from release existence check");
return ResultContainer<ReleaseDto?>.CreateFailResult("Content API returned an unexpected response.");
}
return ResultContainer<ReleaseDto?>.CreatePassResult(release);
}
}
private static readonly HashSet<string> KnownImageMimeTypes = new(StringComparer.OrdinalIgnoreCase)
{
"image/jpeg", "image/png", "image/gif", "image/webp", "image/svg+xml", "image/bmp"
@@ -25,6 +25,10 @@ public interface ICmsTrackService
/// sets Content-Length and is the denominator for <paramref name="progress"/>, which reports cumulative
/// bytes pushed to the wire. Each progress tick also resets the idle/heartbeat upload timeout, so a
/// stalled connection aborts without a fixed total-duration cap.
/// <paramref name="releaseId"/> distinguishes the two rows of a within-batch multi-track Cut: null on
/// the first row (CREATE — the server rejects a pre-existing (title, artist) as a duplicate) and the
/// id returned by that first row on rows 2..N (ATTACH — the server skips the duplicate check and adds
/// the track to the release the batch just created).
/// </summary>
Task<ResultContainer<TrackDto>> UploadTrackAsync(
Stream wavStream,
@@ -42,9 +46,20 @@ public interface ICmsTrackService
ReleaseType releaseType,
int trackNumber,
ReleaseMedium medium = ReleaseMedium.Cut,
long? releaseId = null,
IProgress<long>? progress = null,
CancellationToken ct = default);
/// <summary>
/// Upload-form pre-flight: returns the existing release whose exact (title, artist) matches, or null
/// when none exists. Backs the duplicate block the form runs BEFORE transferring bytes, so the admin
/// is not surprised at the end of a long upload. A 404 from the API is the not-found (null) case, not
/// a failure. The match semantics are the API's <c>GetReleaseByTitleAndArtist</c> — the same read the
/// server backstop uses — so the pre-flight and the backstop agree.
/// </summary>
Task<ResultContainer<ReleaseDto?>> GetExistingReleaseAsync(
string title, string artist, CancellationToken ct = default);
/// <summary>
/// Delete a track via the Content API, which removes the SQL row then the vault entry.
/// Maps a 404 to a "Track not found." failure.