@page "/tracks/upload" @using System.Security.Claims @using DeepDrftManager.Services @using DeepDrftModels.Enums @using Microsoft.AspNetCore.Components.Forms @attribute [Authorize] @inject ICmsTrackService CmsTrackService @inject ICmsReleaseService CmsReleaseService @inject AuthenticationStateProvider AuthStateProvider @inject NavigationManager Navigation @inject ISnackbar Snackbar @inject ILogger Logger Upload Release — DeepDrft CMS Upload Release @if (_medium == ReleaseMedium.Cut) { } else { @* Session/Mix are single-track releases — no multi-track master list. A single WAV slot. *@ Track @if (_tracks.Count > 0) { @* Track name is derived from the Release Name for Session/Mix — no separate input. *@ Selected: @(_tracks[0].WavFile?.Name ?? "—") @if (_tracks[0].Status == BatchRowStatus.Uploading) { } } } @if (!string.IsNullOrEmpty(_errorMessage)) { @_errorMessage } @if (!string.IsNullOrEmpty(_warningMessage)) { @_warningMessage } Cancel @if (_uploading) { Uploading @_uploadedCount / @_tracks.Count… } else { Upload Release } @code { // 1 GB ceiling matches DeepDrftAPI's per-request limit on api/track/upload; the // streaming path means the limit caps the request, not in-memory buffering. private const long MaxUploadBytes = 1_073_741_824L; private List _tracks = new(); private int _selectedIndex = -1; private bool _uploading; private int _uploadedCount; private string? _errorMessage; // Separate from _errorMessage: a soft non-blocking nudge (Severity.Warning), not a hard failure. private string? _warningMessage; private IBrowserFile? _selectedImageFile; private string? _imagePath; // Session-only: the hero image is resource-addressed and cannot be uploaded until the release // exists, so it is held here and POSTed to api/release/{id}/session/hero-image after create. private IBrowserFile? _heroImageFile; // Set true once the admin has acknowledged the missing-hero warning, so a second submit proceeds. private bool _heroWarningAcknowledged; private string _albumName = string.Empty; private string _artist = string.Empty; private string _genre = string.Empty; private string _description = string.Empty; private string _releaseDate = string.Empty; private ReleaseType _releaseType = ReleaseType.Single; private ReleaseMedium _medium = ReleaseMedium.Cut; // Optional pre-select from the Add-Track buttons (§8.E): /tracks/upload?medium=session lands the // form already in Session mode. A seed only — the medium selector stays user-changeable after load. // Unrecognised/absent values fall through to the Cut default (same defensive posture as the API's // TrackController.UploadTrack medium parse). [SupplyParameterFromQuery(Name = "medium")] public string? MediumParam { get; set; } protected override void OnInitialized() { // Seed the medium from the query param so a pre-selected upload form (e.g. the Sessions tab's // Add Track) lands already showing that medium's conditional fields. Goes through OnMediumChanged // so the single-track collapse runs identically to a user selector change. if (!string.IsNullOrWhiteSpace(MediumParam) && Enum.TryParse(MediumParam, ignoreCase: true, out var medium) && Enum.IsDefined(medium)) { OnMediumChanged(medium); } } // Switching to a single-track medium collapses any multi-track selection to the first row so the // single-track invariant holds before submit. The predicate reads the same MediumRules cardinality // declaration the upload service enforces, so the form and the domain cannot drift. private void OnMediumChanged(ReleaseMedium medium) { _medium = medium; if (MediumRules.CardinalityOf(medium).IsSingleTrack && _tracks.Count > 1) { _tracks.RemoveRange(1, _tracks.Count - 1); _selectedIndex = _tracks.Count > 0 ? 0 : -1; } } // Single-track WAV picker for Session/Mix: replaces the one row rather than appending. private void HandleSingleWavSelected(InputFileChangeEventArgs e) { _errorMessage = null; var file = e.File; if (!file.Name.EndsWith(".wav", StringComparison.OrdinalIgnoreCase)) { Snackbar.Add($"'{file.Name}' is not a .wav file.", Severity.Warning); return; } _tracks.Clear(); _tracks.Add(new BatchRowModel { WavFile = file, TrackName = Path.GetFileNameWithoutExtension(file.Name) }); _selectedIndex = 0; } private void HandleWavFilesSelected(IReadOnlyList files) { _errorMessage = null; foreach (var file in files) { if (!file.Name.EndsWith(".wav", StringComparison.OrdinalIgnoreCase)) { Snackbar.Add($"Skipped '{file.Name}' — not a .wav file.", Severity.Warning); continue; } _tracks.Add(new BatchRowModel { WavFile = file, TrackName = Path.GetFileNameWithoutExtension(file.Name) }); } if (_selectedIndex < 0 && _tracks.Count > 0) { _selectedIndex = 0; } } private void MoveUp(int i) { if (i == 0) return; (_tracks[i], _tracks[i - 1]) = (_tracks[i - 1], _tracks[i]); if (_selectedIndex == i) _selectedIndex = i - 1; else if (_selectedIndex == i - 1) _selectedIndex = i; } private void MoveDown(int i) { if (i == _tracks.Count - 1) return; (_tracks[i], _tracks[i + 1]) = (_tracks[i + 1], _tracks[i]); if (_selectedIndex == i) _selectedIndex = i + 1; else if (_selectedIndex == i + 1) _selectedIndex = i; } private void RemoveRow(int i) { _tracks.RemoveAt(i); if (i < _selectedIndex) _selectedIndex--; if (_selectedIndex >= _tracks.Count) _selectedIndex = _tracks.Count - 1; } private async Task SubmitAsync() { _errorMessage = null; _warningMessage = null; if (string.IsNullOrWhiteSpace(_albumName)) { _errorMessage = "Release Name is required."; return; } if (string.IsNullOrWhiteSpace(_artist)) { _errorMessage = "Artist is required."; return; } if (_tracks.Count == 0) { _errorMessage = "Add at least one track."; return; } if (!string.IsNullOrWhiteSpace(_releaseDate) && !DateOnly.TryParseExact(_releaseDate, "yyyy-MM-dd", out _)) { _errorMessage = "Release Date must be in YYYY-MM-DD format."; return; } foreach (var t in _tracks) { if (t.WavFile is null) { _errorMessage = $"'{t.TrackName}' has no WAV file selected."; return; } } var authState = await AuthStateProvider.GetAuthenticationStateAsync(); var userIdValue = authState.User.FindFirstValue(ClaimTypes.NameIdentifier); if (!long.TryParse(userIdValue, out var createdByUserId)) { // The page is gated by [Authorize] under the Admin role, so a missing or // unparseable id here is a configuration bug, not normal client state. Logger.LogError("Authenticated user has no parseable NameIdentifier claim: {Value}", userIdValue); _errorMessage = "Your session is missing a valid identifier. Please sign in again."; return; } // A Session's hero is its primary visual identity on the public detail page. It is optional — // a Session can be authored without one and set later from the Sessions browser — but a missing // hero is usually an oversight, so warn (do not block). The first submit without a hero shows the // warning and primes acknowledgment; a second submit proceeds. if (_medium == ReleaseMedium.Session && _heroImageFile is null && !_heroWarningAcknowledged) { _heroWarningAcknowledged = true; _warningMessage = "No hero image selected. A Session usually needs one — you can add it now, " + "or submit again to create the Session without it (set the hero later from the Sessions browser)."; 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) { _tracks[0].TrackName = _albumName; } _imagePath = null; // Clear any stale uploaded path from a prior partial attempt. _uploading = true; _uploadedCount = 0; try { // Upload any selected cover art once; abort the submit if it fails so we never // create tracks expecting an image that was never stored in the vault. if (_selectedImageFile is { } imgFile) { await using var imgStream = imgFile.OpenReadStream(maxAllowedSize: 50_000_000); var imgResult = await CmsTrackService.UploadImageAsync(imgStream, imgFile.Name, imgFile.ContentType); if (!imgResult.Success) { var imgError = imgResult.Messages.FirstOrDefault()?.Message ?? "Unknown error"; _errorMessage = $"Image upload failed: {imgError}"; return; } _imagePath = imgResult.Value; } int succeeded = 0, failed = 0; for (int i = 0; i < _tracks.Count; i++) { var row = _tracks[i]; var trackNumber = i + 1; // 1-based ordinal from list position row.Status = BatchRowStatus.Uploading; StateHasChanged(); row.UploadedBytes = 0; row.TotalBytes = row.WavFile!.Size; try { // OpenReadStream streams chunks from the browser via the SignalR circuit; the // service wraps it in ProgressStreamContent so the whole file is never materialised // in memory before DeepDrftAPI receives it, and reports bytes-on-the-wire back here. await using var wavStream = row.WavFile.OpenReadStream(MaxUploadBytes); // Progress ticks fire ~once per 80 KB; re-render only when the whole-percent changes // so a half-gig upload paints ~100 frames, not thousands. Progress marshals the // callback onto the component's renderer dispatcher, so StateHasChanged is safe here. var lastPercent = -1; var progress = new Progress(written => { row.UploadedBytes = written; if (row.UploadPercent != lastPercent) { lastPercent = row.UploadPercent; StateHasChanged(); } }); var result = await CmsTrackService.UploadTrackAsync( wavStream, row.WavFile.Size, row.WavFile.Name, row.WavFile.ContentType, row.TrackName, _artist, string.IsNullOrWhiteSpace(_albumName) ? null : _albumName, string.IsNullOrWhiteSpace(_genre) ? null : _genre, string.IsNullOrWhiteSpace(_description) ? null : _description, string.IsNullOrWhiteSpace(_releaseDate) ? null : _releaseDate, row.WavFile.Name, createdByUserId, _releaseType, trackNumber, _medium, progress); if (!result.Success || result.Value is null) { var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error"; row.Status = BatchRowStatus.Failed; row.ErrorMessage = error; failed++; Logger.LogWarning("Batch upload: track '{TrackName}' failed: {Error}", row.TrackName, error); } else { // 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) { var linkResult = await CmsTrackService.UpdateAsync( result.Value.Id, row.TrackName, _artist, string.IsNullOrWhiteSpace(_albumName) ? null : _albumName, string.IsNullOrWhiteSpace(_genre) ? null : _genre, string.IsNullOrWhiteSpace(_description) ? null : _description, string.IsNullOrWhiteSpace(_releaseDate) ? null : (DateOnly?)DateOnly.ParseExact(_releaseDate, "yyyy-MM-dd"), imgPath, _releaseType, _medium, trackNumber); if (!linkResult.Success) { // Non-blocking: track is persisted; cover art can be re-linked by editing. Logger.LogWarning("Batch upload: cover art link failed for '{TrackName}' (id={Id}) — track uploaded but image unlinked", row.TrackName, result.Value.Id); } } // Session hero image is resource-addressed, so it is uploaded here — after the // release exists and we have its id — within the same submit gesture. Non-blocking: // the Session is persisted; a failed hero upload is recoverable from the Sessions // browser's per-row Set/Replace hero action. if (_medium == ReleaseMedium.Session && _heroImageFile is { } heroFile && result.Value.ReleaseId is { } sessionReleaseId) { try { await using var heroStream = heroFile.OpenReadStream(maxAllowedSize: 50_000_000); var heroResult = await CmsReleaseService.UploadSessionHeroImageAsync( sessionReleaseId, heroStream, heroFile.Name, heroFile.ContentType); if (!heroResult.Success) { var heroError = heroResult.Messages.FirstOrDefault()?.Message ?? "Unknown error"; Logger.LogWarning("Batch upload: hero image upload failed for release {ReleaseId} ('{TrackName}'): {Error}", sessionReleaseId, row.TrackName, heroError); Snackbar.Add("Session uploaded, but the hero image failed. Set it from the Sessions browser.", Severity.Warning); } } catch (Exception heroEx) { Logger.LogError(heroEx, "Batch upload: exception uploading hero image for release {ReleaseId}", sessionReleaseId); Snackbar.Add("Session uploaded, but the hero image failed. Set it from the Sessions browser.", Severity.Warning); } } else if (_medium == ReleaseMedium.Session && _heroImageFile is not null) { // ReleaseId was null on a Session track result — internal inconsistency. // Hero file is held but cannot be uploaded without a release id; log and // surface so the admin can set it from the Sessions browser. Logger.LogWarning("Batch upload: Session track '{TrackName}' (id={Id}) has no ReleaseId — hero image dropped", row.TrackName, result.Value.Id); Snackbar.Add("Session uploaded, but the hero image could not be linked (no release id). Set it from the Sessions browser.", Severity.Warning); } // Mix uploads fire the server-side high-res waveform trigger (§3.4). The CMS // computes nothing — the API derives the datum from the audio it just stored. // Non-blocking: the track is persisted; a failed trigger is recoverable from // the Mixes browser's per-row Generate action. if (_medium == ReleaseMedium.Mix && result.Value.ReleaseId is { } mixReleaseId) { var waveformResult = await CmsReleaseService.GenerateMixWaveformAsync(mixReleaseId); if (!waveformResult.Success) { Logger.LogWarning("Batch upload: mix waveform trigger failed for release {ReleaseId} ('{TrackName}')", mixReleaseId, row.TrackName); Snackbar.Add("Mix uploaded, but waveform generation failed. Retry from the Mixes browser.", Severity.Warning); } } row.Status = BatchRowStatus.Done; succeeded++; } } catch (Exception ex) { Logger.LogError(ex, "Batch upload: exception uploading '{TrackName}'", row.TrackName); row.Status = BatchRowStatus.Failed; row.ErrorMessage = "Upload failed — please try again."; failed++; } _uploadedCount++; StateHasChanged(); } if (failed == 0) { Snackbar.Add($"Uploaded {succeeded} track(s).", Severity.Success); Navigation.NavigateTo("/releases"); } else { Snackbar.Add($"Uploaded {succeeded} track(s); {failed} failed — review errors below.", Severity.Warning); // Stay on page so the admin can see the failed rows. } } finally { _uploading = false; StateHasChanged(); } } }