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 { // The base Repository<> exposes Query (soft-delete-filtered IQueryable) but no // DbContext accessor, and release-cardinal queries need a second DbSet. Keep our own reference // to the injected context rather than reaching for a service locator — it is the same scoped // instance the base holds, so reads/writes stay in one unit of work. private readonly DeepDrftContext _context; public TrackRepository( DeepDrftContext context, ILogger> logger, IDbExceptionClassifier? classifier = null) : base(context, logger, classifier: classifier) { _context = context; } // Override base GetByIdAsync to include the Release navigation. Without this, the base // Query has no .Include, so Release is null on every entity (no lazy-loading proxies). public override async Task GetByIdAsync(long id) => await Query.Include(t => t.Release).FirstOrDefaultAsync(e => e.Id == id); // Override base GetAllAsync for the same reason — include Release so callers (e.g. // TrackManager.GetAll) receive fully-populated entities without a separate query. public override async Task> GetAllAsync() => await Query.Include(t => t.Release).ToListAsync(); // 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. Includes Release so the // converter can project the release-cardinal fields. public async Task GetByEntryKeyAsync(string entryKey) => await Query.Include(t => t.Release).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 .Include(t => t.Release) .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) { // Include Release so both the filter predicates and the converter can read release-cardinal // fields through the navigation. IQueryable query = Query.Include(t => t.Release); 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. Artist/Title live on the joined // Release, which is null for loose tracks — guard the navigation before ILike. var pattern = $"%{filter.SearchText}%"; query = query.Where(t => EF.Functions.ILike(t.TrackName, pattern) || (t.Release != null && EF.Functions.ILike(t.Release.Artist, pattern)) || (t.Release != null && EF.Functions.ILike(t.Release.Title, pattern))); } if (!string.IsNullOrWhiteSpace(filter.Album)) query = query.Where(t => t.Release != null && t.Release.Title == filter.Album); if (!string.IsNullOrWhiteSpace(filter.Genre)) query = query.Where(t => t.Release != null && t.Release.Genre == filter.Genre); // Exact release-id join. ReleaseId is a column on the track itself, so this needs no // navigation guard — it is the authoritative alternative to the Album title match. if (filter.ReleaseId is { } releaseId) query = query.Where(t => t.ReleaseId == releaseId); } 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, }; } // All non-deleted releases, title-ascending, each carrying its count of non-deleted tracks. // The TrackCount subquery keeps this a single round-trip; the manager projects to ReleaseDto. public async Task> GetReleasesAsync(CancellationToken ct = default) => await _context.Set() .Where(r => !r.IsDeleted) .OrderBy(r => r.Title) .ToListAsync(ct); // Distinct genres (non-null) with track counts, sourced from the release join. Counting tracks // (not releases) keeps the browse counts consistent with the track-level catalogue. Loose tracks // (no release) carry no genre and are excluded. public async Task> GetDistinctGenresAsync(CancellationToken ct = default) => await Query .Where(t => t.Release != null && t.Release.Genre != null) .GroupBy(t => t.Release!.Genre!) .Select(g => new GenreSummaryDto { Genre = g.Key, TrackCount = g.Count(), }) .OrderBy(g => g.Genre) .ToListAsync(ct); // Count of non-deleted tracks per release, keyed by ReleaseId. The manager joins this against // GetReleasesAsync to populate ReleaseDto.TrackCount without an N+1 fan-out. public async Task> GetTrackCountsByReleaseAsync(CancellationToken ct = default) => await Query .Where(t => t.ReleaseId != null) .GroupBy(t => t.ReleaseId!.Value) .Select(g => new { ReleaseId = g.Key, Count = g.Count() }) .ToDictionaryAsync(x => x.ReleaseId, x => x.Count, ct); // Resolve an existing release by its natural key (title + artist). Returns null when no match, // signalling the manager to create one. Soft-deleted releases never match. public async Task GetReleaseByTitleAndArtistAsync( string title, string artist, CancellationToken ct = default) => await _context.Set() .FirstOrDefaultAsync(r => r.Title == title && r.Artist == artist && !r.IsDeleted, ct); // Persist a new release row and return it with its assigned Id. Lives here (not the manager) // because the repository owns the DbContext — the manager stays free of direct context access. public async Task AddReleaseAsync(ReleaseEntity release, CancellationToken ct = default) { _context.Set().Add(release); await _context.SaveChangesAsync(ct); return release; } // Load a tracked release by id so the manager can edit its fields in place and save. Returns // null when the id does not resolve (or the release is soft-deleted). public async Task GetReleaseByIdAsync(long id, CancellationToken ct = default) => await _context.Set() .FirstOrDefaultAsync(r => r.Id == id && !r.IsDeleted, ct); // Persist edits to a release. Update marks the whole entity modified, so it works whether the // instance is the change-tracked one from GetReleaseByIdAsync or a detached graph. public async Task UpdateReleaseAsync(ReleaseEntity release, CancellationToken ct = default) { _context.Set().Update(release); await _context.SaveChangesAsync(ct); } // Soft-delete a release row in a single set-based UPDATE (no load round-trip). The !IsDeleted // guard makes a repeat call a no-op rather than re-stamping updated_at on an already-deleted row. public async Task SoftDeleteReleaseAsync(long id, CancellationToken ct = default) { await _context.Set() .Where(r => r.Id == id && !r.IsDeleted) .ExecuteUpdateAsync(s => s .SetProperty(r => r.IsDeleted, true) .SetProperty(r => r.UpdatedAt, DateTime.UtcNow), ct); } // Count of non-deleted tracks on a single release. Backs the delete-cascade decision in // UnifiedTrackService: a release with zero live tracks after a delete is soft-deleted too. // Uses Query (soft-delete filtered) so just-deleted tracks are excluded from the count. public async Task CountLiveTracksByReleaseAsync(long releaseId, CancellationToken ct = default) => await Query.CountAsync(t => t.ReleaseId == releaseId, 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.TrackNumber = source.TrackNumber; target.OriginalFileName = source.OriginalFileName; target.ReleaseId = source.ReleaseId; } }