diff --git a/DeepDrftAPI/Controllers/ReleaseController.cs b/DeepDrftAPI/Controllers/ReleaseController.cs new file mode 100644 index 0000000..00c4e5d --- /dev/null +++ b/DeepDrftAPI/Controllers/ReleaseController.cs @@ -0,0 +1,177 @@ +using DeepDrftAPI.Middleware; +using DeepDrftAPI.Services; +using DeepDrftContent.Constants; +using DeepDrftContent.FileDatabase.Models; +using DeepDrftContent.Processors; +using DeepDrftData; +using DeepDrftModels.DTOs; +using DeepDrftModels.Enums; +using Microsoft.AspNetCore.Mvc; + +namespace DeepDrftAPI.Controllers; + +[ApiController] +[Route("api/[controller]")] +public class ReleaseController : ControllerBase +{ + private readonly IReleaseService _releaseService; + private readonly UnifiedReleaseService _unifiedReleaseService; + private readonly WaveformProfileService _waveformProfileService; + private readonly ILogger _logger; + + public ReleaseController( + IReleaseService releaseService, + UnifiedReleaseService unifiedReleaseService, + WaveformProfileService waveformProfileService, + ILogger logger) + { + _releaseService = releaseService; + _unifiedReleaseService = unifiedReleaseService; + _waveformProfileService = waveformProfileService; + _logger = logger; + } + + // GET api/release?medium=session&page=1&pageSize=20&sortColumn=Title&sortDescending=false (unauth) + // Paged release list, optionally filtered to one medium. The matching medium's metadata satellite is + // populated; the others are null. Public browse data, same auth posture as GET api/track/page. + [HttpGet] + public async Task GetReleases( + [FromQuery] string? medium = null, + [FromQuery] int page = 1, + [FromQuery] int pageSize = 20, + [FromQuery] string? sortColumn = null, + [FromQuery] bool sortDescending = false, + CancellationToken ct = default) + { + ReleaseMedium? parsedMedium = null; + if (!string.IsNullOrWhiteSpace(medium)) + { + if (!Enum.TryParse(medium, ignoreCase: true, out var m) || !Enum.IsDefined(m)) + return BadRequest($"Unrecognised medium: {medium}"); + parsedMedium = m; + } + + var result = await _releaseService.GetPagedAsync(page, pageSize, sortColumn, sortDescending, parsedMedium, ct); + if (!result.Success || result.Value is null) + { + var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error"; + _logger.LogError("GetReleases failed: {Error}", error); + return StatusCode(500, "Failed to load releases"); + } + + return Ok(result.Value); + } + + // GET api/release/{id}/mix/waveform (unauthenticated) + // Serves the high-res waveform datum for a Mix release as base64. Mirrors GET api/track/{id}/waveform + // but reads from the mix-waveforms vault. 404 when the release is not a Mix, carries no waveform key, + // or no datum is stored. Declared before the shorter "{id:long}" route for clarity. + [HttpGet("{id:long}/mix/waveform")] + public async Task GetMixWaveform(long id, CancellationToken ct = default) + { + var lookup = await _releaseService.GetByIdAsync(id, ct); + if (!lookup.Success) + { + var error = lookup.Messages.FirstOrDefault()?.Message ?? "Unknown error"; + _logger.LogError("GetMixWaveform lookup failed for {ReleaseId}: {Error}", id, error); + return StatusCode(500, "Failed to load release"); + } + + var release = lookup.Value; + var waveformEntryKey = release?.MixMetadata?.WaveformEntryKey; + if (release is null || release.Medium != ReleaseMedium.Mix || string.IsNullOrEmpty(waveformEntryKey)) + { + _logger.LogInformation("No mix waveform datum for release: {ReleaseId}", id); + return NotFound(); + } + + var bytes = await _waveformProfileService.GetProfileAsync(waveformEntryKey, VaultConstants.MixWaveforms); + if (bytes is null) + { + _logger.LogInformation("Mix waveform key set but no datum stored for release: {ReleaseId}", id); + return NotFound(); + } + + return Ok(new WaveformProfileDto + { + BucketCount = bytes.Length, + Data = Convert.ToBase64String(bytes), + }); + } + + // POST api/release/{id}/mix/waveform ([ApiKeyAuthorize], no body) + // Server-side trigger: fetch the Mix's track audio from the vault, compute a 2048-bucket waveform, + // store it in the mix-waveforms vault, and set MixMetadata.WaveformEntryKey. 404 when the release is + // missing or has no stored audio; 500 on compute/storage failure. Declared before "{id:long}". + [ApiKeyAuthorize] + [HttpPost("{id:long}/mix/waveform")] + public async Task GenerateMixWaveform(long id, CancellationToken ct = default) + { + var result = await _unifiedReleaseService.TriggerMixWaveformAsync(id, ct); + if (result.Success) + return Ok(); + + var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error"; + if (string.Equals(error, ReleaseManager.ReleaseNotFoundMessage, StringComparison.Ordinal) + || string.Equals(error, UnifiedReleaseService.MixTrackNoAudioMessage, StringComparison.Ordinal) + || string.Equals(error, UnifiedReleaseService.MixHasNoTrackMessage, StringComparison.Ordinal)) + { + return NotFound(); + } + + _logger.LogError("GenerateMixWaveform failed for {ReleaseId}: {Error}", id, error); + return StatusCode(500, error); + } + + // POST api/release/{id}/session/hero-image ([ApiKeyAuthorize], multipart) + // Stores a hero image in the images vault and sets SessionMetadata.HeroImageEntryKey. The release + // must be a Session medium (enforced in the service). Declared before "{id:long}". + [ApiKeyAuthorize] + [HttpPost("{id:long}/session/hero-image")] + [RequestSizeLimit(50_000_000)] + public async Task UploadSessionHeroImage( + long id, + [FromForm] IFormFile? image, + CancellationToken ct = default) + { + if (image is null || image.Length == 0) + return BadRequest("Image file is required"); + + if (MimeTypeExtensions.GetExtension(image.ContentType) == ".bin") + { + _logger.LogWarning("UploadSessionHeroImage rejected: unsupported content type '{ContentType}'", image.ContentType); + return BadRequest($"Unsupported image content type: {image.ContentType}"); + } + + var result = await _unifiedReleaseService.SetHeroImageAsync(id, image, ct); + if (result.Success) + return Ok(); + + var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error"; + if (string.Equals(error, ReleaseManager.ReleaseNotFoundMessage, StringComparison.Ordinal)) + return NotFound(); + + _logger.LogError("UploadSessionHeroImage failed for {ReleaseId}: {Error}", id, error); + return StatusCode(500, error); + } + + // GET api/release/{id} (unauthenticated) + // Single release with both metadata navs (nulls for non-matching media). Declared after the longer + // "{id:long}/mix/waveform" routes so the segmented routes resolve first. + [HttpGet("{id:long}")] + public async Task GetReleaseById(long id, CancellationToken ct = default) + { + var result = await _releaseService.GetByIdAsync(id, ct); + if (!result.Success) + { + var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error"; + _logger.LogError("GetReleaseById failed for {ReleaseId}: {Error}", id, error); + return StatusCode(500, "Failed to load release"); + } + + if (result.Value is null) + return NotFound(); + + return Ok(result.Value); + } +} diff --git a/DeepDrftAPI/Program.cs b/DeepDrftAPI/Program.cs index a8da24e..c6e1704 100644 --- a/DeepDrftAPI/Program.cs +++ b/DeepDrftAPI/Program.cs @@ -64,6 +64,14 @@ builder.Services .AddScoped(sp => sp.GetRequiredService()); builder.Services.AddScoped(); +// Release domain — medium-aware read projection + satellite metadata writes. ReleaseManager is the +// IReleaseService implementation; UnifiedReleaseService orchestrates the vault + SQL satellite writes. +builder.Services + .AddScoped() + .AddScoped() + .AddScoped(sp => sp.GetRequiredService()); +builder.Services.AddScoped(); + // AuthBlocks: JWT Bearer auth, Identity, EF schema, role + admin seeding. This API host owns the // AuthBlocks API surface (registration, migration/seed, endpoint mounting). The Manager keeps only // web-side auth (AuthBlocksWeb) and never holds the signing secret, email creds, or admin creds. diff --git a/DeepDrftAPI/Services/UnifiedReleaseService.cs b/DeepDrftAPI/Services/UnifiedReleaseService.cs new file mode 100644 index 0000000..b0f3397 --- /dev/null +++ b/DeepDrftAPI/Services/UnifiedReleaseService.cs @@ -0,0 +1,172 @@ +using DeepDrftContent; +using DeepDrftContent.Constants; +using DeepDrftContent.FileDatabase.Models; +using DeepDrftContent.Processors; +using DeepDrftData; +using DeepDrftModels.Enums; +using NetBlocks.Models; +using FileDb = DeepDrftContent.FileDatabase.Services.FileDatabase; + +namespace DeepDrftAPI.Services; + +/// +/// Host-internal orchestrator for the two release metadata write paths. Mirrors +/// : it makes DeepDrftAPI the single authority over both the vault +/// (FileDatabase) and SQL satellite rows, so the controller stays a thin HTTP boundary and no caller +/// coordinates the two stores. +/// +public class UnifiedReleaseService +{ + // High-res bucket count for Mix waveforms — 4x the player-bar default (512), feeding the + // public-site MixWaveformVisualizer. + private const int MixWaveformBucketCount = 2048; + + /// Error message returned when the Mix release has no linked track. + public const string MixHasNoTrackMessage = "Mix release has no track."; + + /// Error message returned when the Mix track has no audio stored in the vault. + public const string MixTrackNoAudioMessage = "No audio stored for the Mix track."; + + private readonly IReleaseService _releaseService; + private readonly FileDb _fileDatabase; + private readonly ImageProcessor _imageProcessor; + private readonly TrackContentService _trackContentService; + private readonly WaveformProfileService _waveformProfileService; + private readonly ILogger _logger; + + public UnifiedReleaseService( + IReleaseService releaseService, + FileDb fileDatabase, + ImageProcessor imageProcessor, + TrackContentService trackContentService, + WaveformProfileService waveformProfileService, + ILogger logger) + { + _releaseService = releaseService; + _fileDatabase = fileDatabase; + _imageProcessor = imageProcessor; + _trackContentService = trackContentService; + _waveformProfileService = waveformProfileService; + _logger = logger; + } + + /// + /// Process a hero image into the Images vault, then point the release's Session satellite at it. + /// The medium check lives in : if the release + /// is not a Session, the satellite is not written and the image is orphaned (logged, recoverable). + /// + public async Task SetHeroImageAsync(long releaseId, IFormFile imageFile, CancellationToken ct) + { + if (MimeTypeExtensions.GetExtension(imageFile.ContentType) == ".bin") + { + _logger.LogWarning( + "SetHeroImage rejected: unsupported content type '{ContentType}' for release {ReleaseId}", + imageFile.ContentType, releaseId); + return Result.CreateFailResult($"Unsupported image content type: {imageFile.ContentType}"); + } + + byte[] buffer; + await using (var stream = imageFile.OpenReadStream()) + using (var memory = new MemoryStream()) + { + await stream.CopyToAsync(memory, ct); + buffer = memory.ToArray(); + } + + var imageBinary = _imageProcessor.Process(buffer, imageFile.ContentType); + if (imageBinary is null) + { + _logger.LogWarning("SetHeroImage: ImageProcessor rejected content type '{ContentType}'", imageFile.ContentType); + return Result.CreateFailResult($"Unsupported image content type: {imageFile.ContentType}"); + } + + var entryKey = Guid.NewGuid().ToString("N"); + var stored = await _fileDatabase.RegisterResourceAsync(VaultConstants.Images, entryKey, imageBinary); + if (!stored) + { + _logger.LogError("SetHeroImage: vault write failed for release {ReleaseId}, entryKey={EntryKey}", releaseId, entryKey); + return Result.CreateFailResult("Failed to store hero image."); + } + + var linked = await _releaseService.SetSessionHeroImageAsync(releaseId, entryKey, ct); + if (!linked.Success) + { + // Vault write succeeded, SQL link failed — image is orphaned in the Images vault under + // entryKey. Log loudly (include entryKey) so it is recoverable manually. + var error = linked.Messages.FirstOrDefault()?.Message ?? "Unknown error"; + _logger.LogError( + "Hero image stored in vault but Session link failed. Orphaned entry: {EntryKey}. Release: {ReleaseId}. Error: {Error}", + entryKey, releaseId, error); + return linked; + } + + return Result.CreatePassResult(); + } + + /// + /// Fetch the Mix's track audio from the vault, compute a high-res (2048-bucket) waveform datum, + /// store it in the MixWaveforms vault under the track's EntryKey, then point the release's Mix + /// satellite at that same key. The datum key equals the track's EntryKey — the Mix is single-track. + /// + public async Task TriggerMixWaveformAsync(long releaseId, CancellationToken ct) + { + var lookup = await _releaseService.GetByIdAsync(releaseId, ct); + if (!lookup.Success) + { + var error = lookup.Messages.FirstOrDefault()?.Message ?? "Unknown error"; + _logger.LogError("TriggerMixWaveform: release lookup failed for {ReleaseId}: {Error}", releaseId, error); + return Result.CreateFailResult("Failed to load release."); + } + + if (lookup.Value is null) + return Result.CreateFailResult(ReleaseManager.ReleaseNotFoundMessage); + + // Pre-check medium here (before fetching audio) to avoid expensive waveform compute on a + // non-Mix release. ReleaseManager.SetMixWaveformAsync enforces this too, so the double-check + // is intentional — the orchestrator's guard is the cheap early-exit. + if (lookup.Value.Medium != ReleaseMedium.Mix) + return Result.CreateFailResult($"Release {releaseId} is not a Mix medium."); + + var keysResult = await _releaseService.GetTrackEntryKeysAsync(releaseId, ct); + if (!keysResult.Success || keysResult.Value is null) + { + var error = keysResult.Messages.FirstOrDefault()?.Message ?? "Unknown error"; + _logger.LogError("TriggerMixWaveform: entry-key lookup failed for release {ReleaseId}: {Error}", releaseId, error); + return Result.CreateFailResult("Failed to load release tracks."); + } + + var entryKey = keysResult.Value.FirstOrDefault(); + if (entryKey is null) + { + _logger.LogWarning("TriggerMixWaveform: no track on Mix release {ReleaseId}", releaseId); + return Result.CreateFailResult(MixHasNoTrackMessage); + } + + var audio = await _trackContentService.GetAudioBinaryAsync(entryKey); + if (audio is null) + { + _logger.LogWarning("TriggerMixWaveform: no audio in vault for {EntryKey} (release {ReleaseId})", entryKey, releaseId); + return Result.CreateFailResult(MixTrackNoAudioMessage); + } + + var computed = await _waveformProfileService.ComputeAndStoreAsync( + audio.Buffer, entryKey, MixWaveformBucketCount, VaultConstants.MixWaveforms); + if (!computed) + { + _logger.LogError("TriggerMixWaveform: waveform computation/storage failed for {EntryKey}", entryKey); + return Result.CreateFailResult("Failed to compute the Mix waveform."); + } + + var linked = await _releaseService.SetMixWaveformAsync(releaseId, entryKey, ct); + if (!linked.Success) + { + var error = linked.Messages.FirstOrDefault()?.Message ?? "Unknown error"; + _logger.LogError( + "Mix waveform stored in vault but Mix link failed. Entry: {EntryKey}. Release: {ReleaseId}. Error: {Error}", + entryKey, releaseId, error); + return linked; + } + + return Result.CreatePassResult(); + } +} diff --git a/DeepDrftAPI/Startup.cs b/DeepDrftAPI/Startup.cs index 11f11b0..655a1e9 100644 --- a/DeepDrftAPI/Startup.cs +++ b/DeepDrftAPI/Startup.cs @@ -43,6 +43,7 @@ namespace DeepDrftAPI if (db is null) throw new Exception("Unable to initialize file database"); InitializeTrackVault(db).GetAwaiter().GetResult(); InitializeImageVault(db).GetAwaiter().GetResult(); + InitializeMixWaveformsVault(db).GetAwaiter().GetResult(); return db; }); @@ -64,5 +65,14 @@ namespace DeepDrftAPI await fileDatabase.CreateVaultAsync(VaultConstants.Images, MediaVaultType.Image); } } + + // Ensure the mix-waveforms vault exists. Holds high-resolution waveform datums for DJ Mix releases. + private static async Task InitializeMixWaveformsVault(FileDatabase fileDatabase) + { + if (!fileDatabase.HasVault(VaultConstants.MixWaveforms)) + { + await fileDatabase.CreateVaultAsync(VaultConstants.MixWaveforms, MediaVaultType.Media); + } + } } } \ No newline at end of file diff --git a/DeepDrftContent/Constants/VaultConstants.cs b/DeepDrftContent/Constants/VaultConstants.cs index f58a332..5c0f5af 100644 --- a/DeepDrftContent/Constants/VaultConstants.cs +++ b/DeepDrftContent/Constants/VaultConstants.cs @@ -20,4 +20,10 @@ public static class VaultConstants /// from TrackEntity.ImagePath. /// public const string Images = "images"; + + /// + /// Vault name for Mix high-resolution waveform datums, keyed by the mix track's EntryKey. + /// Distinct from WaveformProfiles (player-bar low-res); same pipeline at higher resolution. + /// + public const string MixWaveforms = "mix-waveforms"; } \ No newline at end of file diff --git a/DeepDrftContent/Processors/WaveformProfileService.cs b/DeepDrftContent/Processors/WaveformProfileService.cs index 6c31e21..7a8d938 100644 --- a/DeepDrftContent/Processors/WaveformProfileService.cs +++ b/DeepDrftContent/Processors/WaveformProfileService.cs @@ -39,12 +39,22 @@ public class WaveformProfileService /// /// Computes the loudness profile from and stores it under - /// . Returns false (and logs) on any failure — a missing profile - /// is handled gracefully downstream, so callers on the upload path log-and-continue rather - /// than failing the upload. Does not throw for expected failure modes. + /// in (defaults to + /// when null). Bucket resolution defaults to + /// (512) when is null; + /// pass a higher value (e.g., 2048) for the Mix high-res datum. Returns false (and logs) on any + /// failure — a missing profile is handled gracefully downstream, so callers on the upload path + /// log-and-continue rather than failing the upload. Does not throw for expected failure modes. /// - public async Task ComputeAndStoreAsync(ReadOnlyMemory wavBytes, string entryKey) + public async Task ComputeAndStoreAsync( + ReadOnlyMemory wavBytes, + string entryKey, + int? bucketCount = null, + string? vaultName = null) { + var effectiveBucketCount = bucketCount ?? _options.BucketCount; + var effectiveVaultName = vaultName ?? VaultConstants.WaveformProfiles; + try { var pcm = _audioProcessor.TryExtractPcm(wavBytes.Span); @@ -62,15 +72,14 @@ public class WaveformProfileService value.Channels, value.SampleRate, value.BitsPerSample, - _options.BucketCount); + effectiveBucketCount); var quantized = Quantize(profile); - await EnsureVaultAsync(); + await EnsureVaultAsync(effectiveVaultName); var binary = new MediaBinary(new MediaBinaryParams(quantized, quantized.Length, ProfileExtension)); - var stored = await _fileDatabase.RegisterResourceAsync( - VaultConstants.WaveformProfiles, entryKey, binary); + var stored = await _fileDatabase.RegisterResourceAsync(effectiveVaultName, entryKey, binary); if (!stored) { @@ -88,14 +97,15 @@ public class WaveformProfileService } /// - /// Returns the stored quantized profile bytes for a track, or null if no profile is stored - /// (existing tracks predate profiling, and computation may have failed). Each byte is a - /// peak-normalized loudness value in [0, 255]. + /// Returns the stored quantized profile bytes for a track from + /// (defaults to when null), or null if no profile + /// is stored (existing tracks predate profiling, and computation may have failed). Each byte is + /// a peak-normalized loudness value in [0, 255]. /// - public async Task GetProfileAsync(string entryKey) + public async Task GetProfileAsync(string entryKey, string? vaultName = null) { var binary = await _fileDatabase.LoadResourceAsync( - VaultConstants.WaveformProfiles, entryKey); + vaultName ?? VaultConstants.WaveformProfiles, entryKey); return binary?.Buffer; } @@ -113,11 +123,11 @@ public class WaveformProfileService return bytes; } - private async Task EnsureVaultAsync() + private async Task EnsureVaultAsync(string vaultName) { - if (!_fileDatabase.HasVault(VaultConstants.WaveformProfiles)) + if (!_fileDatabase.HasVault(vaultName)) { - await _fileDatabase.CreateVaultAsync(VaultConstants.WaveformProfiles, MediaVaultType.Media); + await _fileDatabase.CreateVaultAsync(vaultName, MediaVaultType.Media); } } } diff --git a/DeepDrftData/IReleaseService.cs b/DeepDrftData/IReleaseService.cs new file mode 100644 index 0000000..392ad62 --- /dev/null +++ b/DeepDrftData/IReleaseService.cs @@ -0,0 +1,31 @@ +using DeepDrftModels.DTOs; +using DeepDrftModels.Enums; +using Models.Common; +using NetBlocks.Models; + +namespace DeepDrftData; + +/// +/// SQL-side release service. Repository outputs entities; this service outputs DTOs via TrackConverter. +/// Backs the medium-aware release read endpoints (paged list + by-id detail) and the two metadata +/// write paths (Session hero image, Mix waveform). The entity never escapes the service layer. +/// +public interface IReleaseService +{ + /// Paginated releases, optionally filtered to one medium. The matching medium's metadata satellite is included in the result. Omit medium for all releases. + Task>> GetPagedAsync( + int page, int pageSize, string? sortColumn, bool sortDescending, + ReleaseMedium? medium, CancellationToken cancellationToken = default); + + /// Single release with both metadata navs included (nulls for non-matching media). + Task> GetByIdAsync(long id, CancellationToken cancellationToken = default); + + /// Track entry keys for a release. Single-entry for Session/Mix (enforced at upload); may be multiple for Cut. + Task>> GetTrackEntryKeysAsync(long releaseId, CancellationToken cancellationToken = default); + + /// Find-or-create the Session satellite and set its hero image entry key. Fails when the release is not a Session. + Task SetSessionHeroImageAsync(long releaseId, string heroImageEntryKey, CancellationToken cancellationToken = default); + + /// Find-or-create the Mix satellite and set its waveform entry key. Fails when the release is not a Mix. + Task SetMixWaveformAsync(long releaseId, string waveformEntryKey, CancellationToken cancellationToken = default); +} diff --git a/DeepDrftData/ReleaseManager.cs b/DeepDrftData/ReleaseManager.cs new file mode 100644 index 0000000..80416d2 --- /dev/null +++ b/DeepDrftData/ReleaseManager.cs @@ -0,0 +1,147 @@ +using System.Linq.Expressions; +using DeepDrftData.Repositories; +using DeepDrftModels.DTOs; +using DeepDrftModels.Entities; +using DeepDrftModels.Enums; +using Microsoft.Extensions.Logging; +using Models.Common; +using NetBlocks.Models; + +namespace DeepDrftData; + +/// +/// SQL-side release service implementing . Deliberately does NOT extend +/// Manager<>: that CRUD base does not fit this read-projection + satellite-write purpose. +/// The layer boundary holds — ReleaseRepository outputs entities, this manager outputs DTOs via +/// TrackConverter, the single authoritative conversion path. +/// +public class ReleaseManager : IReleaseService +{ + // Distinguishes "release does not exist" from a real failure so the controller can map to 404. + public const string ReleaseNotFoundMessage = "Release not found."; + + private readonly ReleaseRepository _repository; + private readonly ILogger _logger; + + public ReleaseManager(ReleaseRepository repository, ILogger logger) + { + _repository = repository; + _logger = logger; + } + + // Nulls sort to end via the coalescing sentinels, matching TrackManager's convention. + private static Expression> GetOrderExpression(string? sortColumn) + => sortColumn switch + { + "Title" => r => r.Title, + "Artist" => r => r.Artist, + "ReleaseDate" => r => (object)(r.ReleaseDate ?? DateOnly.MaxValue), + "Medium" => r => r.Medium, + _ => r => r.Id + }; + + public async Task>> GetPagedAsync( + int page, int pageSize, string? sortColumn, bool sortDescending, + ReleaseMedium? medium, CancellationToken cancellationToken = default) + { + try + { + var parameters = new PagingParameters + { + Page = page, + PageSize = pageSize, + OrderBy = GetOrderExpression(sortColumn), + IsDescending = sortDescending, + }; + + var entityPage = await _repository.GetPagedByMediumAsync(parameters, medium, cancellationToken); + + var releaseIds = entityPage.Items.Select(r => r.Id).ToList(); + var counts = await _repository.GetTrackCountsByReleaseIdsAsync(releaseIds, cancellationToken); + + var dtos = entityPage.Items + .Select(r => + { + var dto = TrackConverter.Convert(r); + dto.TrackCount = counts.GetValueOrDefault(r.Id); + return dto; + }); + + var dtoPage = PagedResult.From(entityPage, dtos); + return ResultContainer>.CreatePassResult(dtoPage); + } + catch (Exception e) + { + return ResultContainer>.CreateFailResult(e.Message); + } + } + + public async Task> GetByIdAsync(long id, CancellationToken cancellationToken = default) + { + try + { + var entity = await _repository.GetByIdWithMetadataAsync(id, cancellationToken); + // TrackConverter nulls the non-matching satellite. TrackCount is not loaded for the detail + // read (the Tracks collection isn't Include'd) and is not needed by detail consumers. + return ResultContainer.CreatePassResult( + entity is null ? null : TrackConverter.Convert(entity)); + } + catch (Exception e) + { + return ResultContainer.CreateFailResult(e.Message); + } + } + + public async Task>> GetTrackEntryKeysAsync(long releaseId, CancellationToken cancellationToken = default) + { + try + { + var keys = await _repository.GetTrackEntryKeysByReleaseIdAsync(releaseId, cancellationToken); + return ResultContainer>.CreatePassResult(keys); + } + catch (Exception e) + { + return ResultContainer>.CreateFailResult(e.Message); + } + } + + public async Task SetSessionHeroImageAsync(long releaseId, string heroImageEntryKey, CancellationToken cancellationToken = default) + { + try + { + var release = await _repository.GetByIdWithMetadataAsync(releaseId, cancellationToken); + if (release is null) + return Result.CreateFailResult(ReleaseNotFoundMessage); + + if (release.Medium != ReleaseMedium.Session) + return Result.CreateFailResult($"Release {releaseId} is not a Session medium."); + + await _repository.SetHeroImageEntryKeyAsync(releaseId, heroImageEntryKey, cancellationToken); + return Result.CreatePassResult(); + } + catch (Exception e) + { + return Result.CreateFailResult(e.Message); + } + } + + public async Task SetMixWaveformAsync(long releaseId, string waveformEntryKey, CancellationToken cancellationToken = default) + { + try + { + var release = await _repository.GetByIdWithMetadataAsync(releaseId, cancellationToken); + if (release is null) + return Result.CreateFailResult(ReleaseNotFoundMessage); + + if (release.Medium != ReleaseMedium.Mix) + return Result.CreateFailResult($"Release {releaseId} is not a Mix medium."); + + await _repository.SetWaveformEntryKeyAsync(releaseId, waveformEntryKey, cancellationToken); + return Result.CreatePassResult(); + } + catch (Exception e) + { + return Result.CreateFailResult(e.Message); + } + } +} diff --git a/DeepDrftData/Repositories/ReleaseRepository.cs b/DeepDrftData/Repositories/ReleaseRepository.cs new file mode 100644 index 0000000..2f1ccb3 --- /dev/null +++ b/DeepDrftData/Repositories/ReleaseRepository.cs @@ -0,0 +1,141 @@ +using DeepDrftData.Data; +using DeepDrftModels.Entities; +using DeepDrftModels.Enums; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Models.Common; + +namespace DeepDrftData.Repositories; + +/// +/// Medium-aware release queries. Deliberately does NOT extend Repository<DeepDrftContext, ReleaseEntity>: +/// that base is generic CRUD, while this repository's purpose is read-projection (paged, medium-filtered) +/// and satellite-row management (Session/Mix metadata find-or-create). Injects +/// directly so reads/writes stay in one unit of work. +/// +public class ReleaseRepository +{ + private readonly DeepDrftContext _context; + private readonly ILogger _logger; + + public ReleaseRepository(DeepDrftContext context, ILogger logger) + { + _context = context; + _logger = logger; + } + + // Single location where the medium↔metadata correlation is determined on a list read: a satellite + // is loaded only when the caller's medium filter matches it. TrackConverter.Convert(ReleaseEntity) + // enforces the same rule at the DTO boundary (nulling non-matching satellites); this map ensures a + // non-matching satellite is never even queried. Cut (or no filter) loads no satellite on list reads. + private static IQueryable ApplyMediumInclude(IQueryable query, ReleaseMedium? medium) + => medium switch + { + ReleaseMedium.Session => query.Include(r => r.SessionMetadata), + ReleaseMedium.Mix => query.Include(r => r.MixMetadata), + _ => query + }; + + // Paged, optionally medium-filtered release list. The matching medium's satellite is Include'd; + // total count reflects the medium filter (applied before Skip/Take). + public async Task> GetPagedByMediumAsync( + PagingParameters paging, + ReleaseMedium? medium, + CancellationToken ct) + { + IQueryable query = _context.Releases.Where(r => !r.IsDeleted); + if (medium.HasValue) + query = query.Where(r => r.Medium == medium.Value); + + query = ApplyMediumInclude(query, medium); + + var totalCount = await query.CountAsync(ct); + + if (paging.OrderBy is not null) + query = paging.IsDescending ? query.OrderByDescending(paging.OrderBy) : query.OrderBy(paging.OrderBy); + + var items = await query.Skip(paging.Skip).Take(paging.PageSize).ToListAsync(ct); + + return new PagedResult + { + Items = items, + TotalCount = totalCount, + Page = paging.Page, + PageSize = paging.PageSize, + }; + } + + // Single release with both satellites Include'd: the medium is unknown until fetched, and both are + // 1:1 FK-indexed joins. TrackConverter nulls the non-matching satellite at the DTO boundary. + public async Task GetByIdWithMetadataAsync(long id, CancellationToken ct) + => await _context.Releases + .Where(r => r.Id == id && !r.IsDeleted) + .Include(r => r.SessionMetadata) + .Include(r => r.MixMetadata) + .FirstOrDefaultAsync(ct); + + // Non-deleted track counts for a specific set of releases, for populating ReleaseDto.TrackCount on + // list reads without an N+1 fan-out. Releases with zero live tracks are absent from the dictionary. + public async Task> GetTrackCountsByReleaseIdsAsync( + IEnumerable releaseIds, + CancellationToken ct) + { + var ids = releaseIds.ToList(); + return await _context.Tracks + .Where(t => !t.IsDeleted && t.ReleaseId != null && ids.Contains(t.ReleaseId.Value)) + .GroupBy(t => t.ReleaseId!.Value) + .Select(g => new { ReleaseId = g.Key, Count = g.Count() }) + .ToDictionaryAsync(x => x.ReleaseId, x => x.Count, ct); + } + + // Vault entry keys of the non-deleted tracks on a release, track-number ascending. Single-entry for + // Session/Mix (enforced at upload); may be multiple for Cut. + public async Task> GetTrackEntryKeysByReleaseIdAsync(long releaseId, CancellationToken ct) + => await _context.Tracks + .Where(t => !t.IsDeleted && t.ReleaseId == releaseId) + .OrderBy(t => t.TrackNumber) + .Select(t => t.EntryKey) + .ToListAsync(ct); + + // Find-or-create the Session satellite for a release and set its hero-image entry key. The 1:1 FK + // makes (ReleaseId) the natural key; a repeat call updates the existing row in place. + public async Task SetHeroImageEntryKeyAsync(long releaseId, string heroImageEntryKey, CancellationToken ct) + { + var existing = await _context.SessionMetadata.FirstOrDefaultAsync(s => s.ReleaseId == releaseId, ct); + if (existing is not null) + { + existing.HeroImageEntryKey = heroImageEntryKey; + existing.UpdatedAt = DateTime.UtcNow; + await _context.SaveChangesAsync(ct); + return; + } + + _context.SessionMetadata.Add(new SessionMetadata + { + ReleaseId = releaseId, + HeroImageEntryKey = heroImageEntryKey, + }); + await _context.SaveChangesAsync(ct); + } + + // Find-or-create the Mix satellite for a release and set its waveform entry key. Same 1:1 find-or-create + // pattern as SetHeroImageEntryKeyAsync. + public async Task SetWaveformEntryKeyAsync(long releaseId, string waveformEntryKey, CancellationToken ct) + { + var existing = await _context.MixMetadata.FirstOrDefaultAsync(m => m.ReleaseId == releaseId, ct); + if (existing is not null) + { + existing.WaveformEntryKey = waveformEntryKey; + existing.UpdatedAt = DateTime.UtcNow; + await _context.SaveChangesAsync(ct); + return; + } + + _context.MixMetadata.Add(new MixMetadata + { + ReleaseId = releaseId, + WaveformEntryKey = waveformEntryKey, + }); + await _context.SaveChangesAsync(ct); + } +}