using Data.Data.Repositories; using Data.Errors; using DeepDrftData.Data; using DeepDrftModels.DTOs; using DeepDrftModels.Entities; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Models.Common; namespace DeepDrftData.Repositories; public class TrackRepository : Repository { public TrackRepository( DeepDrftContext context, ILogger> logger, IDbExceptionClassifier? classifier = null) : base(context, logger, classifier: classifier) { } // Lookup by vault entry key. The base Repository<> only exposes id-based queries, so this // uses Query (soft-delete filtered) rather than the raw DbSet. public async Task GetByEntryKeyAsync(string entryKey) => await Query.FirstOrDefaultAsync(t => t.EntryKey == entryKey); // Picks one track uniformly at random. Two round-trips (count, then a single offset row) // rather than ORDER BY random() so the database never sorts the whole table — the catalogue // is small today but this keeps the cost flat as it grows. Returns null when empty so the // service surfaces a valid empty-library state, not an error. Uses Query (soft-delete // filtered) so deleted tracks are never candidates. public async Task GetRandomAsync(CancellationToken cancellationToken = default) { var count = await Query.CountAsync(cancellationToken); if (count == 0) return null; var index = Random.Shared.Next(count); return await Query .OrderBy(t => t.Id) .Skip(index) .Take(1) .FirstOrDefaultAsync(cancellationToken); } // Paged query with optional filter predicates. Built off Query (soft-delete filtered) rather than the // base GetPagedAsync(paging) overload, which takes no where-clause. The OrderBy expression and // direction ride in on the PagingParameters the manager already built, so sort + filter + // pagination compose. Filter predicates apply before sort and Skip/Take so TotalCount reflects // the filtered set. public async Task> GetPagedFilteredAsync( PagingParameters paging, TrackFilter? filter, CancellationToken ct = default) { IQueryable query = Query; 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. Album is nullable — ILike on a // null column yields false, which is the desired "no match" behaviour. var pattern = $"%{filter.SearchText}%"; query = query.Where(t => EF.Functions.ILike(t.TrackName, pattern) || EF.Functions.ILike(t.Artist, pattern) || (t.Album != null && EF.Functions.ILike(t.Album, pattern))); } if (!string.IsNullOrWhiteSpace(filter.Album)) query = query.Where(t => t.Album == filter.Album); if (!string.IsNullOrWhiteSpace(filter.Genre)) query = query.Where(t => t.Genre == filter.Genre); } 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, }; } // Distinct albums (non-null) with track counts and a representative cover key. The cover is the // first non-null ImagePath in the group; GroupBy + projection keeps it a single round-trip. public async Task> GetDistinctAlbumsAsync(CancellationToken ct = default) => await Query .Where(t => t.Album != null) .GroupBy(t => t.Album!) .Select(g => new AlbumSummaryDto { Album = g.Key, TrackCount = g.Count(), CoverImageKey = g .Where(t => t.ImagePath != null) .OrderBy(t => t.Id) .Select(t => t.ImagePath) .FirstOrDefault(), }) .OrderBy(a => a.Album) .ToListAsync(ct); // Distinct genres (non-null) with track counts. public async Task> GetDistinctGenresAsync(CancellationToken ct = default) => await Query .Where(t => t.Genre != null) .GroupBy(t => t.Genre!) .Select(g => new GenreSummaryDto { Genre = g.Key, TrackCount = g.Count(), }) .OrderBy(g => g.Genre) .ToListAsync(ct); protected override void UpdateEntity(TrackEntity target, TrackEntity source) { base.UpdateEntity(target, source); // copies CreatedAt, UpdatedAt, IsDeleted target.EntryKey = source.EntryKey; target.TrackName = source.TrackName; target.Artist = source.Artist; target.Album = source.Album; target.Genre = source.Genre; target.ReleaseDate = source.ReleaseDate; target.ImagePath = source.ImagePath; target.CreatedByUserId = source.CreatedByUserId; } }