From f4046025361939d8fb7f36837d184aeb6352beba Mon Sep 17 00:00:00 2001 From: Daniel Harvey Date: Mon, 25 May 2026 08:46:09 -0400 Subject: [PATCH] refactor: make DeepDrftContent sole authority over track SQL + vault; Manager goes HTTP-only --- .../Controllers/TrackController.cs | 362 +++++++++++------- DeepDrftContent/DeepDrftContent.csproj | 5 + .../Models/UpdateTrackMetadataRequest.cs | 12 + DeepDrftContent/Program.cs | 20 +- .../Services/UnifiedTrackService.cs | 117 ++++++ .../Components/Pages/Tracks/TrackEdit.razor | 28 +- .../Components/Pages/Tracks/TrackList.razor | 3 +- DeepDrftManager/Components/_Imports.razor | 1 - DeepDrftManager/DeepDrftManager.csproj | 5 - DeepDrftManager/Program.cs | 19 +- DeepDrftManager/Services/CmsTrackService.cs | 245 ++++++++---- DeepDrftManager/Services/ICmsTrackService.cs | 42 +- 12 files changed, 600 insertions(+), 259 deletions(-) create mode 100644 DeepDrftContent/Models/UpdateTrackMetadataRequest.cs create mode 100644 DeepDrftContent/Services/UnifiedTrackService.cs diff --git a/DeepDrftContent/Controllers/TrackController.cs b/DeepDrftContent/Controllers/TrackController.cs index 2cfaa88..29d306a 100644 --- a/DeepDrftContent/Controllers/TrackController.cs +++ b/DeepDrftContent/Controllers/TrackController.cs @@ -3,6 +3,9 @@ using DeepDrftContent.Data.Constants; using DeepDrftContent.Data.FileDatabase.Models; using DeepDrftContent.Data.FileDatabase.Services; using DeepDrftContent.Middleware; +using DeepDrftContent.Models; +using DeepDrftContent.Services; +using DeepDrftData; using Microsoft.AspNetCore.Mvc; namespace DeepDrftContent.Controllers; @@ -13,6 +16,8 @@ public class TrackController : ControllerBase { private readonly DeepDrftContent.Data.TrackService _trackService; private readonly WavOffsetService _wavOffsetService; + private readonly UnifiedTrackService _unifiedService; + private readonly ITrackService _sqlTrackService; private readonly ILogger _logger; // FileDatabase is injected directly for PutTrack because that endpoint receives a pre-processed @@ -25,14 +30,238 @@ public class TrackController : ControllerBase DeepDrftContent.Data.TrackService trackService, DeepDrftContent.Data.FileDatabase.Services.FileDatabase fileDatabase, WavOffsetService wavOffsetService, + UnifiedTrackService unifiedService, + ITrackService sqlTrackService, ILogger logger) { _trackService = trackService; _fileDatabase = fileDatabase; _wavOffsetService = wavOffsetService; + _unifiedService = unifiedService; + _sqlTrackService = sqlTrackService; _logger = logger; } + // --- Literal-segment routes first --- + // These are declared before the parameterized "{trackId}" / "{id:long}" actions so route + // resolution never treats "page", "upload", or "meta" as a trackId. + + // GET api/track/page?page=1&pageSize=20&sortColumn=TrackName&sortDescending=false + // CMS metadata listing — paged read straight from SQL. + [ApiKeyAuthorize] + [HttpGet("page")] + public async Task GetPage( + [FromQuery] int page = 1, + [FromQuery] int pageSize = 20, + [FromQuery] string? sortColumn = null, + [FromQuery] bool sortDescending = false, + CancellationToken cancellationToken = default) + { + var result = await _sqlTrackService.GetPaged(page, pageSize, sortColumn, sortDescending, cancellationToken); + if (!result.Success || result.Value is null) + { + var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error"; + _logger.LogError("GetPage failed: {Error}", error); + return StatusCode(500, "Failed to load tracks"); + } + + return Ok(result.Value); + } + + // POST api/track/upload: raw WAV in (multipart/form-data) + metadata → persisted TrackEntity out. + // Used by the CMS upload flow on DeepDrftManager; that host proxies the upload here so it never + // touches the vault disk path or SQL directly. UnifiedTrackService owns the two-database write. + // + // RequestSizeLimit/MultipartBodyLengthLimit set to 1 GB: WAV uploads can be tens to hundreds + // of MB and the framework defaults (~28 MB) reject them outright. The IFormFile path streams + // the body to a temp file once Kestrel surfaces it, so the limit is the per-request ceiling, + // not a buffered allocation. + [ApiKeyAuthorize] + [HttpPost("upload")] + [RequestSizeLimit(1_073_741_824)] + [RequestFormLimits(MultipartBodyLengthLimit = 1_073_741_824)] + public async Task> UploadTrack( + [FromForm] IFormFile? wav, + [FromForm] string? trackName, + [FromForm] string? artist, + [FromForm] string? album, + [FromForm] string? genre, + [FromForm] string? releaseDate, + [FromForm] long createdByUserId, + CancellationToken cancellationToken) + { + _logger.LogInformation("UploadTrack called: trackName={TrackName}, artist={Artist}, size={Size}", + trackName, artist, wav?.Length); + + if (wav is null || wav.Length == 0) + { + return BadRequest("WAV file is required"); + } + + if (string.IsNullOrWhiteSpace(trackName)) + { + return BadRequest("trackName is required"); + } + + if (string.IsNullOrWhiteSpace(artist)) + { + return BadRequest("artist is required"); + } + + if (!string.Equals(Path.GetExtension(wav.FileName), ".wav", StringComparison.OrdinalIgnoreCase)) + { + return BadRequest("Uploaded file must have a .wav extension"); + } + + DateOnly? parsedReleaseDate = null; + if (!string.IsNullOrWhiteSpace(releaseDate)) + { + if (!DateOnly.TryParseExact(releaseDate, "yyyy-MM-dd", out var parsed)) + { + return BadRequest("releaseDate must be in YYYY-MM-DD format"); + } + parsedReleaseDate = parsed; + } + + // AudioProcessor.ProcessWavFileAsync requires a path ending in .wav and reads from disk. + // Path.GetTempFileName() yields .tmp, which fails that check — generate our own .wav path. + var tempPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N") + ".wav"); + + try + { + await using (var tempStream = new FileStream( + tempPath, FileMode.CreateNew, FileAccess.Write, FileShare.None, + bufferSize: 81920, useAsync: true)) + await using (var uploadStream = wav.OpenReadStream()) + { + await uploadStream.CopyToAsync(tempStream, cancellationToken); + } + + var result = await _unifiedService.UploadAsync( + tempPath, + trackName, + artist, + string.IsNullOrWhiteSpace(album) ? null : album, + string.IsNullOrWhiteSpace(genre) ? null : genre, + parsedReleaseDate, + createdByUserId, + cancellationToken); + + if (!result.Success || result.Value is null) + { + var error = result.Messages.FirstOrDefault()?.Message ?? "Failed to process and store WAV"; + _logger.LogWarning("UploadTrack: UnifiedTrackService failed for {TrackName}: {Error}", trackName, error); + return StatusCode(500, error); + } + + _logger.LogInformation("UploadTrack succeeded: id={Id}, entryKey={EntryKey}", result.Value.Id, result.Value.EntryKey); + return Ok(result.Value); + } + catch (Exception ex) + { + _logger.LogError(ex, "UploadTrack failed for {TrackName}", trackName); + return StatusCode(500, "Internal server error"); + } + finally + { + try + { + if (System.IO.File.Exists(tempPath)) + { + System.IO.File.Delete(tempPath); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "UploadTrack: failed to delete temp file {TempPath}", tempPath); + } + } + } + + // GET api/track/meta/{id}: single track metadata from SQL. + [ApiKeyAuthorize] + [HttpGet("meta/{id:long}")] + public async Task GetMeta(long id) + { + var result = await _sqlTrackService.GetById(id); + if (!result.Success) + { + var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error"; + _logger.LogError("GetMeta failed for {TrackId}: {Error}", id, error); + return StatusCode(500, "Failed to load track"); + } + + if (result.Value is null) + { + return NotFound(); + } + + return Ok(result.Value); + } + + // PUT api/track/meta/{id}: metadata-only update. EntryKey is immutable and not part of the body. + [ApiKeyAuthorize] + [HttpPut("meta/{id:long}")] + public async Task UpdateMeta(long id, [FromBody] UpdateTrackMetadataRequest request) + { + var lookup = await _sqlTrackService.GetById(id); + if (!lookup.Success) + { + var error = lookup.Messages.FirstOrDefault()?.Message ?? "Unknown error"; + _logger.LogError("UpdateMeta lookup failed for {TrackId}: {Error}", id, error); + return StatusCode(500, "Failed to load track"); + } + + if (lookup.Value is null) + { + return NotFound(); + } + + var track = lookup.Value; + track.TrackName = request.TrackName; + track.Artist = request.Artist; + track.Album = request.Album; + track.Genre = request.Genre; + track.ReleaseDate = request.ReleaseDate; + + var update = await _sqlTrackService.Update(track); + if (!update.Success) + { + var error = update.Messages.FirstOrDefault()?.Message ?? "Unknown error"; + _logger.LogError("UpdateMeta failed for {TrackId}: {Error}", id, error); + return StatusCode(500, "Failed to update track"); + } + + return Ok(); + } + + // DELETE api/track/{id}: removes the SQL row then the vault entry. UnifiedTrackService owns + // the ordering and orphan handling. Declared (with the long route constraint) before the + // string "{trackId}" GET so a numeric id routes here. + [ApiKeyAuthorize] + [HttpDelete("{id:long}")] + public async Task DeleteTrack(long id, CancellationToken cancellationToken) + { + _logger.LogInformation("DeleteTrack called with id: {Id}", id); + + var result = await _unifiedService.DeleteAsync(id, cancellationToken); + if (result.Success) + { + return Ok(); + } + + var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error"; + if (string.Equals(error, "Track not found.", StringComparison.Ordinal)) + { + return NotFound(); + } + + _logger.LogError("DeleteTrack failed for id {Id}: {Error}", id, error); + return StatusCode(500, error); + } + + // --- Parameterized routes --- + [HttpGet("{trackId}")] public async Task GetTrack(string trackId, [FromQuery] long offset = 0) { @@ -120,112 +349,6 @@ public class TrackController : ControllerBase } } - // POST api/track/upload: raw WAV in (multipart/form-data) + metadata → unpersisted TrackEntity out. - // Used by the CMS upload flow on DeepDrftPublic; that host proxies the upload here so it never - // touches the vault disk path directly (Option B in CMS-PLAN §5). - // - // RequestSizeLimit/MultipartBodyLengthLimit set to 1 GB: WAV uploads can be tens to hundreds - // of MB and the framework defaults (~28 MB) reject them outright. The IFormFile path streams - // the body to a temp file once Kestrel surfaces it, so the limit is the per-request ceiling, - // not a buffered allocation. - [ApiKeyAuthorize] - [HttpPost("upload")] - [RequestSizeLimit(1_073_741_824)] - [RequestFormLimits(MultipartBodyLengthLimit = 1_073_741_824)] - public async Task> UploadTrack( - [FromForm] IFormFile? wav, - [FromForm] string? trackName, - [FromForm] string? artist, - [FromForm] string? album, - [FromForm] string? genre, - [FromForm] string? releaseDate, - CancellationToken cancellationToken) - { - _logger.LogInformation("UploadTrack called: trackName={TrackName}, artist={Artist}, size={Size}", - trackName, artist, wav?.Length); - - if (wav is null || wav.Length == 0) - { - return BadRequest("WAV file is required"); - } - - if (string.IsNullOrWhiteSpace(trackName)) - { - return BadRequest("trackName is required"); - } - - if (string.IsNullOrWhiteSpace(artist)) - { - return BadRequest("artist is required"); - } - - if (!string.Equals(Path.GetExtension(wav.FileName), ".wav", StringComparison.OrdinalIgnoreCase)) - { - return BadRequest("Uploaded file must have a .wav extension"); - } - - DateOnly? parsedReleaseDate = null; - if (!string.IsNullOrWhiteSpace(releaseDate)) - { - if (!DateOnly.TryParseExact(releaseDate, "yyyy-MM-dd", out var parsed)) - { - return BadRequest("releaseDate must be in YYYY-MM-DD format"); - } - parsedReleaseDate = parsed; - } - - // AudioProcessor.ProcessWavFileAsync requires a path ending in .wav and reads from disk. - // Path.GetTempFileName() yields .tmp, which fails that check — generate our own .wav path. - var tempPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N") + ".wav"); - - try - { - await using (var tempStream = new FileStream( - tempPath, FileMode.CreateNew, FileAccess.Write, FileShare.None, - bufferSize: 81920, useAsync: true)) - await using (var uploadStream = wav.OpenReadStream()) - { - await uploadStream.CopyToAsync(tempStream, cancellationToken); - } - - var entity = await _trackService.AddTrackFromWavAsync( - tempPath, - trackName, - artist, - string.IsNullOrWhiteSpace(album) ? null : album, - string.IsNullOrWhiteSpace(genre) ? null : genre, - parsedReleaseDate); - - if (entity is null) - { - _logger.LogWarning("UploadTrack: TrackService returned null for {TrackName}", trackName); - return StatusCode(500, "Failed to process and store WAV"); - } - - _logger.LogInformation("UploadTrack succeeded: entryKey={EntryKey}", entity.EntryKey); - return Ok(entity); - } - catch (Exception ex) - { - _logger.LogError(ex, "UploadTrack failed for {TrackName}", trackName); - return StatusCode(500, "Internal server error"); - } - finally - { - try - { - if (System.IO.File.Exists(tempPath)) - { - System.IO.File.Delete(tempPath); - } - } - catch (Exception ex) - { - _logger.LogWarning(ex, "UploadTrack: failed to delete temp file {TempPath}", tempPath); - } - } - } - [ApiKeyAuthorize] [HttpPut("{trackId}")] public async Task PutTrack(string trackId, [FromBody] AudioBinaryDto track) @@ -248,31 +371,4 @@ public class TrackController : ControllerBase DeepDrftContent.Data.Constants.VaultConstants.Tracks, trackId, audioBinary); return success ? Ok() : BadRequest("Failed to store audio track"); } - - [ApiKeyAuthorize] - [HttpDelete("{entryKey}")] - public async Task DeleteTrack(string entryKey) - { - _logger.LogInformation("DeleteTrack called with entryKey: {EntryKey}", entryKey); - - // RemoveResourceAsync distinguishes three outcomes per FileDatabase's error-swallow contract: - // null → vault missing or unexpected error → 500 - // false → entry not present (already deleted or never existed) → 404 - // true → entry removed → 200 - var outcome = await _fileDatabase.RemoveResourceAsync(VaultConstants.Tracks, entryKey); - if (outcome == null) - { - _logger.LogError("DeleteTrack failed for entryKey: {EntryKey} (vault missing or remove error)", entryKey); - return StatusCode(500, "Internal server error"); - } - - if (outcome == false) - { - _logger.LogWarning("DeleteTrack: entry not found: {EntryKey}", entryKey); - return NotFound(); - } - - _logger.LogInformation("DeleteTrack: removed entry {EntryKey}", entryKey); - return Ok(); - } } diff --git a/DeepDrftContent/DeepDrftContent.csproj b/DeepDrftContent/DeepDrftContent.csproj index d04b35e..7b03d00 100644 --- a/DeepDrftContent/DeepDrftContent.csproj +++ b/DeepDrftContent/DeepDrftContent.csproj @@ -8,11 +8,16 @@ + + + + + diff --git a/DeepDrftContent/Models/UpdateTrackMetadataRequest.cs b/DeepDrftContent/Models/UpdateTrackMetadataRequest.cs new file mode 100644 index 0000000..0d83930 --- /dev/null +++ b/DeepDrftContent/Models/UpdateTrackMetadataRequest.cs @@ -0,0 +1,12 @@ +namespace DeepDrftContent.Models; + +/// +/// Body of PUT api/track/meta/{id}. Metadata-only — EntryKey is immutable and never +/// travels over this surface. +/// +public record UpdateTrackMetadataRequest( + string TrackName, + string Artist, + string? Album, + string? Genre, + DateOnly? ReleaseDate); diff --git a/DeepDrftContent/Program.cs b/DeepDrftContent/Program.cs index 91b3e0f..a7b66a0 100644 --- a/DeepDrftContent/Program.cs +++ b/DeepDrftContent/Program.cs @@ -1,8 +1,12 @@ using DeepDrftContent; -using DeepDrftContent.Data.FileDatabase.Services; using DeepDrftContent.Middleware; using DeepDrftContent.Models; +using DeepDrftContent.Services; +using DeepDrftData; +using DeepDrftData.Data; +using DeepDrftData.Repositories; using Microsoft.AspNetCore.HttpOverrides; +using Microsoft.EntityFrameworkCore; using NetBlocks.Utilities.Environment; var builder = WebApplication.CreateBuilder(args); @@ -38,6 +42,20 @@ builder.Configuration.AddJsonFile(apiKeyPath, optional: false, reloadOnChange: f var apiKeySettings = builder.Configuration.GetSection(nameof(ApiKeySettings)).Get(); if (apiKeySettings is null) { throw new Exception("API key settings are not configured"); } +// SQL connection string — DeepDrftContent now owns both vault (FileDatabase) and SQL metadata. +var connectionsPath = CredentialTools.ResolvePathOrThrow("connections", "environment/connections.json"); +builder.Configuration.AddJsonFile(connectionsPath, optional: false, reloadOnChange: false); + +// SQL metadata domain — DbContext + repository + manager (scoped; DbContext is not thread-safe). +// UnifiedTrackService orchestrates the two databases and is the single authority over track data. +builder.Services.AddDbContext(options => + options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection"))); +builder.Services + .AddScoped() + .AddScoped() + .AddScoped(sp => sp.GetRequiredService()); +builder.Services.AddScoped(); + // Configure forwarded headers for reverse proxy support builder.Services.Configure(options => { diff --git a/DeepDrftContent/Services/UnifiedTrackService.cs b/DeepDrftContent/Services/UnifiedTrackService.cs new file mode 100644 index 0000000..70cdf7d --- /dev/null +++ b/DeepDrftContent/Services/UnifiedTrackService.cs @@ -0,0 +1,117 @@ +using DeepDrftContent.Data.Constants; +using DeepDrftData; +using DeepDrftModels.Entities; +using NetBlocks.Models; +using ContentTrackService = DeepDrftContent.Data.TrackService; +using FileDb = DeepDrftContent.Data.FileDatabase.Services.FileDatabase; + +namespace DeepDrftContent.Services; + +/// +/// Host-internal orchestrator that makes DeepDrftContent the single authority over both the +/// vault (FileDatabase) and SQL metadata (DeepDrftData). Owns the two-database write/delete +/// flow so the controller stays a thin HTTP boundary and no caller coordinates the two stores. +/// +public class UnifiedTrackService +{ + private readonly ContentTrackService _contentTrackService; + private readonly ITrackService _sqlTrackService; + private readonly FileDb _fileDatabase; + private readonly ILogger _logger; + + public UnifiedTrackService( + ContentTrackService contentTrackService, + ITrackService sqlTrackService, + FileDb fileDatabase, + ILogger logger) + { + _contentTrackService = contentTrackService; + _sqlTrackService = sqlTrackService; + _fileDatabase = fileDatabase; + _logger = logger; + } + + /// + /// Process a WAV into the vault, then persist its metadata to SQL. On success the returned + /// entity carries the SQL-assigned Id. If the vault write succeeds but the SQL persist fails, + /// the audio is orphaned under EntryKey — logged loudly so it is recoverable manually. + /// + public async Task> UploadAsync( + string tempFilePath, + string trackName, + string artist, + string? album, + string? genre, + DateOnly? releaseDate, + long createdByUserId, + CancellationToken ct) + { + var unpersisted = await _contentTrackService.AddTrackFromWavAsync( + tempFilePath, trackName, artist, album, genre, releaseDate); + + if (unpersisted is null) + { + _logger.LogWarning("UploadAsync: content TrackService returned null for {TrackName}", trackName); + return ResultContainer.CreateFailResult("Failed to process and store WAV."); + } + + unpersisted.CreatedByUserId = createdByUserId; + + var saveResult = await _sqlTrackService.Create(unpersisted); + if (!saveResult.Success || saveResult.Value is null) + { + // Vault write succeeded, SQL persist failed — audio is orphaned in the tracks vault + // under EntryKey. Log loudly (include EntryKey) so it is recoverable manually. + var error = saveResult.Messages.FirstOrDefault()?.Message ?? "Unknown error"; + _logger.LogError( + "Track persisted to vault but SQL save failed. Orphaned entry: {EntryKey}. Error: {Error}", + unpersisted.EntryKey, error); + return ResultContainer.CreateFailResult($"Track was uploaded but could not be saved: {error}"); + } + + return saveResult; + } + + /// + /// Delete a track's SQL row, then its vault entry. SQL is the source of truth: a SQL delete + /// failure fails the operation (and leaves the vault untouched), but a subsequent vault delete + /// failure is logged as an orphan and swallowed — it is a maintenance concern, not user-facing. + /// + public async Task DeleteAsync(long id, CancellationToken ct) + { + var lookup = await _sqlTrackService.GetById(id); + if (!lookup.Success) + { + var error = lookup.Messages.FirstOrDefault()?.Message ?? "unknown error"; + _logger.LogError("DeleteAsync: GetById failed for track {TrackId}: {Error}", id, error); + return Result.CreateFailResult("Failed to load track."); + } + + if (lookup.Value is null) + { + return Result.CreateFailResult("Track not found."); + } + + var entryKey = lookup.Value.EntryKey; + + var sqlDelete = await _sqlTrackService.Delete(id); + if (!sqlDelete.Success) + { + var error = sqlDelete.Messages.FirstOrDefault()?.Message; + _logger.LogError("DeleteAsync: SQL delete failed for track {TrackId}: {Error}", id, error); + return Result.CreateFailResult("Failed to delete track."); + } + + // Tri-state per FileDatabase's error-swallow contract: null = vault missing/error, + // false = entry not present, true = removed. Anything but a clean removal is an orphan. + var removed = await _fileDatabase.RemoveResourceAsync(VaultConstants.Tracks, entryKey); + if (removed is not true) + { + _logger.LogWarning( + "Vault delete did not remove entry after SQL delete. {TrackId} {EntryKey} outcome={Outcome}", + id, entryKey, removed); + } + + return Result.CreatePassResult(); + } +} diff --git a/DeepDrftManager/Components/Pages/Tracks/TrackEdit.razor b/DeepDrftManager/Components/Pages/Tracks/TrackEdit.razor index e15f24d..322262f 100644 --- a/DeepDrftManager/Components/Pages/Tracks/TrackEdit.razor +++ b/DeepDrftManager/Components/Pages/Tracks/TrackEdit.razor @@ -1,7 +1,6 @@ @page "/cms/tracks/{Id:long}" @using DeepDrftManager.Services @attribute [Authorize] -@inject ITrackService TrackService @inject ICmsTrackService CmsTrackService @inject ISnackbar Snackbar @inject IDialogService DialogService @@ -108,7 +107,7 @@ private async Task LoadAsync() { _loading = true; - var result = await TrackService.GetById(Id); + var result = await CmsTrackService.GetByIdAsync(Id); _track = result.Success ? result.Value : null; if (_track is not null) { @@ -124,23 +123,14 @@ _busy = true; try { - // Re-fetch under the current scope so we mutate the DB-authoritative entity, not - // the copy loaded at OnInitialized. Metadata-only update — EntryKey is immutable. - var lookup = await TrackService.GetById(Id); - if (!lookup.Success || lookup.Value is null) - { - Snackbar.Add("Save failed — track could not be loaded.", Severity.Error); - return; - } - - var track = lookup.Value; - track.TrackName = _form.TrackName; - track.Artist = _form.Artist; - track.Album = string.IsNullOrWhiteSpace(_form.Album) ? null : _form.Album; - track.Genre = string.IsNullOrWhiteSpace(_form.Genre) ? null : _form.Genre; - track.ReleaseDate = _form.ReleaseDate is { } d ? DateOnly.FromDateTime(d) : null; - - var updated = await TrackService.Update(track); + // Metadata-only update over HTTP — EntryKey is immutable and not sent. The Content + // API loads the authoritative row and applies these fields. + var releaseDate = _form.ReleaseDate is { } d ? DateOnly.FromDateTime(d) : (DateOnly?)null; + var updated = await CmsTrackService.UpdateAsync( + Id, _form.TrackName, _form.Artist, + string.IsNullOrWhiteSpace(_form.Album) ? null : _form.Album, + string.IsNullOrWhiteSpace(_form.Genre) ? null : _form.Genre, + releaseDate); if (updated.Success) { Snackbar.Add("Track updated.", Severity.Success); diff --git a/DeepDrftManager/Components/Pages/Tracks/TrackList.razor b/DeepDrftManager/Components/Pages/Tracks/TrackList.razor index 378695f..02738cb 100644 --- a/DeepDrftManager/Components/Pages/Tracks/TrackList.razor +++ b/DeepDrftManager/Components/Pages/Tracks/TrackList.razor @@ -2,7 +2,6 @@ @using System.Net @using DeepDrftManager.Services @attribute [Authorize] -@inject ITrackService TrackService @inject ICmsTrackService CmsTrackService @inject IDialogService DialogService @inject ISnackbar Snackbar @@ -83,7 +82,7 @@ var sortColumn = string.IsNullOrEmpty(state.SortLabel) ? "TrackName" : state.SortLabel; var sortDescending = state.SortDirection == SortDirection.Descending; - var result = await TrackService.GetPaged(pageNumber, state.PageSize, sortColumn, sortDescending, cancellationToken); + var result = await CmsTrackService.GetPagedAsync(pageNumber, state.PageSize, sortColumn, sortDescending, cancellationToken); if (!result.Success || result.Value is null) { diff --git a/DeepDrftManager/Components/_Imports.razor b/DeepDrftManager/Components/_Imports.razor index 8772000..7742bd4 100644 --- a/DeepDrftManager/Components/_Imports.razor +++ b/DeepDrftManager/Components/_Imports.razor @@ -12,7 +12,6 @@ @using DeepDrftManager @using DeepDrftManager.Components @using DeepDrftModels.Entities -@using DeepDrftData @using Models.Common @using AuthBlocksModels.SystemDefinitions @using AuthBlocksWeb.HierarchicalAuthorize diff --git a/DeepDrftManager/DeepDrftManager.csproj b/DeepDrftManager/DeepDrftManager.csproj index 22d11d4..02d1ab7 100644 --- a/DeepDrftManager/DeepDrftManager.csproj +++ b/DeepDrftManager/DeepDrftManager.csproj @@ -7,17 +7,12 @@ - - - - - diff --git a/DeepDrftManager/Program.cs b/DeepDrftManager/Program.cs index 113b31d..5fdb4cc 100644 --- a/DeepDrftManager/Program.cs +++ b/DeepDrftManager/Program.cs @@ -1,12 +1,8 @@ using AuthBlocksLib; using AuthBlocksLib.Options; -using DeepDrftData; -using DeepDrftData.Data; -using DeepDrftData.Repositories; using DeepDrftManager.Components; using DeepDrftManager.Services; using Microsoft.AspNetCore.HttpOverrides; -using Microsoft.EntityFrameworkCore; using MudBlazor.Services; using NetBlocks.Utilities.Environment; @@ -30,19 +26,8 @@ builder.Configuration.AddJsonFile(authBlocksPath, optional: false, reloadOnChang // MudBlazor. builder.Services.AddMudServices(); -// SQL metadata domain — DbContext + repository + manager. The CMS pages inject ITrackService -// and resolve the same scoped TrackManager instance, so the DTO and entity surfaces share state. -builder.Services.AddDbContext(options => - options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection"))); - -builder.Services - .AddScoped() - .AddScoped() - .AddScoped(sp => sp.GetRequiredService()); - -// CMS track mutations (upload proxy + delete). Called directly by the InteractiveServer -// Blazor components — no in-process HTTP roundtrip. Vault access still goes over HTTP to -// DeepDrftContent via the named clients below. +// CMS track operations (read + mutate). Every track read and write goes over HTTP to the +// DeepDrftContent API via the named clients below — the Manager holds no in-process data layer. builder.Services.AddScoped(); // AuthBlocks: JWT Bearer auth, Identity, EF schema, admin seeding. diff --git a/DeepDrftManager/Services/CmsTrackService.cs b/DeepDrftManager/Services/CmsTrackService.cs index 932dc00..474bd23 100644 --- a/DeepDrftManager/Services/CmsTrackService.cs +++ b/DeepDrftManager/Services/CmsTrackService.cs @@ -1,36 +1,32 @@ +using System.Net; using System.Net.Http.Headers; -using DeepDrftData; +using System.Net.Http.Json; using DeepDrftModels.Entities; +using Models.Common; using NetBlocks.Models; namespace DeepDrftManager.Services; /// -/// Direct-call CMS track service. Replaces the former in-process HTTP roundtrip through -/// CmsUploadController / CmsDeleteController: the Manager is InteractiveServer-only, so its -/// Blazor components inject this service and call it directly rather than POSTing to their -/// own loopback controllers. Vault access remains over HTTP to DeepDrftContent (a separate -/// host); SQL metadata is reached directly via . +/// HTTP client over the DeepDrftContent 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 DeepDrftContent, 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 ContentClientName = "DeepDrft.Content"; private const string ContentCmsClientName = "DeepDrft.Content.Cms"; private const string UploadPath = "api/track/upload"; private readonly IHttpClientFactory _httpClientFactory; - private readonly IConfiguration _configuration; private readonly ILogger _logger; public CmsTrackService( IHttpClientFactory httpClientFactory, - ITrackService trackService, - IConfiguration configuration, ILogger logger) { _httpClientFactory = httpClientFactory; - _trackService = trackService; - _configuration = configuration; _logger = logger; } @@ -58,10 +54,11 @@ public class CmsTrackService : ICmsTrackService if (!string.IsNullOrWhiteSpace(album)) multipart.Add(new StringContent(album), "album"); if (!string.IsNullOrWhiteSpace(genre)) multipart.Add(new StringContent(genre), "genre"); if (!string.IsNullOrWhiteSpace(releaseDate)) multipart.Add(new StringContent(releaseDate), "releaseDate"); + multipart.Add(new StringContent(createdByUserId.ToString()), "createdByUserId"); var client = _httpClientFactory.CreateClient(ContentCmsClientName); using var request = new HttpRequestMessage(HttpMethod.Post, UploadPath) { Content = multipart }; - + HttpResponseMessage response; try { @@ -91,10 +88,12 @@ public class CmsTrackService : ICmsTrackService string.IsNullOrWhiteSpace(body) ? $"Upload rejected ({statusCode})." : body); } - TrackEntity? unpersisted; + // The Content API now owns the dual-database write, so the response is the persisted + // entity (Id > 0) — no SQL roundtrip here. + TrackEntity? persisted; try { - unpersisted = await response.Content.ReadFromJsonAsync(ct); + persisted = await response.Content.ReadFromJsonAsync(ct); } catch (Exception ex) { @@ -102,78 +101,184 @@ public class CmsTrackService : ICmsTrackService return ResultContainer.CreateFailResult("Content API returned an unexpected response."); } - if (unpersisted is null) + if (persisted is null) { _logger.LogError("Content API returned a null TrackEntity"); return ResultContainer.CreateFailResult("Content API returned an empty response."); } - unpersisted.CreatedByUserId = createdByUserId; - - var saveResult = await _trackService.Create(unpersisted); - if (!saveResult.Success || saveResult.Value is null) - { - // The vault write succeeded but the SQL persist failed — audio is now orphaned - // in the tracks vault under EntryKey. CMS-PLAN W2.4 covers the dead-letter - // mechanism; until then we log loudly so the orphan is recoverable manually. - var error = saveResult.Messages.FirstOrDefault()?.Message ?? "Unknown error"; - _logger.LogError( - "Track persisted to vault but SQL save failed. Orphaned entry: {EntryKey}. Error: {Error}", - unpersisted.EntryKey, error); - return ResultContainer.CreateFailResult($"Track was uploaded but could not be saved: {error}"); - } - - return saveResult; + return ResultContainer.CreatePassResult(persisted); } } public async Task DeleteTrackAsync(long id, CancellationToken ct = default) { - // 1. Resolve the EntryKey before we delete the SQL row — afterwards the join is gone. - var lookup = await _trackService.GetById(id); - if (!lookup.Success) - { - var error = lookup.Messages.FirstOrDefault()?.Message ?? "unknown error"; - _logger.LogError("CMS delete: GetById threw for track {TrackId}: {Error}", id, error); - return Result.CreateFailResult("Failed to load track."); - } - - if (lookup.Value is null) - { - return Result.CreateFailResult("Track not found."); - } - - var track = lookup.Value; - - var entryKey = track.EntryKey; - - // 2. SQL delete. On failure, do NOT touch the vault — nothing to clean up. - var sqlDelete = await _trackService.Delete(id); - if (!sqlDelete.Success) - { - var error = sqlDelete.Messages.FirstOrDefault()?.Message; - _logger.LogError("CMS delete: SQL delete failed for track {TrackId}: {Error}", id, error); - return Result.CreateFailResult("Failed to delete track."); - } - - // 3. Vault delete. Failure is logged as an orphan but does not fail the operation: - // SQL is the source of truth for the user's view; the orphan is a maintenance concern. var client = _httpClientFactory.CreateClient(ContentCmsClientName); + + HttpResponseMessage response; try { - using var response = await client.DeleteAsync($"api/track/{Uri.EscapeDataString(entryKey)}", ct); - if (!response.IsSuccessStatusCode) - { - _logger.LogWarning( - "Vault delete failed after SQL delete. {TrackId} {EntryKey} {StatusCode}", - id, entryKey, (int)response.StatusCode); - } + response = await client.DeleteAsync($"api/track/{id}", ct); } catch (Exception ex) { - _logger.LogWarning(ex, "Vault delete threw after SQL delete. {TrackId} {EntryKey}", id, entryKey); + _logger.LogError(ex, "Content API call failed for delete of track {TrackId}", id); + return Result.CreateFailResult("Content API is unreachable."); } - return Result.CreatePassResult(); + 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>> GetPagedAsync( + int page, int pageSize, string? sortColumn, bool sortDescending, + 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)}"; + } + + 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."); + } + + TrackEntity? track; + try + { + track = await response.Content.ReadFromJsonAsync(ct); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to deserialize TrackEntity from Content API response"); + return ResultContainer.CreateFailResult("Content API returned an unexpected response."); + } + + return ResultContainer.CreatePassResult(track); + } + } + + public async Task UpdateAsync( + long id, string trackName, string artist, + string? album, string? genre, DateOnly? releaseDate, + CancellationToken ct = default) + { + var client = _httpClientFactory.CreateClient(ContentCmsClientName); + var body = new + { + trackName, + artist, + album, + genre, + releaseDate, + }; + + 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."); + } } } diff --git a/DeepDrftManager/Services/ICmsTrackService.cs b/DeepDrftManager/Services/ICmsTrackService.cs index 5424965..cfc9519 100644 --- a/DeepDrftManager/Services/ICmsTrackService.cs +++ b/DeepDrftManager/Services/ICmsTrackService.cs @@ -1,21 +1,20 @@ using DeepDrftModels.Entities; +using Models.Common; using NetBlocks.Models; namespace DeepDrftManager.Services; /// -/// CMS-side track mutations for the Manager host. Coordinates the dual-database write: -/// SQL metadata via ITrackService and binary audio in DeepDrftContent's vault over HTTP. -/// DeepDrftManager intentionally does not reference DeepDrftContent.Services (CMS-PLAN §5, -/// Option B) — all vault access is over the network to DeepDrftContent. +/// CMS-side track operations for the Manager host. Every read and write goes over HTTP to the +/// DeepDrftContent API, which is the single authority over both the SQL metadata store and the +/// binary audio vault. DeepDrftManager holds no in-process data layer. /// public interface ICmsTrackService { /// - /// Proxy a WAV upload to DeepDrftContent, then persist the returned metadata to SQL. - /// On success the returned entity carries the SQL-assigned Id. If the vault write - /// succeeds but the SQL persist fails, the audio is orphaned under EntryKey — the - /// failure is logged loudly and surfaced as a failed result. + /// Proxy a WAV upload to DeepDrftContent. The Content API owns the dual-database write and + /// returns the persisted entity carrying the SQL-assigned Id. A vault-without-SQL + /// orphan is handled and logged server-side; here it surfaces as a failed result. /// Task> UploadTrackAsync( Stream wavStream, @@ -30,9 +29,30 @@ public interface ICmsTrackService CancellationToken ct = default); /// - /// Delete a track's SQL row, then its vault entry. SQL is the source of truth: a SQL - /// delete failure fails the operation, but a subsequent vault delete failure is logged - /// and swallowed (the orphan is a maintenance concern, not a user-facing error). + /// 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. /// Task DeleteTrackAsync(long id, CancellationToken ct = default); + + /// + /// Fetch a page of track metadata from the Content API's GET api/track/page. + /// + Task>> GetPagedAsync( + int page, int pageSize, string? sortColumn, bool sortDescending, + CancellationToken ct = default); + + /// + /// Fetch a single track's metadata from GET api/track/meta/{id}. A 404 returns a + /// passing result with a null value. + /// + Task> GetByIdAsync(long id, CancellationToken ct = default); + + /// + /// Update a track's metadata via PUT api/track/meta/{id}. EntryKey is immutable and + /// not part of the update. + /// + Task UpdateAsync( + long id, string trackName, string artist, + string? album, string? genre, DateOnly? releaseDate, + CancellationToken ct = default); }