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:
@@ -146,6 +146,9 @@
|
||||
private string _releaseDate = string.Empty;
|
||||
private ReleaseType _releaseType = ReleaseType.Single;
|
||||
private ReleaseMedium _medium = ReleaseMedium.Cut;
|
||||
// The id of the release being edited. New tracks added in this session attach to it via the upload
|
||||
// service's releaseId (ATTACH) path, so they are not rejected as a pre-existing-(title,artist) duplicate.
|
||||
private long? _releaseId;
|
||||
|
||||
// The medium selector drives ReleaseType visibility and is persisted on save: every UpdateAsync /
|
||||
// UploadTrackAsync call below passes _medium, and PUT api/track/meta resets ReleaseType to its
|
||||
@@ -214,6 +217,10 @@
|
||||
}
|
||||
|
||||
var release = tracks[0].Release;
|
||||
// The release being edited already exists, so any new track added here ATTACHES to it (the upload
|
||||
// service's releaseId path) rather than taking the CREATE path, which would reject it as a
|
||||
// duplicate (title, artist). Fall back to the track's own ReleaseId if the nav is not populated.
|
||||
_releaseId = release?.Id ?? tracks[0].ReleaseId;
|
||||
_albumName = albumName;
|
||||
_artist = release?.Artist ?? string.Empty;
|
||||
_genre = release?.Genre ?? string.Empty;
|
||||
@@ -592,6 +599,7 @@
|
||||
_releaseType,
|
||||
trackNumber,
|
||||
_medium,
|
||||
_releaseId,
|
||||
progress);
|
||||
|
||||
if (!uploadResult.Success || uploadResult.Value is null)
|
||||
|
||||
@@ -298,6 +298,29 @@
|
||||
return;
|
||||
}
|
||||
|
||||
// Pre-flight duplicate guard (primary block): the upload form creates new releases only, so a
|
||||
// (title, artist) that already exists in the catalogue is refused BEFORE any bytes transfer —
|
||||
// the admin is not surprised at the end of a long upload. The server backstops this on the
|
||||
// create path, but checking here keeps the failure fast and visible. The values passed match
|
||||
// exactly what the upload sends (untrimmed _albumName/_artist) so the pre-flight and the server
|
||||
// agree on the match. A check failure (API unreachable) blocks rather than proceeding blind.
|
||||
var duplicateCheck = await CmsTrackService.GetExistingReleaseAsync(_albumName, _artist);
|
||||
if (!duplicateCheck.Success)
|
||||
{
|
||||
var checkError = duplicateCheck.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
_errorMessage = $"Could not verify the release name: {checkError}";
|
||||
Snackbar.Add(_errorMessage, Severity.Error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (duplicateCheck.Value is { } existing)
|
||||
{
|
||||
_errorMessage = $"A release titled '{existing.Title}' by {existing.Artist} already exists. "
|
||||
+ "The upload form creates new releases only — use the edit tools to change an existing one.";
|
||||
Snackbar.Add(_errorMessage, Severity.Error);
|
||||
return;
|
||||
}
|
||||
|
||||
// For single-track media (Session/Mix) the track name is derived from the Release Name —
|
||||
// no separate Track Name input is shown. Sync here so the stored name always matches.
|
||||
if (MediumRules.CardinalityOf(_medium).IsSingleTrack && _tracks.Count > 0)
|
||||
@@ -327,6 +350,11 @@
|
||||
}
|
||||
|
||||
int succeeded = 0, failed = 0;
|
||||
// Within-batch attach: row 1 creates the release (no releaseId → CREATE path); once it
|
||||
// succeeds we carry its ReleaseId into rows 2..N so they ATTACH to the just-created release
|
||||
// rather than tripping the server's pre-existing-duplicate block. Only a multi-track Cut
|
||||
// reaches row 2 (single-track media collapse to one row).
|
||||
long? batchReleaseId = null;
|
||||
for (int i = 0; i < _tracks.Count; i++)
|
||||
{
|
||||
var row = _tracks[i];
|
||||
@@ -375,6 +403,7 @@
|
||||
_releaseType,
|
||||
trackNumber,
|
||||
_medium,
|
||||
batchReleaseId,
|
||||
progress);
|
||||
|
||||
if (!result.Success || result.Value is null)
|
||||
@@ -387,6 +416,15 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
// Capture the release id created by the first successful row so subsequent rows
|
||||
// attach to it (the within-batch multi-track Cut path). Only set once — later
|
||||
// rows must not overwrite it. A null ReleaseId here (loose track) leaves it null,
|
||||
// which is correct: a release-less upload has no within-batch release to attach to.
|
||||
if (batchReleaseId is null && result.Value.ReleaseId is { } createdReleaseId)
|
||||
{
|
||||
batchReleaseId = createdReleaseId;
|
||||
}
|
||||
|
||||
// The upload endpoint does not accept an imagePath, so link the cover art with
|
||||
// a follow-up metadata update — same two-step pattern BatchEdit uses.
|
||||
if (_imagePath is { } imgPath)
|
||||
@@ -487,7 +525,13 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
Snackbar.Add($"Uploaded {succeeded} track(s); {failed} failed — review errors below.", Severity.Warning);
|
||||
// Surface the actual reason, not just counts — a server rejection (duplicate, cardinality)
|
||||
// relays a human-readable message via row.ErrorMessage. Show the first failure's reason so
|
||||
// the admin sees WHY without scanning the rows; the per-row errors remain as detail.
|
||||
var firstError = _tracks.FirstOrDefault(t => t.Status == BatchRowStatus.Failed)?.ErrorMessage;
|
||||
var reason = string.IsNullOrWhiteSpace(firstError) ? "review errors below" : firstError;
|
||||
_errorMessage = succeeded == 0 ? reason : $"{succeeded} uploaded; {failed} failed: {reason}";
|
||||
Snackbar.Add($"Uploaded {succeeded} track(s); {failed} failed — {reason}", Severity.Warning);
|
||||
// Stay on page so the admin can see the failed rows.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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