270 lines
13 KiB
C#
270 lines
13 KiB
C#
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<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);
|
|
|
|
// Aggregate figures for the public home hero stat row, assembled in as few round-trips as is clean.
|
|
// All counts go through Query / the release set's !IsDeleted filter so soft-deleted rows never count.
|
|
// 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<HomeStatsDto> GetHomeStatsAsync(CancellationToken ct = default)
|
|
{
|
|
var releases = _context.Set<ReleaseEntity>().Where(r => !r.IsDeleted);
|
|
|
|
var cutTrackCount = await Query
|
|
.CountAsync(t => t.Release != null && 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.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<List<TrackEntity>> 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<int> 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);
|
|
|
|
// 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.DurationSeconds = source.DurationSeconds;
|
|
target.ReleaseId = source.ReleaseId;
|
|
}
|
|
}
|