737c423d9c
Retire the three-card overview for a search + medium + genre browser over all releases. Adds q/genre filter params to the api/release paged read path, mirroring the existing api/track/page TrackFilter pattern.
163 lines
7.1 KiB
C#
163 lines
7.1 KiB
C#
using DeepDrftData.Data;
|
|
using DeepDrftModels.DTOs;
|
|
using DeepDrftModels.Entities;
|
|
using DeepDrftModels.Enums;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.Logging;
|
|
using Models.Common;
|
|
|
|
namespace DeepDrftData.Repositories;
|
|
|
|
/// <summary>
|
|
/// Medium-aware release queries. Deliberately does NOT extend <c>Repository<DeepDrftContext, ReleaseEntity></c>:
|
|
/// 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 <see cref="DeepDrftContext"/>
|
|
/// directly so reads/writes stay in one unit of work.
|
|
/// </summary>
|
|
public class ReleaseRepository
|
|
{
|
|
private readonly DeepDrftContext _context;
|
|
private readonly ILogger<ReleaseRepository> _logger;
|
|
|
|
public ReleaseRepository(DeepDrftContext context, ILogger<ReleaseRepository> 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<ReleaseEntity> ApplyMediumInclude(IQueryable<ReleaseEntity> query, ReleaseMedium? medium)
|
|
=> medium switch
|
|
{
|
|
ReleaseMedium.Session => query.Include(r => r.SessionMetadata),
|
|
ReleaseMedium.Mix => query.Include(r => r.MixMetadata),
|
|
_ => query
|
|
};
|
|
|
|
// Paged release list, optionally narrowed by medium and a free-text/genre filter. The matching
|
|
// medium's satellite is Include'd; total count reflects every applied predicate (all before
|
|
// Skip/Take). The filter predicates mirror TrackRepository.GetPagedFilteredAsync so the release
|
|
// browse path searches and filters identically to the track path.
|
|
public async Task<PagedResult<ReleaseEntity>> GetPagedByMediumAsync(
|
|
PagingParameters<ReleaseEntity> paging,
|
|
ReleaseMedium? medium,
|
|
ReleaseFilter? filter,
|
|
CancellationToken ct)
|
|
{
|
|
IQueryable<ReleaseEntity> query = _context.Releases.Where(r => !r.IsDeleted);
|
|
if (medium.HasValue)
|
|
query = query.Where(r => r.Medium == medium.Value);
|
|
|
|
if (filter is not null)
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(filter.SearchText))
|
|
{
|
|
// Postgres case-insensitive LIKE. The '%' wraps make it a contains-match; ILike is
|
|
// EF-translatable where ToLower().Contains() is not. Title/Artist are non-null columns
|
|
// on the release itself, so no navigation guard is needed (unlike the track path).
|
|
var pattern = $"%{filter.SearchText}%";
|
|
query = query.Where(r =>
|
|
EF.Functions.ILike(r.Title, pattern)
|
|
|| EF.Functions.ILike(r.Artist, pattern));
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(filter.Genre))
|
|
query = query.Where(r => r.Genre == filter.Genre);
|
|
}
|
|
|
|
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<ReleaseEntity>
|
|
{
|
|
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<ReleaseEntity?> 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<Dictionary<long, int>> GetTrackCountsByReleaseIdsAsync(
|
|
IEnumerable<long> 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<List<string>> 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);
|
|
}
|
|
}
|