Files
deepdrft/DeepDrftData/Repositories/TrackRepository.cs
T

217 lines
10 KiB
C#

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<DeepDrftContext, TrackEntity>
{
// The base Repository<> exposes Query (soft-delete-filtered IQueryable<TrackEntity>) 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<Repository<DeepDrftContext, TrackEntity>> 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<TrackEntity?> 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<IEnumerable<TrackEntity>> 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<TrackEntity?> 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<TrackEntity?> 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<PagedResult<TrackEntity>> GetPagedFilteredAsync(
PagingParameters<TrackEntity> 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<TrackEntity> 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<TrackEntity>
{
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<List<ReleaseEntity>> GetReleasesAsync(CancellationToken ct = default)
=> await _context.Set<ReleaseEntity>()
.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<List<GenreSummaryDto>> 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<Dictionary<long, int>> 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<ReleaseEntity?> GetReleaseByTitleAndArtistAsync(
string title, string artist, CancellationToken ct = default)
=> await _context.Set<ReleaseEntity>()
.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<ReleaseEntity> AddReleaseAsync(ReleaseEntity release, CancellationToken ct = default)
{
_context.Set<ReleaseEntity>().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<ReleaseEntity?> GetReleaseByIdAsync(long id, CancellationToken ct = default)
=> await _context.Set<ReleaseEntity>()
.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<ReleaseEntity>().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<ReleaseEntity>()
.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<int> 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;
}
}