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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user