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 several-hundred-MB WAV this can take many 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 = 600; // 10 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) { // Two-phase cancellation for the upload send: // // BODY-STREAMING phase (while bytes are on the wire): // idleCts fires if no progress tick arrives within the idle window. Each // ProgressStreamContent chunk resets CancelAfter(idle), so a slow-but-moving // upload never trips it; a genuinely stalled socket does. // // RESPONSE-WAIT phase (after the last byte, while the server persists): // The idle heartbeat goes silent once the body is fully sent. responseCts is // armed at that moment with a larger budget so a fully-uploaded file is never // killed mid-persist. idleCts is simultaneously disarmed (CancelAfter(Infinite)) // so it cannot misfire during the response-wait. // // sendCts links both so either deadline — plus the caller's ct — cancels the send. using var idleCts = CancellationTokenSource.CreateLinkedTokenSource(ct); idleCts.CancelAfter(_uploadIdleTimeout); // responseCts starts disarmed; the body-complete callback below arms it. using var responseCts = CancellationTokenSource.CreateLinkedTokenSource(ct); // Umbrella token passed to SendAsync — either phase token (or the caller) can cancel. using var sendCts = CancellationTokenSource.CreateLinkedTokenSource(idleCts.Token, responseCts.Token); // Rebuild the multipart container so the boundary is owned by HttpClient and the // caller-supplied stream (already buffered by the SignalR upload) is the source. using var multipart = new MultipartFormDataContent(); var wavContent = 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) { // Body still in flight — keep the idle heartbeat alive. idleCts.CancelAfter(_uploadIdleTimeout); } else { // Last byte on the wire. Disarm the idle timer and start the response budget. idleCts.CancelAfter(Timeout.InfiniteTimeSpan); responseCts.CancelAfter(_uploadResponseTimeout); } }); wavContent.Headers.ContentType = new MediaTypeHeaderValue( string.IsNullOrWhiteSpace(contentType) ? "audio/wav" : contentType); 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"); // Use the dedicated upload client (InfiniteTimeSpan) so the two-phase CTS logic above is the // sole timeout authority. Non-upload operations use the bounded "DeepDrft.Content.Cms" client. var client = _httpClientFactory.CreateClient(UploadClientName); using var request = new HttpRequestMessage(HttpMethod.Post, UploadPath) { Content = multipart }; HttpResponseMessage response; try { response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, sendCts.Token); } catch (OperationCanceledException) when (!ct.IsCancellationRequested) { // Either idle window (body-streaming stall) or response-wait budget (server persist too slow). if (idleCts.IsCancellationRequested) { _logger.LogWarning("Upload of {TrackName} stalled — no progress for {IdleSeconds}s; aborting.", trackName, _uploadIdleTimeout.TotalSeconds); return ResultContainer.CreateFailResult( $"Upload stalled — no data transferred for {_uploadIdleTimeout.TotalSeconds:0}s. Please retry."); } // responseCts fired: body reached the server but persist timed out. _logger.LogWarning("Upload of {TrackName} timed out waiting for server response after {ResponseSeconds}s.", trackName, _uploadResponseTimeout.TotalSeconds); return ResultContainer.CreateFailResult( $"Upload timed out waiting for the server to respond after {_uploadResponseTimeout.TotalSeconds:0}s. Please retry."); } catch (Exception ex) { _logger.LogError(ex, "Content API call failed for upload of {TrackName}", trackName); 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 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 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); } }