5d6b54d2fc
Add ReleaseMedium enum (Cut/Session/Mix) and two 1:1 satellite entities (SessionMetadata, MixMetadata) with EF configs and an additive migration. ReleaseDto.ReleaseType is now nullable, nulled for non-Cut at the converter. Existing releases default to Cut via column default; no data migration.
316 lines
13 KiB
C#
316 lines
13 KiB
C#
using Data.Errors;
|
|
using Data.Managers;
|
|
using DeepDrftData.Repositories;
|
|
using DeepDrftModels.DTOs;
|
|
using DeepDrftModels.Entities;
|
|
using DeepDrftModels.Enums;
|
|
using Microsoft.Extensions.Logging;
|
|
using Models.Common;
|
|
using NetBlocks.Models;
|
|
|
|
namespace DeepDrftData;
|
|
|
|
/// <summary>
|
|
/// SQL-side track service built on the BlazorBlocks Manager base. The layer boundary:
|
|
/// TrackRepository outputs entities; this service outputs DTOs via TrackConverter — the
|
|
/// single authoritative entity↔DTO conversion path. The ITrackService surface is DTO-typed
|
|
/// throughout; the entity never escapes the service layer.
|
|
///
|
|
/// The base Manager<> surface does not line up with ITrackService by signature (base
|
|
/// Add vs Create, base Update→Result vs Update→DTO, base Get/GetPage vs GetAll/GetPaged,
|
|
/// base GetById→TDto vs GetById→TDto?), so the query and mutation methods are implemented
|
|
/// here over Repository + TrackConverter. Only Delete(long)→Result is inherited unchanged.
|
|
/// </summary>
|
|
public class TrackManager
|
|
: Manager<TrackEntity, TrackDto, TrackRepository, TrackConverter>, ITrackService
|
|
{
|
|
public TrackManager(
|
|
TrackRepository repository,
|
|
ILogger<Manager<TrackEntity, TrackDto, TrackRepository, TrackConverter>> logger)
|
|
: base(repository, logger)
|
|
{
|
|
}
|
|
|
|
// Explicit impl: base GetById returns ResultContainer<TrackDto> (fails on miss); the
|
|
// service contract is ResultContainer<TrackDto?> (pass with null on miss). Return types
|
|
// differ, so this cannot be a public overload of the inherited member.
|
|
async Task<ResultContainer<TrackDto?>> ITrackService.GetById(long id)
|
|
{
|
|
try
|
|
{
|
|
var entity = await Repository.GetByIdAsync(id);
|
|
return ResultContainer<TrackDto?>.CreatePassResult(
|
|
entity is null ? null : TrackConverter.Convert(entity));
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
return ResultContainer<TrackDto?>.CreateFailResult(e.Message);
|
|
}
|
|
}
|
|
|
|
// Lookup by vault entry key. No base-name conflict (unlike GetById), so this is a plain
|
|
// public method. Mirrors the nullable-on-miss shape of ITrackService.GetById.
|
|
public async Task<ResultContainer<TrackDto?>> GetByEntryKey(string entryKey)
|
|
{
|
|
try
|
|
{
|
|
var entity = await Repository.GetByEntryKeyAsync(entryKey);
|
|
return ResultContainer<TrackDto?>.CreatePassResult(
|
|
entity is null ? null : TrackConverter.Convert(entity));
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
return ResultContainer<TrackDto?>.CreateFailResult(e.Message);
|
|
}
|
|
}
|
|
|
|
// No base-name conflict, so this is a plain public method. Mirrors the nullable-on-empty
|
|
// shape of GetById: pass with null when the library has no tracks.
|
|
public async Task<ResultContainer<TrackDto?>> GetRandom(CancellationToken cancellationToken = default)
|
|
{
|
|
try
|
|
{
|
|
var entity = await Repository.GetRandomAsync(cancellationToken);
|
|
return ResultContainer<TrackDto?>.CreatePassResult(
|
|
entity is null ? null : TrackConverter.Convert(entity));
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
return ResultContainer<TrackDto?>.CreateFailResult(e.Message);
|
|
}
|
|
}
|
|
|
|
public async Task<ResultContainer<List<TrackDto>>> GetAll()
|
|
{
|
|
try
|
|
{
|
|
var entities = await Repository.GetAllAsync();
|
|
return ResultContainer<List<TrackDto>>.CreatePassResult(
|
|
entities.Select(TrackConverter.Convert).ToList());
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
return ResultContainer<List<TrackDto>>.CreateFailResult(e.Message);
|
|
}
|
|
}
|
|
|
|
public async Task<ResultContainer<PagedResult<TrackDto>>> GetPaged(
|
|
int pageNumber,
|
|
int pageSize,
|
|
string? sortColumn,
|
|
bool sortDescending,
|
|
TrackFilter? filter = null,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
try
|
|
{
|
|
var parameters = new PagingParameters<TrackEntity>
|
|
{
|
|
Page = pageNumber,
|
|
PageSize = pageSize,
|
|
IsDescending = sortDescending,
|
|
// Sorts navigate through the nullable Release relation; the null-coalescing
|
|
// sentinels push loose tracks (no release) to the end, matching the prior
|
|
// nulls-last behaviour on the flat columns.
|
|
OrderBy = sortColumn switch
|
|
{
|
|
"TrackName" => e => e.TrackName,
|
|
"Artist" => e => (object)(e.Release == null ? string.Empty : e.Release.Artist),
|
|
"Album" => e => (object)(e.Release == null ? string.Empty : e.Release.Title),
|
|
"Genre" => e => (object)(e.Release == null ? string.Empty : (e.Release.Genre ?? string.Empty)),
|
|
"ReleaseDate" => e => (object)(e.Release == null ? DateOnly.MaxValue : (e.Release.ReleaseDate ?? DateOnly.MaxValue)),
|
|
"TrackNumber" => e => e.TrackNumber,
|
|
_ => e => e.Id
|
|
}
|
|
};
|
|
|
|
// Always route through GetPagedFilteredAsync — it handles a null filter by skipping
|
|
// all Where predicates, and it always includes Release. This removes the base-class
|
|
// GetPagedAsync path, which has no .Include and would return entities with null Release.
|
|
var effectiveFilter = filter is null || filter.IsEmpty ? null : filter;
|
|
|
|
var page = await Repository.GetPagedFilteredAsync(parameters, effectiveFilter, cancellationToken);
|
|
|
|
var dtoPage = PagedResult<TrackDto>.From(page, page.Items.Select(TrackConverter.Convert));
|
|
return ResultContainer<PagedResult<TrackDto>>.CreatePassResult(dtoPage);
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
return ResultContainer<PagedResult<TrackDto>>.CreateFailResult(e.Message);
|
|
}
|
|
}
|
|
|
|
public async Task<ResultContainer<List<ReleaseDto>>> GetReleases(CancellationToken cancellationToken = default)
|
|
{
|
|
try
|
|
{
|
|
var releases = await Repository.GetReleasesAsync(cancellationToken);
|
|
var counts = await Repository.GetTrackCountsByReleaseAsync(cancellationToken);
|
|
|
|
var dtos = releases
|
|
.Select(r =>
|
|
{
|
|
var dto = TrackConverter.Convert(r);
|
|
dto.TrackCount = counts.GetValueOrDefault(r.Id);
|
|
return dto;
|
|
})
|
|
.ToList();
|
|
|
|
return ResultContainer<List<ReleaseDto>>.CreatePassResult(dtos);
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
return ResultContainer<List<ReleaseDto>>.CreateFailResult(e.Message);
|
|
}
|
|
}
|
|
|
|
public async Task<ResultContainer<ReleaseDto>> FindOrCreateRelease(
|
|
string title, string artist, ReleaseDto releaseData, CancellationToken cancellationToken = default)
|
|
{
|
|
try
|
|
{
|
|
var existing = await Repository.GetReleaseByTitleAndArtistAsync(title, artist, cancellationToken);
|
|
if (existing is not null)
|
|
return ResultContainer<ReleaseDto>.CreatePassResult(TrackConverter.Convert(existing));
|
|
|
|
// The natural key (title + artist) is authoritative — override whatever the caller put
|
|
// in releaseData so a typo upstream cannot create a release that won't be found again.
|
|
var entity = TrackConverter.Convert(releaseData);
|
|
entity.Id = 0;
|
|
entity.Title = title;
|
|
entity.Artist = artist;
|
|
|
|
try
|
|
{
|
|
var added = await Repository.AddReleaseAsync(entity, cancellationToken);
|
|
return ResultContainer<ReleaseDto>.CreatePassResult(TrackConverter.Convert(added));
|
|
}
|
|
catch (ClassifiedDbException ex) when (ex.Error.Category == DbErrorCategory.UniqueViolation)
|
|
{
|
|
// Concurrent upload inserted the same (title, artist) between our read and write.
|
|
// Re-query and return the winning row. Should not return null here since the
|
|
// constraint just fired, but re-throw if it does so the caller sees an error.
|
|
var race = await Repository.GetReleaseByTitleAndArtistAsync(title, artist, cancellationToken);
|
|
if (race is null) throw;
|
|
return ResultContainer<ReleaseDto>.CreatePassResult(TrackConverter.Convert(race));
|
|
}
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
return ResultContainer<ReleaseDto>.CreateFailResult(e.Message);
|
|
}
|
|
}
|
|
|
|
public async Task<ResultContainer<List<GenreSummaryDto>>> GetDistinctGenres(CancellationToken cancellationToken = default)
|
|
{
|
|
try
|
|
{
|
|
var genres = await Repository.GetDistinctGenresAsync(cancellationToken);
|
|
return ResultContainer<List<GenreSummaryDto>>.CreatePassResult(genres);
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
return ResultContainer<List<GenreSummaryDto>>.CreateFailResult(e.Message);
|
|
}
|
|
}
|
|
|
|
public async Task<ResultContainer<TrackDto>> Create(TrackDto newTrack)
|
|
{
|
|
try
|
|
{
|
|
// A track with release context resolves (or creates) the shared release first so the FK
|
|
// is set before insert. A standalone track (Release null) stays a loose track, ReleaseId
|
|
// null. Callers that already resolved the FK (UnifiedTrackService) pass Release null and
|
|
// a populated ReleaseId, which falls straight through.
|
|
if (newTrack.Release is { } release && !string.IsNullOrWhiteSpace(release.Title))
|
|
{
|
|
var resolved = await FindOrCreateRelease(release.Title, release.Artist, release);
|
|
if (!resolved.Success || resolved.Value is null)
|
|
{
|
|
var error = resolved.Messages.FirstOrDefault()?.Message ?? "Failed to resolve release.";
|
|
return ResultContainer<TrackDto>.CreateFailResult(error);
|
|
}
|
|
|
|
newTrack.ReleaseId = resolved.Value.Id;
|
|
}
|
|
|
|
var added = await Repository.AddAsync(TrackConverter.Convert(newTrack));
|
|
return ResultContainer<TrackDto>.CreatePassResult(TrackConverter.Convert(added));
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
return ResultContainer<TrackDto>.CreateFailResult(e.Message);
|
|
}
|
|
}
|
|
|
|
// Explicit impl: base Update returns Result; the service contract returns the persisted
|
|
// DTO so the CMS edit flow reads back DB-authoritative values.
|
|
async Task<ResultContainer<TrackDto>> ITrackService.Update(TrackDto track)
|
|
{
|
|
try
|
|
{
|
|
await Repository.UpdateAsync(TrackConverter.Convert(track));
|
|
|
|
// Release-cardinal edits flow through the linked release row, not the track. When the
|
|
// track carries a Release payload and a resolved FK, load the tracked release, apply the
|
|
// edited fields, and save. EntryKey/track fields are already persisted above.
|
|
if (track.Release is { } release && track.ReleaseId is { } releaseId)
|
|
{
|
|
var releaseEntity = await Repository.GetReleaseByIdAsync(releaseId);
|
|
if (releaseEntity is not null)
|
|
{
|
|
releaseEntity.Title = release.Title;
|
|
releaseEntity.Artist = release.Artist;
|
|
releaseEntity.Genre = release.Genre;
|
|
releaseEntity.ReleaseDate = release.ReleaseDate;
|
|
releaseEntity.ImagePath = release.ImagePath;
|
|
releaseEntity.Medium = release.Medium;
|
|
// DTO ReleaseType is nullable (meaningful only for Cut); the entity field is not.
|
|
// Default to Single when null, matching TrackConverter.Convert(ReleaseDto).
|
|
releaseEntity.ReleaseType = release.ReleaseType ?? ReleaseType.Single;
|
|
releaseEntity.CreatedByUserId = release.CreatedByUserId;
|
|
await Repository.UpdateReleaseAsync(releaseEntity);
|
|
}
|
|
}
|
|
|
|
var updated = await Repository.GetByIdAsync(track.Id);
|
|
return updated is not null
|
|
? ResultContainer<TrackDto>.CreatePassResult(TrackConverter.Convert(updated))
|
|
: ResultContainer<TrackDto>.CreateFailResult("Track not found after update.");
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
return ResultContainer<TrackDto>.CreateFailResult(e.Message);
|
|
}
|
|
}
|
|
|
|
// Delete(long) → Result is inherited from Manager<> and satisfies ITrackService.Delete
|
|
// by signature. No override.
|
|
|
|
public async Task<Result> DeleteRelease(long id, CancellationToken cancellationToken = default)
|
|
{
|
|
try
|
|
{
|
|
await Repository.SoftDeleteReleaseAsync(id, cancellationToken);
|
|
return Result.CreatePassResult();
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
return Result.CreateFailResult(e.Message);
|
|
}
|
|
}
|
|
|
|
public async Task<ResultContainer<int>> CountLiveTracksByRelease(long releaseId, CancellationToken cancellationToken = default)
|
|
{
|
|
try
|
|
{
|
|
var count = await Repository.CountLiveTracksByReleaseAsync(releaseId, cancellationToken);
|
|
return ResultContainer<int>.CreatePassResult(count);
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
return ResultContainer<int>.CreateFailResult(e.Message);
|
|
}
|
|
}
|
|
}
|