using System.Net; using System.Net.Http.Headers; using System.Net.Http.Json; using DeepDrftModels.DTOs; using DeepDrftModels.Enums; using Models.Common; using NetBlocks.Models; namespace DeepDrftManager.Services; /// /// HTTP client over the DeepDrftAPI API for all CMS track operations. The Manager is /// InteractiveServer-only and holds no in-process data layer: every track read and write is a /// network call to DeepDrftAPI, which is the single authority over both the SQL metadata /// store and the binary audio vault. The ApiKey is baked into the DeepDrft.Content.Cms /// named client's default headers. /// public class CmsTrackService : ICmsTrackService { private const string ContentCmsClientName = "DeepDrft.Content.Cms"; private const string UploadClientName = "DeepDrft.Content.Cms.Upload"; private const string UploadPath = "api/track/upload"; // Idle/heartbeat window: abort an upload only after this long with zero bytes written to the wire. // The window resets on every progress tick, so a slow-but-moving half-gig upload never trips it; // a genuinely stalled socket does. Governs the BODY-STREAMING phase only. // Operator-tunable via Upload:IdleTimeoutSeconds. private const int DefaultIdleTimeoutSeconds = 90; // Response-wait budget: once the request body is fully on the wire the server runs AudioProcessor // decode → vault write → SQL persist. For a multi-GB WAV this can exceed 10 minutes. // The idle heartbeat goes silent after the last byte, so a separate, larger deadline governs the // response-wait phase so a fully-uploaded file is never killed mid-persist. // Operator-tunable via Upload:ResponseTimeoutSeconds. private const int DefaultResponseTimeoutSeconds = 1200; // 20 minutes private readonly IHttpClientFactory _httpClientFactory; private readonly ILogger _logger; private readonly TimeSpan _uploadIdleTimeout; private readonly TimeSpan _uploadResponseTimeout; public CmsTrackService( IHttpClientFactory httpClientFactory, IConfiguration configuration, ILogger logger) { _httpClientFactory = httpClientFactory; _logger = logger; var idleSeconds = configuration.GetValue("Upload:IdleTimeoutSeconds") ?? DefaultIdleTimeoutSeconds; _uploadIdleTimeout = TimeSpan.FromSeconds(idleSeconds > 0 ? idleSeconds : DefaultIdleTimeoutSeconds); var responseSeconds = configuration.GetValue("Upload:ResponseTimeoutSeconds") ?? DefaultResponseTimeoutSeconds; _uploadResponseTimeout = TimeSpan.FromSeconds(responseSeconds > 0 ? responseSeconds : DefaultResponseTimeoutSeconds); } public async Task> UploadTrackAsync( Stream wavStream, long contentLength, string fileName, string contentType, string trackName, string artist, string? album, string? genre, string? description, string? releaseDate, string? originalFileName, long createdByUserId, ReleaseType releaseType, int trackNumber, ReleaseMedium medium = ReleaseMedium.Cut, IProgress? progress = null, CancellationToken ct = default) { // Build the WAV part once; the two-phase send helper owns the cancellation plumbing. using var phase = new UploadPhase(this, ct); var wavContent = phase.WrapContent(wavStream, contentLength, contentType, progress); using var multipart = new MultipartFormDataContent(); multipart.Add(wavContent, "audioFile", fileName); multipart.Add(new StringContent(trackName), "trackName"); multipart.Add(new StringContent(artist), "artist"); if (!string.IsNullOrWhiteSpace(album)) multipart.Add(new StringContent(album), "album"); if (!string.IsNullOrWhiteSpace(genre)) multipart.Add(new StringContent(genre), "genre"); if (!string.IsNullOrWhiteSpace(description)) multipart.Add(new StringContent(description), "description"); if (!string.IsNullOrWhiteSpace(releaseDate)) multipart.Add(new StringContent(releaseDate), "releaseDate"); // Explicit field — decouples the admin-visible display name from the WAV part's content-disposition filename. if (!string.IsNullOrWhiteSpace(originalFileName)) multipart.Add(new StringContent(originalFileName), "originalFileName"); multipart.Add(new StringContent(createdByUserId.ToString()), "createdByUserId"); multipart.Add(new StringContent(releaseType.ToString()), "releaseType"); multipart.Add(new StringContent(trackNumber.ToString()), "trackNumber"); // 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"); var send = await phase.SendAsync(UploadPath, multipart, $"upload of {trackName}"); if (send.Response is not { } response) { return ResultContainer.CreateFailResult(send.FailureMessage!); } using (response) { if (!response.IsSuccessStatusCode) { var body = await response.Content.ReadAsStringAsync(ct); var statusCode = (int)response.StatusCode; if (statusCode >= 500) { _logger.LogError("Content API returned {Status} for upload of {TrackName}: {Body}", statusCode, trackName, body); return ResultContainer.CreateFailResult("Upload failed on the content server. Please try again."); } // 4xx: body is user-friendly validation text from DeepDrftAPI — relay as-is. _logger.LogWarning("Content API rejected upload: {Status} {Body}", statusCode, body); return ResultContainer.CreateFailResult( string.IsNullOrWhiteSpace(body) ? $"Upload rejected ({statusCode})." : body); } // The Content API now owns the dual-database write, so the response is the persisted // track DTO (Id > 0) — no SQL roundtrip here. TrackDto? persisted; try { persisted = await response.Content.ReadFromJsonAsync(ct); } catch (Exception ex) { _logger.LogError(ex, "Failed to deserialize TrackDto from Content API response"); return ResultContainer.CreateFailResult("Content API returned an unexpected response."); } if (persisted is null) { _logger.LogError("Content API returned a null TrackDto"); return ResultContainer.CreateFailResult("Content API returned an empty response."); } return ResultContainer.CreatePassResult(persisted); } } public async Task ReplaceTrackAudioAsync( long id, Stream wavStream, long contentLength, string fileName, string contentType, IProgress? progress = null, CancellationToken ct = default) { // Same two-phase send plumbing as UploadTrackAsync — a WAV replace is an equally large body. // The request carries only the audio part; the server resolves the track by route id and // preserves its metadata, so no metadata fields ride along. using var phase = new UploadPhase(this, ct); var wavContent = phase.WrapContent(wavStream, contentLength, contentType, progress); using var multipart = new MultipartFormDataContent(); multipart.Add(wavContent, "audioFile", fileName); var send = await phase.SendAsync($"api/track/{id}/replace-audio", multipart, $"replace of track {id}"); if (send.Response is not { } response) { return Result.CreateFailResult(send.FailureMessage!); } using (response) { if (response.IsSuccessStatusCode) { return Result.CreatePassResult(); } if (response.StatusCode == HttpStatusCode.NotFound) { return Result.CreateFailResult("Track not found."); } var body = await response.Content.ReadAsStringAsync(ct); var statusCode = (int)response.StatusCode; if (statusCode >= 500) { _logger.LogError("Content API returned {Status} for replace of track {TrackId}: {Body}", statusCode, id, body); return Result.CreateFailResult("Replace failed on the content server. Please try again."); } // 4xx: body is user-friendly validation text from DeepDrftAPI — relay as-is. _logger.LogWarning("Content API rejected replace of track {TrackId}: {Status} {Body}", id, statusCode, body); return Result.CreateFailResult( string.IsNullOrWhiteSpace(body) ? $"Replace rejected ({statusCode})." : body); } } /// /// Owns the two-phase cancellation for a large-body multipart send (the original upload and the /// audio replace share it identically): /// /// BODY-STREAMING phase (while bytes are on the wire): the idle CTS fires if no progress tick /// arrives within the idle window. Each chunk resets it, so a /// slow-but-moving body never trips it; a genuinely stalled socket does. /// /// RESPONSE-WAIT phase (after the last byte, while the server persists): the idle heartbeat goes /// silent, so a separate, larger budget is armed at body-complete and the idle timer is disarmed, /// guaranteeing a fully-sent body is never killed mid-persist. /// /// The send CTS links both phase tokens plus the caller's token. Single-sourcing this here keeps /// the idle/response-wait behaviour identical across every large-body call. /// private sealed class UploadPhase : IDisposable { private readonly CmsTrackService _owner; private readonly CancellationToken _callerToken; private readonly CancellationTokenSource _idleCts; private readonly CancellationTokenSource _responseCts; private readonly CancellationTokenSource _sendCts; public UploadPhase(CmsTrackService owner, CancellationToken callerToken) { _owner = owner; _callerToken = callerToken; _idleCts = CancellationTokenSource.CreateLinkedTokenSource(callerToken); _idleCts.CancelAfter(owner._uploadIdleTimeout); // responseCts starts disarmed; the body-complete callback arms it. _responseCts = CancellationTokenSource.CreateLinkedTokenSource(callerToken); _sendCts = CancellationTokenSource.CreateLinkedTokenSource(_idleCts.Token, _responseCts.Token); } public ProgressStreamContent WrapContent( Stream wavStream, long contentLength, string contentType, IProgress? progress) { var content = new ProgressStreamContent( wavStream, contentLength, written => { // One mechanism, three consumers: advance the UI meter, reset the idle heartbeat, // and on body-complete transition to the response-wait budget. progress?.Report(written); if (written < contentLength) { _idleCts.CancelAfter(_owner._uploadIdleTimeout); } else { // Last byte on the wire. Disarm the idle timer and start the response budget. _idleCts.CancelAfter(Timeout.InfiniteTimeSpan); _responseCts.CancelAfter(_owner._uploadResponseTimeout); } }); content.Headers.ContentType = new MediaTypeHeaderValue( string.IsNullOrWhiteSpace(contentType) ? "audio/wav" : contentType); return content; } public async Task SendAsync( string path, HttpContent content, string operationLabel) { // Dedicated upload client (InfiniteTimeSpan) so the two-phase CTS logic is the sole timeout // authority. Non-upload operations use the bounded "DeepDrft.Content.Cms" client. var client = _owner._httpClientFactory.CreateClient(UploadClientName); using var request = new HttpRequestMessage(HttpMethod.Post, path) { Content = content }; try { var response = await client.SendAsync( request, HttpCompletionOption.ResponseHeadersRead, _sendCts.Token); return LargeBodySendResult.Ok(response); } catch (OperationCanceledException) when (!_callerToken.IsCancellationRequested) { if (_idleCts.IsCancellationRequested) { _owner._logger.LogWarning("{Operation} stalled — no progress for {IdleSeconds}s; aborting.", operationLabel, _owner._uploadIdleTimeout.TotalSeconds); return LargeBodySendResult.Fail( $"{operationLabel} stalled — no data transferred for {_owner._uploadIdleTimeout.TotalSeconds:0}s. Please retry."); } _owner._logger.LogWarning("{Operation} timed out waiting for server response after {ResponseSeconds}s.", operationLabel, _owner._uploadResponseTimeout.TotalSeconds); return LargeBodySendResult.Fail( $"{operationLabel} timed out waiting for the server to respond after {_owner._uploadResponseTimeout.TotalSeconds:0}s. Please retry."); } catch (Exception ex) { _owner._logger.LogError(ex, "Content API call failed for {Operation}", operationLabel); return LargeBodySendResult.Fail("Content API is unreachable."); } } public void Dispose() { _sendCts.Dispose(); _responseCts.Dispose(); _idleCts.Dispose(); } } // Outcome of a two-phase send: either a live response the caller must dispose, or a user-facing // failure message. Exactly one is non-null. private readonly struct LargeBodySendResult { public HttpResponseMessage? Response { get; private init; } public string? FailureMessage { get; private init; } public static LargeBodySendResult Ok(HttpResponseMessage response) => new() { Response = response }; public static LargeBodySendResult Fail(string message) => new() { FailureMessage = message }; } public async Task DeleteTrackAsync(long id, CancellationToken ct = default) { var client = _httpClientFactory.CreateClient(ContentCmsClientName); HttpResponseMessage response; try { response = await client.DeleteAsync($"api/track/{id}", ct); } catch (Exception ex) { _logger.LogError(ex, "Content API call failed for delete of track {TrackId}", id); return Result.CreateFailResult("Content API is unreachable."); } using (response) { if (response.IsSuccessStatusCode) { return Result.CreatePassResult(); } if (response.StatusCode == HttpStatusCode.NotFound) { return Result.CreateFailResult("Track not found."); } var body = await response.Content.ReadAsStringAsync(ct); _logger.LogError("Content API delete failed for track {TrackId}: {Status} {Body}", id, (int)response.StatusCode, body); return Result.CreateFailResult("Failed to delete track."); } } public async Task DeleteReleaseAsync(long releaseId, CancellationToken ct = default) { var client = _httpClientFactory.CreateClient(ContentCmsClientName); HttpResponseMessage response; try { response = await client.DeleteAsync($"api/track/release/{releaseId}", ct); } catch (Exception ex) { _logger.LogError(ex, "Content API call failed for delete of release {ReleaseId}", releaseId); return Result.CreateFailResult("Content API is unreachable."); } using (response) { if (response.IsSuccessStatusCode) { return Result.CreatePassResult(); } if (response.StatusCode == HttpStatusCode.NotFound) { return Result.CreateFailResult("Release not found."); } var body = await response.Content.ReadAsStringAsync(ct); _logger.LogError("Content API delete failed for release {ReleaseId}: {Status} {Body}", releaseId, (int)response.StatusCode, body); return Result.CreateFailResult("Failed to delete release."); } } public async Task>> GetPagedAsync( int page, int pageSize, string? sortColumn, bool sortDescending, string? album = null, string? genre = null, CancellationToken ct = default) { var client = _httpClientFactory.CreateClient(ContentCmsClientName); var query = $"api/track/page?page={page}&pageSize={pageSize}&sortDescending={sortDescending}"; if (!string.IsNullOrWhiteSpace(sortColumn)) { query += $"&sortColumn={Uri.EscapeDataString(sortColumn)}"; } if (!string.IsNullOrWhiteSpace(album)) { query += $"&album={Uri.EscapeDataString(album)}"; } if (!string.IsNullOrWhiteSpace(genre)) { query += $"&genre={Uri.EscapeDataString(genre)}"; } HttpResponseMessage response; try { response = await client.GetAsync(query, ct); } catch (Exception ex) { _logger.LogError(ex, "Content API call failed for track page"); return ResultContainer>.CreateFailResult("Content API is unreachable."); } using (response) { if (!response.IsSuccessStatusCode) { _logger.LogError("Content API track page failed: {Status}", (int)response.StatusCode); return ResultContainer>.CreateFailResult("Failed to load tracks."); } PagedResult? paged; try { paged = await response.Content.ReadFromJsonAsync>(ct); } catch (Exception ex) { _logger.LogError(ex, "Failed to deserialize PagedResult from Content API response"); return ResultContainer>.CreateFailResult("Content API returned an unexpected response."); } if (paged is null) { _logger.LogError("Content API returned a null PagedResult"); return ResultContainer>.CreateFailResult("Content API returned an empty response."); } return ResultContainer>.CreatePassResult(paged); } } public async Task> GetByIdAsync(long id, CancellationToken ct = default) { var client = _httpClientFactory.CreateClient(ContentCmsClientName); HttpResponseMessage response; try { response = await client.GetAsync($"api/track/meta/{id}", ct); } catch (Exception ex) { _logger.LogError(ex, "Content API call failed for track {TrackId}", id); return ResultContainer.CreateFailResult("Content API is unreachable."); } using (response) { if (response.StatusCode == HttpStatusCode.NotFound) { return ResultContainer.CreatePassResult(null); } if (!response.IsSuccessStatusCode) { _logger.LogError("Content API track lookup failed for {TrackId}: {Status}", id, (int)response.StatusCode); return ResultContainer.CreateFailResult("Failed to load track."); } TrackDto? track; try { track = await response.Content.ReadFromJsonAsync(ct); } catch (Exception ex) { _logger.LogError(ex, "Failed to deserialize TrackDto from Content API response"); return ResultContainer.CreateFailResult("Content API returned an unexpected response."); } return ResultContainer.CreatePassResult(track); } } private static readonly HashSet KnownImageMimeTypes = new(StringComparer.OrdinalIgnoreCase) { "image/jpeg", "image/png", "image/gif", "image/webp", "image/svg+xml", "image/bmp" }; public async Task> UploadImageAsync( Stream imageStream, string fileName, string contentType, CancellationToken ct = default) { if (string.IsNullOrWhiteSpace(contentType) || !KnownImageMimeTypes.Contains(contentType)) { _logger.LogWarning("UploadImageAsync rejected: unsupported or missing content type '{ContentType}'", contentType); return ResultContainer.CreateFailResult($"Unsupported image type: {contentType}. Accepted: JPEG, PNG, GIF, WebP, SVG, BMP."); } using var multipart = new MultipartFormDataContent(); var imageContent = new StreamContent(imageStream); imageContent.Headers.ContentType = new MediaTypeHeaderValue(contentType); multipart.Add(imageContent, "image", fileName); var client = _httpClientFactory.CreateClient(ContentCmsClientName); using var request = new HttpRequestMessage(HttpMethod.Post, "api/image/upload") { Content = multipart }; HttpResponseMessage response; try { response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct); } catch (Exception ex) { _logger.LogError(ex, "Content API call failed for image upload of {FileName}", fileName); return ResultContainer.CreateFailResult("Content API is unreachable."); } using (response) { if (!response.IsSuccessStatusCode) { var body = await response.Content.ReadAsStringAsync(ct); var statusCode = (int)response.StatusCode; if (statusCode >= 500) { _logger.LogError("Content API returned {Status} for image upload of {FileName}: {Body}", statusCode, fileName, body); return ResultContainer.CreateFailResult("Image upload failed on the content server."); } // 4xx: body is user-friendly validation text from DeepDrftAPI — relay as-is. _logger.LogWarning("Content API rejected image upload: {Status} {Body}", statusCode, body); return ResultContainer.CreateFailResult( string.IsNullOrWhiteSpace(body) ? $"Image upload rejected ({statusCode})." : body); } ImageUploadResponse? payload; try { payload = await response.Content.ReadFromJsonAsync(ct); } catch (Exception ex) { _logger.LogError(ex, "Failed to deserialize image upload response from Content API"); return ResultContainer.CreateFailResult("Content API returned an unexpected response."); } if (payload is null || string.IsNullOrWhiteSpace(payload.EntryKey)) { _logger.LogError("Content API returned an empty entry key for image upload"); return ResultContainer.CreateFailResult("Content API returned an empty response."); } return ResultContainer.CreatePassResult(payload.EntryKey); } } private sealed record ImageUploadResponse(string EntryKey); public async Task UpdateAsync( long id, string trackName, string artist, string? album, string? genre, string? description, DateOnly? releaseDate, string? imagePath = null, ReleaseType? releaseType = null, ReleaseMedium? medium = null, int? trackNumber = null, CancellationToken ct = default) { var client = _httpClientFactory.CreateClient(ContentCmsClientName); var body = new { trackName, artist, album, genre, description, releaseDate, imagePath, releaseType = releaseType.HasValue ? (int?)releaseType.Value : null, medium = medium.HasValue ? (int?)medium.Value : null, trackNumber, }; HttpResponseMessage response; try { response = await client.PutAsJsonAsync($"api/track/meta/{id}", body, ct); } catch (Exception ex) { _logger.LogError(ex, "Content API call failed for update of track {TrackId}", id); return Result.CreateFailResult("Content API is unreachable."); } using (response) { if (response.IsSuccessStatusCode) { return Result.CreatePassResult(); } if (response.StatusCode == HttpStatusCode.NotFound) { return Result.CreateFailResult("Track not found."); } var responseBody = await response.Content.ReadAsStringAsync(ct); _logger.LogError("Content API update failed for track {TrackId}: {Status} {Body}", id, (int)response.StatusCode, responseBody); return Result.CreateFailResult("Failed to update track."); } } public async Task> GetWaveformStatusAsync(CancellationToken ct = default) { var client = _httpClientFactory.CreateClient(ContentCmsClientName); HttpResponseMessage response; try { response = await client.GetAsync("api/track/waveform-status", ct); } catch (Exception ex) { _logger.LogError(ex, "Content API call failed for waveform status"); return ResultContainer.CreateFailResult("Content API is unreachable."); } using (response) { if (!response.IsSuccessStatusCode) { _logger.LogError("Content API waveform status failed: {Status}", (int)response.StatusCode); return ResultContainer.CreateFailResult("Failed to load waveform status."); } WaveformStatusDto[]? status; try { status = await response.Content.ReadFromJsonAsync(ct); } catch (Exception ex) { _logger.LogError(ex, "Failed to deserialize waveform status from Content API response"); return ResultContainer.CreateFailResult("Content API returned an unexpected response."); } if (status is null) { _logger.LogError("Content API returned a null waveform status list"); return ResultContainer.CreateFailResult("Content API returned an empty response."); } return ResultContainer.CreatePassResult(status); } } public async Task GenerateWaveformProfileAsync(string entryKey, CancellationToken ct = default) { var client = _httpClientFactory.CreateClient(ContentCmsClientName); HttpResponseMessage response; try { response = await client.PostAsync($"api/track/{Uri.EscapeDataString(entryKey)}/waveform", null, ct); } catch (Exception ex) { _logger.LogError(ex, "Content API call failed for waveform generation of {EntryKey}", entryKey); return Result.CreateFailResult("Content API is unreachable."); } using (response) { if (response.IsSuccessStatusCode) { return Result.CreatePassResult(); } if (response.StatusCode == HttpStatusCode.NotFound) { return Result.CreateFailResult("Track audio not found."); } var body = await response.Content.ReadAsStringAsync(ct); _logger.LogError("Content API waveform generation failed for {EntryKey}: {Status} {Body}", entryKey, (int)response.StatusCode, body); return Result.CreateFailResult("Failed to generate waveform profile."); } } public async Task GenerateHighResWaveformAsync(string entryKey, CancellationToken ct = default) { var client = _httpClientFactory.CreateClient(ContentCmsClientName); HttpResponseMessage response; try { response = await client.PostAsync($"api/track/{Uri.EscapeDataString(entryKey)}/waveform/high-res", null, ct); } catch (Exception ex) { _logger.LogError(ex, "Content API call failed for high-res waveform generation of {EntryKey}", entryKey); return Result.CreateFailResult("Content API is unreachable."); } using (response) { if (response.IsSuccessStatusCode) { return Result.CreatePassResult(); } if (response.StatusCode == HttpStatusCode.NotFound) { return Result.CreateFailResult("Track audio not found."); } var body = await response.Content.ReadAsStringAsync(ct); _logger.LogError("Content API high-res waveform generation failed for {EntryKey}: {Status} {Body}", entryKey, (int)response.StatusCode, body); return Result.CreateFailResult("Failed to generate high-res waveform datum."); } } public async Task>> GetReleasesAsync(CancellationToken ct = default) { var client = _httpClientFactory.CreateClient(ContentCmsClientName); HttpResponseMessage response; try { response = await client.GetAsync("api/track/albums", ct); } catch (Exception ex) { _logger.LogError(ex, "Content API call failed for releases"); return ResultContainer>.CreateFailResult("Content API is unreachable."); } using (response) { if (!response.IsSuccessStatusCode) { _logger.LogError("Content API releases failed: {Status}", (int)response.StatusCode); return ResultContainer>.CreateFailResult("Failed to load albums."); } List? releases; try { releases = await response.Content.ReadFromJsonAsync>(ct); } catch (Exception ex) { _logger.LogError(ex, "Failed to deserialize releases from Content API response"); return ResultContainer>.CreateFailResult("Content API returned an unexpected response."); } if (releases is null) { _logger.LogError("Content API returned a null releases list"); return ResultContainer>.CreateFailResult("Content API returned an empty response."); } return ResultContainer>.CreatePassResult(releases); } } public async Task> GetTrackCountAsync(CancellationToken ct = default) { // Re-use the paged endpoint: a single-item page carries the full TotalCount, so no // dedicated count endpoint is needed. var paged = await GetPagedAsync(page: 1, pageSize: 1, sortColumn: null, sortDescending: false, ct: ct); if (!paged.Success || paged.Value is null) { var error = paged.Messages.FirstOrDefault()?.Message ?? "Failed to load track count."; return ResultContainer.CreateFailResult(error); } return ResultContainer.CreatePassResult(paged.Value.TotalCount); } }