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; /// /// 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 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> GetPagedByMediumAsync( PagingParameters paging, ReleaseMedium? medium, ReleaseFilter? filter, CancellationToken ct) { IQueryable 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 { 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); } }