f02974b3c2
- Add CmsTrackBrowserViewModel.Invalidate(); called from TrackEdit/BatchEdit on save or delete so album/genre cache is invalidated and re-fetches on next mode switch
- CmsAlbumBrowser now handles 0-track releases: confirm dialog + DeleteReleaseAsync instead of early return; partial-failure path also fires OnReleasesChanged to trigger cache invalidation
- TrackList.OnAlbumsChanged now calls VM.Invalidate() so genres stay fresh after any album delete
- UnifiedTrackService.DeleteAsync cascades release soft-delete when last live track is removed (non-fatal; logs on failure)
- New DELETE api/track/release/{id} endpoint (ApiKeyAuthorize) for direct release soft-delete
- EF migration SoftDeleteOrphanedReleases backfills existing orphaned release rows via raw SQL (data-only, no schema change)
212 lines
10 KiB
C#
212 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);
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|