diff --git a/DeepDrftAPI/Controllers/ReleaseController.cs b/DeepDrftAPI/Controllers/ReleaseController.cs index 00c4e5d..d566c72 100644 --- a/DeepDrftAPI/Controllers/ReleaseController.cs +++ b/DeepDrftAPI/Controllers/ReleaseController.cs @@ -31,12 +31,15 @@ public class ReleaseController : ControllerBase _logger = logger; } - // GET api/release?medium=session&page=1&pageSize=20&sortColumn=Title&sortDescending=false (unauth) - // Paged release list, optionally filtered to one medium. The matching medium's metadata satellite is - // populated; the others are null. Public browse data, same auth posture as GET api/track/page. + // GET api/release?medium=session&q=text&genre=House&page=1&pageSize=20&sortColumn=Title&sortDescending=false (unauth) + // Paged release list, optionally narrowed by medium, free-text search (q), and genre. The matching + // medium's metadata satellite is populated; the others are null. Backs the public /archive browser. + // Public browse data, same auth posture as GET api/track/page. [HttpGet] public async Task GetReleases( [FromQuery] string? medium = null, + [FromQuery] string? q = null, + [FromQuery] string? genre = null, [FromQuery] int page = 1, [FromQuery] int pageSize = 20, [FromQuery] string? sortColumn = null, @@ -51,7 +54,8 @@ public class ReleaseController : ControllerBase parsedMedium = m; } - var result = await _releaseService.GetPagedAsync(page, pageSize, sortColumn, sortDescending, parsedMedium, ct); + var filter = new ReleaseFilter { SearchText = q, Genre = genre }; + var result = await _releaseService.GetPagedAsync(page, pageSize, sortColumn, sortDescending, parsedMedium, filter, ct); if (!result.Success || result.Value is null) { var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error"; diff --git a/DeepDrftData/IReleaseService.cs b/DeepDrftData/IReleaseService.cs index 392ad62..dc567db 100644 --- a/DeepDrftData/IReleaseService.cs +++ b/DeepDrftData/IReleaseService.cs @@ -12,10 +12,10 @@ namespace DeepDrftData; /// public interface IReleaseService { - /// Paginated releases, optionally filtered to one medium. The matching medium's metadata satellite is included in the result. Omit medium for all releases. + /// Paginated releases, optionally narrowed by medium and a free-text/genre filter. The matching medium's metadata satellite is included in the result. Omit medium for all releases; omit filter for no search/genre narrowing. Task>> GetPagedAsync( int page, int pageSize, string? sortColumn, bool sortDescending, - ReleaseMedium? medium, CancellationToken cancellationToken = default); + ReleaseMedium? medium, ReleaseFilter? filter = null, CancellationToken cancellationToken = default); /// Single release with both metadata navs included (nulls for non-matching media). Task> GetByIdAsync(long id, CancellationToken cancellationToken = default); diff --git a/DeepDrftData/ReleaseManager.cs b/DeepDrftData/ReleaseManager.cs index 80416d2..0763b51 100644 --- a/DeepDrftData/ReleaseManager.cs +++ b/DeepDrftData/ReleaseManager.cs @@ -42,7 +42,7 @@ public class ReleaseManager : IReleaseService public async Task>> GetPagedAsync( int page, int pageSize, string? sortColumn, bool sortDescending, - ReleaseMedium? medium, CancellationToken cancellationToken = default) + ReleaseMedium? medium, ReleaseFilter? filter = null, CancellationToken cancellationToken = default) { try { @@ -54,7 +54,9 @@ public class ReleaseManager : IReleaseService IsDescending = sortDescending, }; - var entityPage = await _repository.GetPagedByMediumAsync(parameters, medium, cancellationToken); + // Collapse an all-null filter to null so the repository skips the predicate block entirely. + var effectiveFilter = filter is { IsEmpty: false } ? filter : null; + var entityPage = await _repository.GetPagedByMediumAsync(parameters, medium, effectiveFilter, cancellationToken); var releaseIds = entityPage.Items.Select(r => r.Id).ToList(); var counts = await _repository.GetTrackCountsByReleaseIdsAsync(releaseIds, cancellationToken); diff --git a/DeepDrftData/Repositories/ReleaseRepository.cs b/DeepDrftData/Repositories/ReleaseRepository.cs index 2f1ccb3..0bc1158 100644 --- a/DeepDrftData/Repositories/ReleaseRepository.cs +++ b/DeepDrftData/Repositories/ReleaseRepository.cs @@ -1,4 +1,5 @@ using DeepDrftData.Data; +using DeepDrftModels.DTOs; using DeepDrftModels.Entities; using DeepDrftModels.Enums; using Microsoft.EntityFrameworkCore; @@ -36,17 +37,37 @@ public class ReleaseRepository _ => query }; - // Paged, optionally medium-filtered release list. The matching medium's satellite is Include'd; - // total count reflects the medium filter (applied before Skip/Take). + // Paged release list, optionally narrowed by medium and a free-text/genre filter. The matching + // medium's satellite is Include'd; total count reflects every applied predicate (all before + // Skip/Take). The filter predicates mirror TrackRepository.GetPagedFilteredAsync so the release + // browse path searches and filters identically to the track path. public async Task> GetPagedByMediumAsync( PagingParameters paging, ReleaseMedium? medium, + ReleaseFilter? filter, CancellationToken ct) { IQueryable query = _context.Releases.Where(r => !r.IsDeleted); if (medium.HasValue) query = query.Where(r => r.Medium == medium.Value); + 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. Title/Artist are non-null columns + // on the release itself, so no navigation guard is needed (unlike the track path). + var pattern = $"%{filter.SearchText}%"; + query = query.Where(r => + EF.Functions.ILike(r.Title, pattern) + || EF.Functions.ILike(r.Artist, pattern)); + } + + if (!string.IsNullOrWhiteSpace(filter.Genre)) + query = query.Where(r => r.Genre == filter.Genre); + } + query = ApplyMediumInclude(query, medium); var totalCount = await query.CountAsync(ct); diff --git a/DeepDrftModels/DTOs/ReleaseFilter.cs b/DeepDrftModels/DTOs/ReleaseFilter.cs new file mode 100644 index 0000000..2758416 --- /dev/null +++ b/DeepDrftModels/DTOs/ReleaseFilter.cs @@ -0,0 +1,25 @@ +namespace DeepDrftModels.DTOs; + +/// +/// Cross-project release filter contract for the paged release read surface. Threaded alongside +/// (never inside) the external PagingParameters<T>, which cannot carry a where-clause, +/// and beside the medium filter (a separate enum param, not a free-text field). Mirrors +/// for the release-cardinal browse path. An instance with all properties +/// null is equivalent to no filter — see . +/// +public class ReleaseFilter +{ + /// Free-text, case-insensitive LIKE across the release Title and Artist. + public string? SearchText { get; set; } + + /// Exact genre match. + public string? Genre { get; set; } + + /// + /// True when no predicate is set. An empty filter must produce identical results to a null + /// filter, so callers collapse it to null before querying. + /// + public bool IsEmpty => + string.IsNullOrWhiteSpace(SearchText) + && string.IsNullOrWhiteSpace(Genre); +} diff --git a/DeepDrftPublic.Client/Clients/ReleaseClient.cs b/DeepDrftPublic.Client/Clients/ReleaseClient.cs index a24e7c1..3e89769 100644 --- a/DeepDrftPublic.Client/Clients/ReleaseClient.cs +++ b/DeepDrftPublic.Client/Clients/ReleaseClient.cs @@ -29,7 +29,9 @@ public class ReleaseClient int page, int pageSize, string? sortColumn = null, - bool sortDescending = false) + bool sortDescending = false, + string? search = null, + string? genre = null) { var queryArgs = new Dictionary { @@ -40,6 +42,12 @@ public class ReleaseClient if (!string.IsNullOrEmpty(medium)) queryArgs["medium"] = medium; + if (!string.IsNullOrEmpty(search)) + queryArgs["q"] = search; + + if (!string.IsNullOrEmpty(genre)) + queryArgs["genre"] = genre; + if (!string.IsNullOrEmpty(sortColumn)) queryArgs["sortColumn"] = sortColumn; diff --git a/DeepDrftPublic.Client/Pages/ArchiveView.razor b/DeepDrftPublic.Client/Pages/ArchiveView.razor index baf38ac..3eeb150 100644 --- a/DeepDrftPublic.Client/Pages/ArchiveView.razor +++ b/DeepDrftPublic.Client/Pages/ArchiveView.razor @@ -1,31 +1,116 @@ @page "/archive" +@using DeepDrftModels.Enums DeepDrft Archive
-
- @foreach (var medium in _media) + @* Search + filter affordances are interactive-only: the debounce timer and chip selection + need WASM. During prerender/non-interactive they are hidden, matching TracksView's gate. + The release grid still prerenders so the archive is meaningful before hydration. *@ + @if (RendererInfo.IsInteractive) + { +
+ +
+ +
+ + All + @foreach (var medium in _media) + { + @MediumLabel(medium) + } + +
+ + @if (_genres.Count > 0) { - -
- - @medium.Title - @medium.Blurb -
-
+
+ + @foreach (var genre in _genres) + { + @genre.Genre + } + +
} -
+ } + + @if (_loading) + { + + @foreach (var _ in Enumerable.Range(0, 8)) + { + +
+ +
+
+ } +
+ } + else if (_releases.Count == 0) + { +
+ No releases found +
+ } + else + { + + @foreach (var release in _releases) + { + + + + } + + }
- -@code { - private record MediumCard(string Title, string Blurb, string Route, string Icon); - - private static readonly MediumCard[] _media = - [ - new("Cuts", "Studio recordings — singles, EPs, and albums.", "/cuts", Icons.Material.Filled.Album), - new("Sessions", "Single live takes, each with its own hero image.", "/sessions", Icons.Material.Filled.Piano), - new("Mixes", "Long-form continuous mixes with high-resolution waveforms.", "/mixes", Icons.Material.Filled.GraphicEq), - ]; -} diff --git a/DeepDrftPublic.Client/Pages/ArchiveView.razor.cs b/DeepDrftPublic.Client/Pages/ArchiveView.razor.cs new file mode 100644 index 0000000..a7843ba --- /dev/null +++ b/DeepDrftPublic.Client/Pages/ArchiveView.razor.cs @@ -0,0 +1,149 @@ +using DeepDrftModels.DTOs; +using DeepDrftModels.Enums; +using DeepDrftPublic.Client.Services; +using Microsoft.AspNetCore.Components; + +namespace DeepDrftPublic.Client.Pages; + +/// +/// The public archive: a release-cardinal searchable browser over every release across all media +/// (Phase 9 §8.H, decision H2). Replaces the former three-card medium overview. Search (Title / +/// Artist), an enum-driven medium filter, and a genre filter narrow the release list; each card +/// routes to its per-medium detail. Mirrors the seam: the unfiltered first +/// page is bridged across the prerender -> WASM boundary so hydration neither re-fetches nor replays +/// the card entrance animations. +/// +public partial class ArchiveView : ComponentBase, IDisposable +{ + private const string PersistKey = "archive-releases"; + + // A large page covers the full library in one fetch — the archive has no pager, matching the + // medium galleries (AlbumsView / MediumBrowseBase) which also pull pageSize: 100. + private const int PageSize = 100; + + [Inject] public required IReleaseDataService ReleaseData { get; set; } + [Inject] public required ITrackDataService TrackData { get; set; } + [Inject] public required PersistentComponentState PersistentState { get; set; } + + // Medium filter chips are enum-driven so a fourth medium surfaces a chip from one lookup entry, + // with no markup fork (Phase 9 extension discipline). + private static readonly ReleaseMedium[] _media = Enum.GetValues(); + + private bool _loading = true; + private List _releases = []; + private List _genres = []; + + // null medium == All; null genre == no genre filter. SearchText null/empty == no search. + private ReleaseMedium? _selectedMedium; + private string? _selectedGenre; + + private string? SearchText { get; set; } + + private PersistingComponentStateSubscription _persistingSubscription; + + private bool HasActiveFilter => + _selectedMedium is not null + || !string.IsNullOrWhiteSpace(_selectedGenre) + || !string.IsNullOrWhiteSpace(SearchText); + + protected override async Task OnInitializedAsync() + { + _persistingSubscription = PersistentState.RegisterOnPersisting(Persist); + + // The genre chip source is the release-cardinal distinct-genre list (already sourced from the + // release join — see GetDistinctGenresAsync). It only renders interactively, so it is fetched + // lazily on the interactive pass rather than persisted. + if (RendererInfo.IsInteractive) + await LoadGenres(); + + // The prerendered page is always the unfiltered first page. Restore it only when no filter is + // active; a filtered interactive pass must fetch its own narrowed result instead. + if (!HasActiveFilter + && PersistentState.TryTakeFromJson>(PersistKey, out var restored) + && restored is not null) + { + _releases = restored; + _loading = false; + return; + } + + await LoadReleases(); + } + + private async Task LoadGenres() + { + var result = await TrackData.GetGenres(); + if (result is { Success: true, Value: { } genres }) + _genres = genres; + } + + private async Task LoadReleases() + { + _loading = true; + + var result = await ReleaseData.GetPaged( + medium: _selectedMedium?.ToString().ToLowerInvariant(), + page: 1, + pageSize: PageSize, + search: SearchText, + genre: _selectedGenre); + + _releases = result is { Success: true, Value.Items: { } items } + ? items.ToList() + : []; + + _loading = false; + } + + // Fired by MudTextField after its 400ms DebounceInterval, so only the trailing keystroke in a + // burst reaches here. Re-fetches with the composed filter (search + medium + genre). + private async Task OnSearchInput(string? value) + { + SearchText = string.IsNullOrWhiteSpace(value) ? null : value; + await LoadReleases(); + } + + private async Task OnMediumSelected(ReleaseMedium? medium) + { + _selectedMedium = medium; + await LoadReleases(); + } + + private async Task OnGenreSelected(string? genre) + { + _selectedGenre = string.IsNullOrWhiteSpace(genre) ? null : genre; + await LoadReleases(); + } + + // Per-medium detail target. Session/Mix open their own detail page; a Cut has no single-release + // detail page, so it opens the track gallery filtered to its release title — the same destination + // AlbumsView's Cut cards use, preserving the established navigation. + private static string DetailHref(ReleaseDto release) => release.Medium switch + { + ReleaseMedium.Session => $"/sessions/{release.Id}", + ReleaseMedium.Mix => $"/mixes/{release.Id}", + _ => $"/tracks?album={Uri.EscapeDataString(release.Title)}", + }; + + // Display label for a medium filter chip. Centralised so a new medium's label is one entry, not a + // markup change. "DJ Mix" matches the CMS Type-chip wording (§8.D). + private static string MediumLabel(ReleaseMedium medium) => medium switch + { + ReleaseMedium.Cut => "Cuts", + ReleaseMedium.Session => "Sessions", + ReleaseMedium.Mix => "Mixes", + _ => medium.ToString(), + }; + + private Task Persist() + { + // Only the unfiltered first page is safe to restore onto a later plain /archive visit. A + // filtered render leaves the cache untouched so the bridge never serves narrowed results to an + // unfiltered load. + if (_releases.Count > 0 && !HasActiveFilter) + PersistentState.PersistAsJson(PersistKey, _releases); + return Task.CompletedTask; + } + + public void Dispose() => _persistingSubscription.Dispose(); +} diff --git a/DeepDrftPublic.Client/Pages/ArchiveView.razor.css b/DeepDrftPublic.Client/Pages/ArchiveView.razor.css index ab7de5e..d47c02e 100644 --- a/DeepDrftPublic.Client/Pages/ArchiveView.razor.css +++ b/DeepDrftPublic.Client/Pages/ArchiveView.razor.css @@ -2,11 +2,30 @@ padding-top: 16px; } -.archive-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); - gap: 2rem; - margin-top: 1rem; +.archive-search-row { + display: flex; + justify-content: flex-start; + padding: 0 0 12px 0; +} + +/* archive-search-field rides on MudTextField, whose root is a child Razor component element. + Blazor isolation does not stamp the scope attribute there, so ::deep is required. */ +::deep .archive-search-field { + max-width: 420px; + width: 100%; +} + +.archive-filter-row { + display: flex; + justify-content: flex-start; + flex-wrap: wrap; + padding: 0 0 12px 0; +} + +.archive-card-center { + display: flex; + justify-content: center; + width: 100%; } .archive-card-link { @@ -14,35 +33,51 @@ color: inherit; } -.archive-card { +.archive-release-card { display: flex; flex-direction: column; - align-items: center; - text-align: center; - gap: 0.75rem; - padding: 2.5rem 1.5rem; - border: 1px solid var(--mud-palette-lines-default); + width: 200px; + cursor: pointer; border-radius: 8px; - background-color: var(--mud-palette-surface); - transition: transform 120ms ease, box-shadow 120ms ease; + overflow: hidden; + transition: transform 120ms ease; } -.archive-card:hover { +.archive-release-card:hover { transform: translateY(-4px); - box-shadow: 0 8px 28px color-mix(in srgb, var(--mud-palette-text-secondary) 18%, transparent); } -/* archive-card-icon rides on MudIcon (child Razor component); ::deep pierces its output. */ -::deep .archive-card-icon { - font-size: 56px; - color: var(--mud-palette-primary); +.archive-release-cover { + width: 200px; + height: 200px; + background-size: cover; + background-position: center; + background-repeat: no-repeat; } -/* archive-card-title / archive-card-blurb ride on MudText (child Razor component). */ -::deep .archive-card-title { +.archive-release-cover--fallback { + background-color: var(--mud-palette-dark, #1a2238); +} + +.archive-release-body { + padding: 8px 4px 0 4px; + display: flex; + flex-direction: column; + gap: 2px; +} + +/* archive-release-title / archive-release-artist ride on MudText (child Razor component); ::deep + pierces into its output since Blazor isolation does not scope-stamp child component roots. */ +::deep .archive-release-title { font-weight: 600; } -::deep .archive-card-blurb { +::deep .archive-release-artist { opacity: 0.7; } + +.archive-empty { + display: flex; + justify-content: center; + padding: 48px 0; +} diff --git a/DeepDrftPublic.Client/Services/IReleaseDataService.cs b/DeepDrftPublic.Client/Services/IReleaseDataService.cs index fcc7aa8..0ecee43 100644 --- a/DeepDrftPublic.Client/Services/IReleaseDataService.cs +++ b/DeepDrftPublic.Client/Services/IReleaseDataService.cs @@ -12,13 +12,15 @@ namespace DeepDrftPublic.Client.Services; /// public interface IReleaseDataService { - /// Paged releases, optionally filtered to one medium ("cut" | "session" | "mix"). + /// Paged releases, optionally narrowed by medium ("cut" | "session" | "mix"), free-text search, and genre. Task>> GetPaged( string? medium, int page, int pageSize, string? sortColumn = null, - bool sortDescending = false); + bool sortDescending = false, + string? search = null, + string? genre = null); /// Single release with both metadata satellites (nulls for non-matching media). Task> GetById(long id); diff --git a/DeepDrftPublic.Client/Services/ReleaseClientDataService.cs b/DeepDrftPublic.Client/Services/ReleaseClientDataService.cs index 02d3328..637a2d0 100644 --- a/DeepDrftPublic.Client/Services/ReleaseClientDataService.cs +++ b/DeepDrftPublic.Client/Services/ReleaseClientDataService.cs @@ -24,8 +24,10 @@ public class ReleaseClientDataService : IReleaseDataService int page, int pageSize, string? sortColumn = null, - bool sortDescending = false) - => _releaseClient.GetPaged(medium, page, pageSize, sortColumn, sortDescending); + bool sortDescending = false, + string? search = null, + string? genre = null) + => _releaseClient.GetPaged(medium, page, pageSize, sortColumn, sortDescending, search, genre); public Task> GetById(long id) => _releaseClient.GetById(id); diff --git a/DeepDrftPublic/Controllers/ReleaseProxyController.cs b/DeepDrftPublic/Controllers/ReleaseProxyController.cs index dfcf819..764d2d2 100644 --- a/DeepDrftPublic/Controllers/ReleaseProxyController.cs +++ b/DeepDrftPublic/Controllers/ReleaseProxyController.cs @@ -22,10 +22,12 @@ public class ReleaseProxyController : ControllerBase _logger = logger; } - /// Proxies the paged release list, forwarding the optional medium filter and sort params. + /// Proxies the paged release list, forwarding the optional medium, search (q), genre, and sort params. [HttpGet] public async Task GetReleases( [FromQuery] string? medium = null, + [FromQuery] string? q = null, + [FromQuery] string? genre = null, [FromQuery] int page = 1, [FromQuery] int pageSize = 20, [FromQuery] string? sortColumn = null, @@ -35,6 +37,10 @@ public class ReleaseProxyController : ControllerBase var query = $"api/release?page={page}&pageSize={pageSize}&sortDescending={sortDescending}"; if (!string.IsNullOrWhiteSpace(medium)) query += $"&medium={Uri.EscapeDataString(medium)}"; + if (!string.IsNullOrWhiteSpace(q)) + query += $"&q={Uri.EscapeDataString(q)}"; + if (!string.IsNullOrWhiteSpace(genre)) + query += $"&genre={Uri.EscapeDataString(genre)}"; if (!string.IsNullOrWhiteSpace(sortColumn)) query += $"&sortColumn={Uri.EscapeDataString(sortColumn)}"; diff --git a/DeepDrftTests/ReleaseBrowseQueryTests.cs b/DeepDrftTests/ReleaseBrowseQueryTests.cs new file mode 100644 index 0000000..ddcd7ec --- /dev/null +++ b/DeepDrftTests/ReleaseBrowseQueryTests.cs @@ -0,0 +1,209 @@ +using DeepDrftData.Data; +using DeepDrftData.Repositories; +using DeepDrftModels.DTOs; +using DeepDrftModels.Entities; +using DeepDrftModels.Enums; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using Models.Common; + +namespace DeepDrftTests; + +/// +/// Query-shape tests for the release-cardinal browse path that backs the public /archive browser +/// (Phase 9 §8.H). Exercises : the medium +/// narrowing, the genre filter, and the null/empty-filter passthrough. +/// +/// Provider note: these run on the EF in-memory provider, which executes LINQ in process. That covers +/// the medium predicate, exact genre equality, and the count/ordering — every predicate except the +/// free-text branch. That branch uses EF.Functions.ILike, an Npgsql-only relational function +/// with no in-memory translation, so the SearchText case is a Postgres integration test gated on a DSN +/// (). Without a +/// configured database it is ignored rather than asserted against a provider that never runs the +/// predicate — mirroring . +/// +[TestFixture] +public class ReleaseBrowseQueryTests +{ + private DeepDrftContext _context = null!; + + [SetUp] + public void SetUp() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + _context = new DeepDrftContext(options); + } + + [TearDown] + public void TearDown() => _context.Dispose(); + + private ReleaseRepository CreateRepository() + => new(_context, NullLogger.Instance); + + private static ReleaseEntity Release( + string title, string artist, ReleaseMedium medium = ReleaseMedium.Cut, string? genre = null) + => new() + { + Title = title, + Artist = artist, + Medium = medium, + Genre = genre, + }; + + private async Task SeedAsync(params ReleaseEntity[] releases) + { + _context.Releases.AddRange(releases); + await _context.SaveChangesAsync(); + } + + private static PagingParameters DefaultPaging() + => new() { Page = 1, PageSize = 20, OrderBy = r => r.Id, IsDescending = false }; + + // Medium filter narrows to a single medium: only releases of that medium are returned, and + // TotalCount reflects the filtered set, not the table. + [Test] + public async Task GetPagedByMediumAsync_WithMedium_ReturnsOnlyThatMedium() + { + await SeedAsync( + Release("Cut One", "A", ReleaseMedium.Cut), + Release("Session One", "B", ReleaseMedium.Session), + Release("Mix One", "C", ReleaseMedium.Mix), + Release("Session Two", "D", ReleaseMedium.Session)); + + var repo = CreateRepository(); + var result = await repo.GetPagedByMediumAsync( + DefaultPaging(), ReleaseMedium.Session, filter: null, CancellationToken.None); + + Assert.That(result.TotalCount, Is.EqualTo(2)); + Assert.That(result.Items.Select(r => r.Title), Is.EquivalentTo(new[] { "Session One", "Session Two" })); + } + + // Clearing the medium filter (null) shows every medium — the all-releases archive default. + [Test] + public async Task GetPagedByMediumAsync_WithNullMedium_ReturnsAllMedia() + { + await SeedAsync( + Release("Cut One", "A", ReleaseMedium.Cut), + Release("Session One", "B", ReleaseMedium.Session), + Release("Mix One", "C", ReleaseMedium.Mix)); + + var repo = CreateRepository(); + var result = await repo.GetPagedByMediumAsync( + DefaultPaging(), medium: null, filter: null, CancellationToken.None); + + Assert.That(result.TotalCount, Is.EqualTo(3)); + Assert.That(result.Items.Select(r => r.Title), + Is.EquivalentTo(new[] { "Cut One", "Session One", "Mix One" })); + } + + // Genre filter narrows across all media: only releases of that exact genre are returned, regardless + // of medium, and TotalCount reflects the filtered set. + [Test] + public async Task GetPagedByMediumAsync_WithGenre_ReturnsOnlyThatGenreAcrossMedia() + { + await SeedAsync( + Release("Cut One", "A", ReleaseMedium.Cut, genre: "Techno"), + Release("Session One", "B", ReleaseMedium.Session, genre: "House"), + Release("Mix One", "C", ReleaseMedium.Mix, genre: "Techno")); + + var repo = CreateRepository(); + var result = await repo.GetPagedByMediumAsync( + DefaultPaging(), medium: null, new ReleaseFilter { Genre = "Techno" }, CancellationToken.None); + + Assert.That(result.TotalCount, Is.EqualTo(2)); + Assert.That(result.Items.Select(r => r.Title), Is.EquivalentTo(new[] { "Cut One", "Mix One" })); + } + + // Medium and genre compose: the result is the intersection, narrowed both by medium and genre. + [Test] + public async Task GetPagedByMediumAsync_WithMediumAndGenre_ComposesBothPredicates() + { + await SeedAsync( + Release("Mix Techno", "A", ReleaseMedium.Mix, genre: "Techno"), + Release("Mix House", "B", ReleaseMedium.Mix, genre: "House"), + Release("Cut Techno", "C", ReleaseMedium.Cut, genre: "Techno")); + + var repo = CreateRepository(); + var result = await repo.GetPagedByMediumAsync( + DefaultPaging(), ReleaseMedium.Mix, new ReleaseFilter { Genre = "Techno" }, CancellationToken.None); + + Assert.That(result.TotalCount, Is.EqualTo(1)); + Assert.That(result.Items.Single().Title, Is.EqualTo("Mix Techno")); + } + + // A null filter is a passthrough: same items and count as no filter at all. An all-null + // ReleaseFilter is collapsed to null by the manager, so the repository sees null here. + [Test] + public async Task GetPagedByMediumAsync_WithNullFilter_ReturnsAllReleases() + { + await SeedAsync( + Release("Cut One", "A", ReleaseMedium.Cut), + Release("Session One", "B", ReleaseMedium.Session), + Release("Mix One", "C", ReleaseMedium.Mix)); + + var repo = CreateRepository(); + var result = await repo.GetPagedByMediumAsync( + DefaultPaging(), medium: null, filter: null, CancellationToken.None); + + Assert.That(result.TotalCount, Is.EqualTo(3)); + } + + // Soft-deleted releases never surface in the browse list, with or without a filter. + [Test] + public async Task GetPagedByMediumAsync_ExcludesSoftDeletedReleases() + { + var deleted = Release("Gone", "A", ReleaseMedium.Cut, genre: "Techno"); + deleted.IsDeleted = true; + await SeedAsync( + Release("Live", "B", ReleaseMedium.Cut, genre: "Techno"), + deleted); + + var repo = CreateRepository(); + var result = await repo.GetPagedByMediumAsync( + DefaultPaging(), medium: null, new ReleaseFilter { Genre = "Techno" }, CancellationToken.None); + + Assert.That(result.TotalCount, Is.EqualTo(1)); + Assert.That(result.Items.Single().Title, Is.EqualTo("Live")); + } + + // Free-text search across Title and Artist, case-insensitive. EF.Functions.ILike is Npgsql-only and + // does not translate on the in-memory provider, so this runs only against a real Postgres database + // supplied via the DEEPDRFT_TEST_PG environment variable. Without it the test is ignored rather than + // asserted against a provider that cannot execute the predicate. + [Test] + public async Task GetPagedByMediumAsync_WithSearchText_MatchesTitleOrArtistCaseInsensitive() + { + var dsn = Environment.GetEnvironmentVariable("DEEPDRFT_TEST_PG"); + if (string.IsNullOrWhiteSpace(dsn)) + Assert.Ignore("Set DEEPDRFT_TEST_PG to a Postgres connection string to run the ILike search test."); + + var options = new DbContextOptionsBuilder() + .UseNpgsql(dsn) + .Options; + await using var pg = new DeepDrftContext(options); + await pg.Database.EnsureCreatedAsync(); + + try + { + pg.Releases.AddRange( + Release("Nightfall", "jazzmin", ReleaseMedium.Session), + Release("All JAZZ Hands", "Brick", ReleaseMedium.Cut), + Release("Silence", "Nobody", ReleaseMedium.Mix)); + await pg.SaveChangesAsync(); + + var repo = new ReleaseRepository(pg, NullLogger.Instance); + var result = await repo.GetPagedByMediumAsync( + DefaultPaging(), medium: null, new ReleaseFilter { SearchText = "jazz" }, CancellationToken.None); + + Assert.That(result.Items.Select(r => r.Title), + Is.EquivalentTo(new[] { "Nightfall", "All JAZZ Hands" }), + "ILike matches 'jazz' case-insensitively in the release Title or Artist"); + } + finally + { + await pg.Database.EnsureDeletedAsync(); + } + } +}