using Data.Data.Repositories; using Data.Errors; 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; 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); // Aggregate figures for the public home hero stat row, assembled in as few round-trips as is clean. // All counts go through Query (!t.IsDeleted) plus an explicit !t.Release.IsDeleted guard so tracks // under a directly-deleted release are also excluded. Mix runtime sums DurationSeconds with a // null-coalesce to 0 so not-yet-backfilled rows contribute zero rather than throwing or skewing the // total. The cut release-type breakdown is grouped here so a zero-count type is simply absent from // the result (no present-with-zero row). public async Task GetHomeStatsAsync(CancellationToken ct = default) { var releases = _context.Set().Where(r => !r.IsDeleted); var cutTrackCount = await Query .CountAsync(t => t.Release != null && !t.Release.IsDeleted && t.Release.Medium == ReleaseMedium.Cut, ct); var cutReleaseTypeCounts = await releases .Where(r => r.Medium == ReleaseMedium.Cut) .GroupBy(r => r.ReleaseType) .Select(g => new CutReleaseTypeCount { ReleaseType = g.Key, Count = g.Count() }) .ToListAsync(ct); var mixReleaseCount = await releases .CountAsync(r => r.Medium == ReleaseMedium.Mix, ct); var mixRuntimeSeconds = await Query .Where(t => t.Release != null && !t.Release.IsDeleted && t.Release.Medium == ReleaseMedium.Mix) .SumAsync(t => t.DurationSeconds ?? 0d, ct); return new HomeStatsDto { CutTrackCount = cutTrackCount, CutReleaseTypeCounts = cutReleaseTypeCounts, MixReleaseCount = mixReleaseCount, MixRuntimeSeconds = mixRuntimeSeconds, }; } // EntryKey + stored duration for non-deleted tracks whose SQL duration is still null — the work list // the one-time duration backfill iterates. The migration cannot read the vault, so duration is filled // at runtime: this lists which rows still need it, the backfill reads each from the vault and writes // it back via UpdateDurationAsync. public async Task> GetTracksMissingDurationAsync(CancellationToken ct = default) => await Query.Where(t => t.DurationSeconds == null).ToListAsync(ct); // Set-based duration write for one track (no load round-trip), used by the backfill. The // DurationSeconds == null guard keeps a re-run from re-stamping updated_at on an already-filled row // and from clobbering a value the upload path may have set in the meantime. public async Task UpdateDurationAsync(long id, double durationSeconds, CancellationToken ct = default) => await Query .Where(t => t.Id == id && t.DurationSeconds == null) .ExecuteUpdateAsync(s => s .SetProperty(t => t.DurationSeconds, durationSeconds) .SetProperty(t => t.UpdatedAt, DateTime.UtcNow), ct); // Unconditional duration overwrite for one track (no load round-trip), used by the replace-audio // path. Unlike UpdateDurationAsync, there is no null guard — replace always overwrites the // existing value because a normally-uploaded track already has a non-null DurationSeconds and the // null-guarded backfill query would match zero rows and silently leave it stale. Returns the count // of rows affected; zero means the track was removed between the GetById lookup and this write. public async Task SetDurationAsync(long id, double durationSeconds, CancellationToken ct = default) => await Query .Where(t => t.Id == id) .ExecuteUpdateAsync(s => s .SetProperty(t => t.DurationSeconds, durationSeconds) .SetProperty(t => t.UpdatedAt, DateTime.UtcNow), 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.DurationSeconds = source.DurationSeconds; target.ReleaseId = source.ReleaseId; } }