feat(api): browse all media kinds, grouped search, delete media-items (#141, #161)

- Extend /api/library/browse to episodes, music videos, songs, other videos,
  images and remote streams (new LibraryBrowseMediaType values + hydrators);
  add optional Subtitle to LibraryBrowseItemResponseModel for leaf-item context
- Add GET /api/search: grouped per-kind results reusing the browse query/shape;
  empty query -> 422
- Add DELETE /api/media-items: body { ids }, empty -> 422, success -> 204
- Tests: SearchController, MediaItemsController, security + OpenAPI contract entries
- Regenerate openapi v1.json + web v1.d.ts

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-07 15:23:07 +02:00
co-authored by Claude Fable 5
parent e0f36a12fb
commit 6c68c291a0
16 changed files with 914 additions and 5 deletions
@@ -91,12 +91,24 @@ public class GetLibraryBrowseItemsHandler(
LibraryBrowseMediaType.TelevisionShow => [LuceneSearchIndex.ShowType], LibraryBrowseMediaType.TelevisionShow => [LuceneSearchIndex.ShowType],
LibraryBrowseMediaType.TelevisionSeason => [LuceneSearchIndex.SeasonType], LibraryBrowseMediaType.TelevisionSeason => [LuceneSearchIndex.SeasonType],
LibraryBrowseMediaType.Artist => [LuceneSearchIndex.ArtistType], LibraryBrowseMediaType.Artist => [LuceneSearchIndex.ArtistType],
LibraryBrowseMediaType.Episode => [LuceneSearchIndex.EpisodeType],
LibraryBrowseMediaType.MusicVideo => [LuceneSearchIndex.MusicVideoType],
LibraryBrowseMediaType.Song => [LuceneSearchIndex.SongType],
LibraryBrowseMediaType.OtherVideo => [LuceneSearchIndex.OtherVideoType],
LibraryBrowseMediaType.Image => [LuceneSearchIndex.ImageType],
LibraryBrowseMediaType.RemoteStream => [LuceneSearchIndex.RemoteStreamType],
null => null =>
[ [
LuceneSearchIndex.MovieType, LuceneSearchIndex.MovieType,
LuceneSearchIndex.ShowType, LuceneSearchIndex.ShowType,
LuceneSearchIndex.SeasonType, LuceneSearchIndex.SeasonType,
LuceneSearchIndex.ArtistType LuceneSearchIndex.ArtistType,
LuceneSearchIndex.EpisodeType,
LuceneSearchIndex.MusicVideoType,
LuceneSearchIndex.SongType,
LuceneSearchIndex.OtherVideoType,
LuceneSearchIndex.ImageType,
LuceneSearchIndex.RemoteStreamType
], ],
_ => [] _ => []
}; };
@@ -115,6 +127,15 @@ public class GetLibraryBrowseItemsHandler(
List<int> showIds = searchItems.Where(i => i.Type == LuceneSearchIndex.ShowType).Select(i => i.Id).ToList(); List<int> showIds = searchItems.Where(i => i.Type == LuceneSearchIndex.ShowType).Select(i => i.Id).ToList();
List<int> seasonIds = searchItems.Where(i => i.Type == LuceneSearchIndex.SeasonType).Select(i => i.Id).ToList(); List<int> seasonIds = searchItems.Where(i => i.Type == LuceneSearchIndex.SeasonType).Select(i => i.Id).ToList();
List<int> artistIds = searchItems.Where(i => i.Type == LuceneSearchIndex.ArtistType).Select(i => i.Id).ToList(); List<int> artistIds = searchItems.Where(i => i.Type == LuceneSearchIndex.ArtistType).Select(i => i.Id).ToList();
List<int> episodeIds = searchItems.Where(i => i.Type == LuceneSearchIndex.EpisodeType).Select(i => i.Id).ToList();
List<int> musicVideoIds =
searchItems.Where(i => i.Type == LuceneSearchIndex.MusicVideoType).Select(i => i.Id).ToList();
List<int> songIds = searchItems.Where(i => i.Type == LuceneSearchIndex.SongType).Select(i => i.Id).ToList();
List<int> otherVideoIds =
searchItems.Where(i => i.Type == LuceneSearchIndex.OtherVideoType).Select(i => i.Id).ToList();
List<int> imageIds = searchItems.Where(i => i.Type == LuceneSearchIndex.ImageType).Select(i => i.Id).ToList();
List<int> remoteStreamIds =
searchItems.Where(i => i.Type == LuceneSearchIndex.RemoteStreamType).Select(i => i.Id).ToList();
Dictionary<(string Type, int Id), LibraryBrowseItemResponseModel> hydrated = []; Dictionary<(string Type, int Id), LibraryBrowseItemResponseModel> hydrated = [];
@@ -138,6 +159,37 @@ public class GetLibraryBrowseItemsHandler(
hydrated[(LuceneSearchIndex.ArtistType, item.Id)] = item; hydrated[(LuceneSearchIndex.ArtistType, item.Id)] = item;
} }
foreach (LibraryBrowseItemResponseModel item in await GetEpisodes(dbContext, episodeIds, cancellationToken))
{
hydrated[(LuceneSearchIndex.EpisodeType, item.Id)] = item;
}
foreach (LibraryBrowseItemResponseModel item in await GetMusicVideos(dbContext, musicVideoIds, cancellationToken))
{
hydrated[(LuceneSearchIndex.MusicVideoType, item.Id)] = item;
}
foreach (LibraryBrowseItemResponseModel item in await GetSongs(dbContext, songIds, cancellationToken))
{
hydrated[(LuceneSearchIndex.SongType, item.Id)] = item;
}
foreach (LibraryBrowseItemResponseModel item in await GetOtherVideos(dbContext, otherVideoIds, cancellationToken))
{
hydrated[(LuceneSearchIndex.OtherVideoType, item.Id)] = item;
}
foreach (LibraryBrowseItemResponseModel item in await GetImages(dbContext, imageIds, cancellationToken))
{
hydrated[(LuceneSearchIndex.ImageType, item.Id)] = item;
}
foreach (LibraryBrowseItemResponseModel item in
await GetRemoteStreams(dbContext, remoteStreamIds, cancellationToken))
{
hydrated[(LuceneSearchIndex.RemoteStreamType, item.Id)] = item;
}
return searchItems return searchItems
.Where(i => hydrated.ContainsKey((i.Type, i.Id))) .Where(i => hydrated.ContainsKey((i.Type, i.Id)))
.Select(i => hydrated[(i.Type, i.Id)]) .Select(i => hydrated[(i.Type, i.Id)])
@@ -330,6 +382,307 @@ public class GetLibraryBrowseItemsHandler(
null)).ToList()); null)).ToList());
} }
private static async Task<List<LibraryBrowseItemResponseModel>> GetEpisodes(
TvContext dbContext,
List<int> ids,
CancellationToken cancellationToken)
{
if (ids.Count == 0)
{
return [];
}
return await dbContext.EpisodeMetadata
.AsNoTracking()
.Where(em => ids.Contains(em.EpisodeId))
.Include(em => em.Artwork)
.Include(em => em.Episode)
.ThenInclude(e => e.LibraryPath)
.ThenInclude(lp => lp.Library)
.Include(em => em.Episode)
.ThenInclude(e => e.MediaVersions)
.Include(em => em.Episode)
.ThenInclude(e => e.Season)
.ThenInclude(s => s.Show)
.ThenInclude(sh => sh.ShowMetadata)
.ToListAsync(cancellationToken)
.Map(list => list
.GroupBy(em => em.EpisodeId)
.Select(g => g.OrderBy(em => em.Id).First())
.Map(em => new LibraryBrowseItemResponseModel(
em.EpisodeId,
LibraryBrowseMediaType.Episode,
em.Title ?? string.Empty,
em.Episode.LibraryPath.LibraryId,
em.Episode.LibraryPath.Library.Name,
ArtworkWithFallback(em, ArtworkKind.Thumbnail, ArtworkKind.Poster),
BestDuration(em.Episode.MediaVersions),
1,
null,
CollectionType.Episode,
null,
null,
null,
null,
em.EpisodeId,
null,
EpisodeSubtitle(em))).ToList());
}
private static async Task<List<LibraryBrowseItemResponseModel>> GetMusicVideos(
TvContext dbContext,
List<int> ids,
CancellationToken cancellationToken)
{
if (ids.Count == 0)
{
return [];
}
return await dbContext.MusicVideoMetadata
.AsNoTracking()
.Where(mvm => ids.Contains(mvm.MusicVideoId))
.Include(mvm => mvm.Artwork)
.Include(mvm => mvm.MusicVideo)
.ThenInclude(mv => mv.LibraryPath)
.ThenInclude(lp => lp.Library)
.Include(mvm => mvm.MusicVideo)
.ThenInclude(mv => mv.MediaVersions)
.Include(mvm => mvm.MusicVideo)
.ThenInclude(mv => mv.Artist)
.ThenInclude(a => a.ArtistMetadata)
.ToListAsync(cancellationToken)
.Map(list => list
.GroupBy(mvm => mvm.MusicVideoId)
.Select(g => g.OrderBy(mvm => mvm.Id).First())
.Map(mvm => new LibraryBrowseItemResponseModel(
mvm.MusicVideoId,
LibraryBrowseMediaType.MusicVideo,
mvm.Title ?? string.Empty,
mvm.MusicVideo.LibraryPath.LibraryId,
mvm.MusicVideo.LibraryPath.Library.Name,
ArtworkWithFallback(mvm, ArtworkKind.Thumbnail, ArtworkKind.Poster),
BestDuration(mvm.MusicVideo.MediaVersions),
1,
null,
CollectionType.MusicVideo,
null,
null,
null,
null,
mvm.MusicVideoId,
null,
MusicVideoSubtitle(mvm))).ToList());
}
private static async Task<List<LibraryBrowseItemResponseModel>> GetSongs(
TvContext dbContext,
List<int> ids,
CancellationToken cancellationToken)
{
if (ids.Count == 0)
{
return [];
}
return await dbContext.SongMetadata
.AsNoTracking()
.Where(sm => ids.Contains(sm.SongId))
.Include(sm => sm.Artwork)
.Include(sm => sm.Song)
.ThenInclude(s => s.LibraryPath)
.ThenInclude(lp => lp.Library)
.Include(sm => sm.Song)
.ThenInclude(s => s.MediaVersions)
.ToListAsync(cancellationToken)
.Map(list => list
.GroupBy(sm => sm.SongId)
.Select(g => g.OrderBy(sm => sm.Id).First())
.Map(sm => new LibraryBrowseItemResponseModel(
sm.SongId,
LibraryBrowseMediaType.Song,
sm.Title ?? string.Empty,
sm.Song.LibraryPath.LibraryId,
sm.Song.LibraryPath.Library.Name,
ArtworkWithFallback(sm, ArtworkKind.Thumbnail, ArtworkKind.Poster),
BestDuration(sm.Song.MediaVersions),
1,
null,
CollectionType.Song,
null,
null,
null,
null,
sm.SongId,
null,
SongSubtitle(sm))).ToList());
}
private static async Task<List<LibraryBrowseItemResponseModel>> GetOtherVideos(
TvContext dbContext,
List<int> ids,
CancellationToken cancellationToken)
{
if (ids.Count == 0)
{
return [];
}
return await dbContext.OtherVideoMetadata
.AsNoTracking()
.Where(ovm => ids.Contains(ovm.OtherVideoId))
.Include(ovm => ovm.Artwork)
.Include(ovm => ovm.OtherVideo)
.ThenInclude(ov => ov.LibraryPath)
.ThenInclude(lp => lp.Library)
.Include(ovm => ovm.OtherVideo)
.ThenInclude(ov => ov.MediaVersions)
.ToListAsync(cancellationToken)
.Map(list => list
.GroupBy(ovm => ovm.OtherVideoId)
.Select(g => g.OrderBy(ovm => ovm.Id).First())
.Map(ovm => new LibraryBrowseItemResponseModel(
ovm.OtherVideoId,
LibraryBrowseMediaType.OtherVideo,
ovm.Title ?? string.Empty,
ovm.OtherVideo.LibraryPath.LibraryId,
ovm.OtherVideo.LibraryPath.Library.Name,
ArtworkWithFallback(ovm, ArtworkKind.Thumbnail, ArtworkKind.Poster),
BestDuration(ovm.OtherVideo.MediaVersions),
1,
null,
CollectionType.OtherVideo,
null,
null,
null,
null,
ovm.OtherVideoId,
null,
string.IsNullOrWhiteSpace(ovm.OriginalTitle) ? null : ovm.OriginalTitle)).ToList());
}
private static async Task<List<LibraryBrowseItemResponseModel>> GetImages(
TvContext dbContext,
List<int> ids,
CancellationToken cancellationToken)
{
if (ids.Count == 0)
{
return [];
}
return await dbContext.ImageMetadata
.AsNoTracking()
.Where(im => ids.Contains(im.ImageId))
.Include(im => im.Artwork)
.Include(im => im.Image)
.ThenInclude(i => i.LibraryPath)
.ThenInclude(lp => lp.Library)
.Include(im => im.Image)
.ThenInclude(i => i.MediaVersions)
.ToListAsync(cancellationToken)
.Map(list => list
.GroupBy(im => im.ImageId)
.Select(g => g.OrderBy(im => im.Id).First())
.Map(im => new LibraryBrowseItemResponseModel(
im.ImageId,
LibraryBrowseMediaType.Image,
im.Title ?? string.Empty,
im.Image.LibraryPath.LibraryId,
im.Image.LibraryPath.Library.Name,
ArtworkWithFallback(im, ArtworkKind.Poster, ArtworkKind.Thumbnail),
BestDuration(im.Image.MediaVersions),
1,
null,
CollectionType.Image,
null,
null,
null,
null,
im.ImageId,
null,
string.IsNullOrWhiteSpace(im.OriginalTitle) ? null : im.OriginalTitle)).ToList());
}
private static async Task<List<LibraryBrowseItemResponseModel>> GetRemoteStreams(
TvContext dbContext,
List<int> ids,
CancellationToken cancellationToken)
{
if (ids.Count == 0)
{
return [];
}
return await dbContext.RemoteStreamMetadata
.AsNoTracking()
.Where(rsm => ids.Contains(rsm.RemoteStreamId))
.Include(rsm => rsm.Artwork)
.Include(rsm => rsm.RemoteStream)
.ThenInclude(rs => rs.LibraryPath)
.ThenInclude(lp => lp.Library)
.Include(rsm => rsm.RemoteStream)
.ThenInclude(rs => rs.MediaVersions)
.ToListAsync(cancellationToken)
.Map(list => list
.GroupBy(rsm => rsm.RemoteStreamId)
.Select(g => g.OrderBy(rsm => rsm.Id).First())
.Map(rsm => new LibraryBrowseItemResponseModel(
rsm.RemoteStreamId,
LibraryBrowseMediaType.RemoteStream,
rsm.Title ?? string.Empty,
rsm.RemoteStream.LibraryPath.LibraryId,
rsm.RemoteStream.LibraryPath.Library.Name,
ArtworkWithFallback(rsm, ArtworkKind.Thumbnail, ArtworkKind.Poster),
BestDuration(rsm.RemoteStream.MediaVersions),
1,
null,
CollectionType.RemoteStream,
null,
null,
null,
null,
rsm.RemoteStreamId,
null,
string.IsNullOrWhiteSpace(rsm.OriginalTitle) ? null : rsm.OriginalTitle)).ToList());
}
private static string EpisodeSubtitle(EpisodeMetadata metadata)
{
string showTitle = metadata.Episode.Season.Show.ShowMetadata.HeadOrNone()
.Map(sm => sm.Title ?? string.Empty)
.IfNone(string.Empty);
int seasonNumber = metadata.Episode.Season.SeasonNumber;
string suffix = $"S{seasonNumber}E{metadata.EpisodeNumber}";
return string.IsNullOrWhiteSpace(showTitle) ? suffix : $"{showTitle} - {suffix}";
}
private static string MusicVideoSubtitle(MusicVideoMetadata metadata)
{
string artist = metadata.MusicVideo.Artist.ArtistMetadata.HeadOrNone()
.Map(am => am.Title ?? string.Empty)
.IfNone(string.Empty);
string album = string.IsNullOrWhiteSpace(metadata.Album) ? string.Empty : metadata.Album;
if (!string.IsNullOrWhiteSpace(artist) && !string.IsNullOrWhiteSpace(album))
{
return $"{artist} - {album}";
}
return string.IsNullOrWhiteSpace(artist) ? album : artist;
}
private static string SongSubtitle(SongMetadata metadata)
{
string artists = string.Join(", ", metadata.Artists ?? []);
string album = string.IsNullOrWhiteSpace(metadata.Album) ? string.Empty : metadata.Album;
if (!string.IsNullOrWhiteSpace(artists) && !string.IsNullOrWhiteSpace(album))
{
return $"{artists} - {album}";
}
return string.IsNullOrWhiteSpace(artists) ? album : artists;
}
private static async Task<int> CountCollections( private static async Task<int> CountCollections(
TvContext dbContext, TvContext dbContext,
GetLibraryBrowseItems request, GetLibraryBrowseItems request,
@@ -913,6 +1266,12 @@ public class GetLibraryBrowseItemsHandler(
return string.IsNullOrWhiteSpace(showTitle) ? seasonTitle : $"{showTitle} - {seasonTitle}"; return string.IsNullOrWhiteSpace(showTitle) ? seasonTitle : $"{showTitle} - {seasonTitle}";
} }
private static string ArtworkWithFallback(Metadata metadata, ArtworkKind primary, ArtworkKind fallback)
{
string artwork = Artwork(metadata, primary);
return string.IsNullOrWhiteSpace(artwork) ? Artwork(metadata, fallback) : artwork;
}
private static string Artwork(Metadata metadata, ArtworkKind artworkKind) private static string Artwork(Metadata metadata, ArtworkKind artworkKind)
{ {
string artwork = Optional(metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == artworkKind)) string artwork = Optional(metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == artworkKind))
@@ -0,0 +1,5 @@
using ErsatzTV.Core.Api.Search;
namespace ErsatzTV.Application.Search;
public record GetSearchResults(string Query, int PageSize) : IRequest<SearchResultsResponseModel>;
@@ -0,0 +1,39 @@
using ErsatzTV.Application.LibraryBrowse;
using ErsatzTV.Core.Api.LibraryBrowse;
using ErsatzTV.Core.Api.Search;
using MediatR;
namespace ErsatzTV.Application.Search;
// Fans out to the shared library-browse query once per media kind (mirroring the legacy Search.razor page,
// which sends one `type:{kind} AND ({query})` query per kind). Reusing GetLibraryBrowseItems keeps hydration,
// artwork resolution and the response shape identical to /api/library/browse. Raw Lucene queries pass through
// unchanged, so state filters such as `state:FileNotFound` (the Trash screen) work here too.
public class GetSearchResultsHandler(IMediator mediator)
: IRequestHandler<GetSearchResults, SearchResultsResponseModel>
{
public async Task<SearchResultsResponseModel> Handle(
GetSearchResults request,
CancellationToken cancellationToken)
{
async Task<SearchResultGroupResponseModel> ForKind(LibraryBrowseMediaType kind)
{
PagedLibraryBrowseItemsResponseModel paged = await mediator.Send(
new GetLibraryBrowseItems(request.Query, null, kind, 0, request.PageSize),
cancellationToken);
return new SearchResultGroupResponseModel(paged.TotalCount, paged.Page);
}
return new SearchResultsResponseModel(
await ForKind(LibraryBrowseMediaType.Movie),
await ForKind(LibraryBrowseMediaType.TelevisionShow),
await ForKind(LibraryBrowseMediaType.TelevisionSeason),
await ForKind(LibraryBrowseMediaType.Artist),
await ForKind(LibraryBrowseMediaType.Episode),
await ForKind(LibraryBrowseMediaType.MusicVideo),
await ForKind(LibraryBrowseMediaType.Song),
await ForKind(LibraryBrowseMediaType.OtherVideo),
await ForKind(LibraryBrowseMediaType.Image),
await ForKind(LibraryBrowseMediaType.RemoteStream));
}
}
@@ -19,4 +19,5 @@ public record LibraryBrowseItemResponseModel(
int? SmartCollectionId, int? SmartCollectionId,
int? RerunCollectionId, int? RerunCollectionId,
int? MediaItemId, int? MediaItemId,
int? PlaylistId); int? PlaylistId,
string? Subtitle = null);
@@ -11,5 +11,11 @@ public enum LibraryBrowseMediaType
SmartCollection = 6, SmartCollection = 6,
MultiCollection = 7, MultiCollection = 7,
RerunCollection = 8, RerunCollection = 8,
Playlist = 9 Playlist = 9,
Episode = 10,
MusicVideo = 11,
Song = 12,
OtherVideo = 13,
Image = 14,
RemoteStream = 15
} }
@@ -0,0 +1,8 @@
#nullable enable
using ErsatzTV.Core.Api.LibraryBrowse;
namespace ErsatzTV.Core.Api.Search;
public record SearchResultGroupResponseModel(
int TotalCount,
List<LibraryBrowseItemResponseModel> Items);
@@ -0,0 +1,14 @@
#nullable enable
namespace ErsatzTV.Core.Api.Search;
public record SearchResultsResponseModel(
SearchResultGroupResponseModel Movies,
SearchResultGroupResponseModel Shows,
SearchResultGroupResponseModel Seasons,
SearchResultGroupResponseModel Artists,
SearchResultGroupResponseModel Episodes,
SearchResultGroupResponseModel MusicVideos,
SearchResultGroupResponseModel Songs,
SearchResultGroupResponseModel OtherVideos,
SearchResultGroupResponseModel Images,
SearchResultGroupResponseModel RemoteStreams);
@@ -32,6 +32,7 @@ public class ApiControllerSecurityTests
typeof(LibrariesController), typeof(LibrariesController),
typeof(LogsController), typeof(LogsController),
typeof(MaintenanceController), typeof(MaintenanceController),
typeof(MediaItemsController),
typeof(PlayoutController), typeof(PlayoutController),
typeof(ResolutionController), typeof(ResolutionController),
typeof(ScannerController), typeof(ScannerController),
@@ -0,0 +1,66 @@
using System.Reflection;
using ErsatzTV.Controllers.Api;
using ErsatzTV.Controllers.Api.Requests;
using ErsatzTV.Core;
using ErsatzTV.Application.Maintenance;
using LanguageExt;
using MediatR;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Routing;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
using static LanguageExt.Prelude;
namespace ErsatzTV.Tests.Controllers;
[TestFixture]
public class MediaItemsControllerTests
{
private MediaItemsController _controller = null!;
private IMediator _mediator = null!;
[SetUp]
public void SetUp()
{
_mediator = Substitute.For<IMediator>();
_controller = new MediaItemsController(_mediator);
}
[Test]
public void Controller_Should_Expose_Delete_Route_With_Stable_Operation_Name()
{
MethodInfo action = typeof(MediaItemsController).GetMethod(nameof(MediaItemsController.Delete))
?? throw new AssertionException($"Missing action {nameof(MediaItemsController.Delete)}");
var attribute = action.GetCustomAttributes<HttpDeleteAttribute>().Single();
attribute.Template.ShouldBe("/api/media-items");
attribute.Name.ShouldBe("DeleteMediaItems");
}
[Test]
public async Task Delete_Should_Return_422_For_Empty_Ids()
{
IActionResult result = await _controller.Delete(new DeleteMediaItemsRequest([]), CancellationToken.None);
var unprocessable = result.ShouldBeOfType<UnprocessableEntityObjectResult>();
unprocessable.StatusCode.ShouldBe(422);
await _mediator.DidNotReceive().Send(Arg.Any<DeleteItemsFromDatabase>(), Arg.Any<CancellationToken>());
}
[Test]
public async Task Delete_Should_Return_204_On_Success()
{
_mediator.Send(Arg.Any<DeleteItemsFromDatabase>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, LanguageExt.Unit>(LanguageExt.Unit.Default));
IActionResult result = await _controller.Delete(
new DeleteMediaItemsRequest([1, 2, 3]),
CancellationToken.None);
result.ShouldBeOfType<NoContentResult>();
await _mediator.Received(1).Send(
Arg.Is<DeleteItemsFromDatabase>(c => c.MediaItemIds.SequenceEqual(new[] { 1, 2, 3 })),
Arg.Any<CancellationToken>());
}
}
@@ -213,6 +213,8 @@ public class OpenApiErrorResponseContractTests
[TestCase("/api/settings/resolutions/{id}", "delete", "401")] [TestCase("/api/settings/resolutions/{id}", "delete", "401")]
[TestCase("/api/settings/resolutions/{id}", "delete", "404")] [TestCase("/api/settings/resolutions/{id}", "delete", "404")]
[TestCase("/api/settings/resolutions/{id}", "delete", "422")] [TestCase("/api/settings/resolutions/{id}", "delete", "422")]
[TestCase("/api/search", "get", "422")]
[TestCase("/api/media-items", "delete", "422")]
public void Static_OpenApi_Should_Document_ProblemDetails_For_Api_Error_Responses( public void Static_OpenApi_Should_Document_ProblemDetails_For_Api_Error_Responses(
string path, string path,
string method, string method,
@@ -0,0 +1,79 @@
using System.Reflection;
using ErsatzTV.Application.Search;
using ErsatzTV.Controllers.Api;
using ErsatzTV.Core.Api.Search;
using MediatR;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Routing;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Controllers;
[TestFixture]
public class SearchControllerTests
{
private SearchController _controller = null!;
private IMediator _mediator = null!;
[SetUp]
public void SetUp()
{
_mediator = Substitute.For<IMediator>();
_controller = new SearchController(_mediator);
}
[Test]
public void Controller_Should_Expose_Search_Route_With_Stable_Operation_Name()
{
MethodInfo action = typeof(SearchController).GetMethod(nameof(SearchController.Search))
?? throw new AssertionException($"Missing action {nameof(SearchController.Search)}");
var attribute = action.GetCustomAttributes<HttpGetAttribute>().Single();
attribute.Template.ShouldBe("/api/search");
attribute.Name.ShouldBe("Search");
}
[Test]
public async Task Search_Should_Return_422_For_Empty_Query()
{
IActionResult result = await _controller.Search(" ", 50, CancellationToken.None);
var unprocessable = result.ShouldBeOfType<UnprocessableEntityObjectResult>();
unprocessable.StatusCode.ShouldBe(422);
await _mediator.DidNotReceive().Send(Arg.Any<GetSearchResults>(), Arg.Any<CancellationToken>());
}
[Test]
public async Task Search_Should_Clamp_PageSize_And_Send_Query()
{
_mediator.Send(Arg.Any<GetSearchResults>(), Arg.Any<CancellationToken>())
.Returns(EmptyResults());
IActionResult result = await _controller.Search("star", 500, CancellationToken.None);
result.ShouldBeOfType<OkObjectResult>();
await _mediator.Received(1).Send(
Arg.Is<GetSearchResults>(q => q.Query == "star" && q.PageSize == 100),
Arg.Any<CancellationToken>());
}
[Test]
public async Task Search_Should_Return_Grouped_Results()
{
SearchResultsResponseModel results = EmptyResults();
_mediator.Send(Arg.Any<GetSearchResults>(), Arg.Any<CancellationToken>()).Returns(results);
IActionResult result = await _controller.Search("star", 50, CancellationToken.None);
var ok = result.ShouldBeOfType<OkObjectResult>();
ok.Value.ShouldBe(results);
}
private static SearchResultsResponseModel EmptyResults()
{
var empty = new SearchResultGroupResponseModel(0, []);
return new SearchResultsResponseModel(empty, empty, empty, empty, empty, empty, empty, empty, empty, empty);
}
}
@@ -0,0 +1,32 @@
using System.ComponentModel.DataAnnotations;
using ErsatzTV.Controllers.Api.Requests;
using ErsatzTV.Core;
using ErsatzTV.Extensions;
using MediatR;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
namespace ErsatzTV.Controllers.Api;
[ApiController]
public class MediaItemsController(IMediator mediator) : ControllerBase
{
[HttpDelete("/api/media-items", Name = "DeleteMediaItems")]
[Tags("Media Items")]
[EndpointSummary("Delete media items from the database")]
[EndpointGroupName("general")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> Delete(
[Required] [FromBody] DeleteMediaItemsRequest request,
CancellationToken cancellationToken)
{
if (request.Ids is null || request.Ids.Count == 0)
{
return BaseError.New("At least one media item id is required").ToErrorResult();
}
Either<BaseError, Unit> result = await mediator.Send(request.ToCommand(), cancellationToken);
return result.ToDeletedResult();
}
}
@@ -0,0 +1,8 @@
using ErsatzTV.Application.Maintenance;
namespace ErsatzTV.Controllers.Api.Requests;
public record DeleteMediaItemsRequest(List<int> Ids)
{
public DeleteItemsFromDatabase ToCommand() => new(Ids);
}
@@ -0,0 +1,38 @@
using ErsatzTV.Application.Search;
using ErsatzTV.Core;
using ErsatzTV.Core.Api.Search;
using ErsatzTV.Extensions;
using MediatR;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
namespace ErsatzTV.Controllers.Api;
[ApiController]
public class SearchController(IMediator mediator) : ControllerBase
{
private const int MaxPageSize = 100;
[HttpGet("/api/search", Name = "Search")]
[Tags("Search")]
[EndpointSummary("Search library items across all media kinds")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(SearchResultsResponseModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> Search(
[FromQuery] string query = "",
[FromQuery] int pageSize = 50,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(query))
{
return BaseError.New("A non-empty query is required").ToErrorResult();
}
int clampedPageSize = Math.Clamp(pageSize, 1, MaxPageSize);
SearchResultsResponseModel result = await mediator.Send(
new GetSearchResults(query, clampedPageSize),
cancellationToken);
return new OkObjectResult(result);
}
}
+232 -1
View File
@@ -3196,6 +3196,65 @@
} }
} }
}, },
"/api/media-items": {
"delete": {
"tags": [
"Media Items"
],
"summary": "Delete media items from the database",
"operationId": "DeleteMediaItems",
"requestBody": {
"content": {
"application/json-patch+json": {
"schema": {
"$ref": "#/components/schemas/DeleteMediaItemsRequest"
}
},
"application/json": {
"schema": {
"$ref": "#/components/schemas/DeleteMediaItemsRequest"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/DeleteMediaItemsRequest"
}
},
"application/*+json": {
"schema": {
"$ref": "#/components/schemas/DeleteMediaItemsRequest"
}
}
},
"required": true
},
"responses": {
"204": {
"description": "No Content"
},
"422": {
"description": "Unprocessable Entity",
"content": {
"text/plain": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
},
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
}
}
}
},
"/api/media-sources": { "/api/media-sources": {
"get": { "get": {
"tags": [ "tags": [
@@ -4619,6 +4678,76 @@
} }
} }
}, },
"/api/search": {
"get": {
"tags": [
"Search"
],
"summary": "Search library items across all media kinds",
"operationId": "Search",
"parameters": [
{
"name": "query",
"in": "query",
"schema": {
"type": "string",
"default": ""
}
},
{
"name": "pageSize",
"in": "query",
"schema": {
"type": "integer",
"format": "int32",
"default": 50
}
}
],
"responses": {
"200": {
"description": "OK",
"content": {
"text/plain": {
"schema": {
"$ref": "#/components/schemas/SearchResultsResponseModel"
}
},
"application/json": {
"schema": {
"$ref": "#/components/schemas/SearchResultsResponseModel"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/SearchResultsResponseModel"
}
}
}
},
"422": {
"description": "Unprocessable Entity",
"content": {
"text/plain": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
},
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
}
}
}
},
"/api/sessions": { "/api/sessions": {
"get": { "get": {
"tags": [ "tags": [
@@ -8649,6 +8778,24 @@
} }
} }
}, },
"DeleteMediaItemsRequest": {
"required": [
"ids"
],
"type": "object",
"properties": {
"ids": {
"type": [
"null",
"array"
],
"items": {
"type": "integer",
"format": "int32"
}
}
}
},
"FFmpegFullProfileResponseModel": { "FFmpegFullProfileResponseModel": {
"required": [ "required": [
"id", "id",
@@ -9450,6 +9597,12 @@
"integer" "integer"
], ],
"format": "int32" "format": "int32"
},
"subtitle": {
"type": [
"null",
"string"
]
} }
} }
}, },
@@ -9463,7 +9616,13 @@
"SmartCollection", "SmartCollection",
"MultiCollection", "MultiCollection",
"RerunCollection", "RerunCollection",
"Playlist" "Playlist",
"Episode",
"MusicVideo",
"Song",
"OtherVideo",
"Image",
"RemoteStream"
], ],
"type": "string" "type": "string"
}, },
@@ -10887,6 +11046,72 @@
} }
} }
}, },
"SearchResultGroupResponseModel": {
"required": [
"totalCount",
"items"
],
"type": "object",
"properties": {
"totalCount": {
"type": "integer",
"format": "int32"
},
"items": {
"type": "array",
"items": {
"$ref": "#/components/schemas/LibraryBrowseItemResponseModel"
}
}
}
},
"SearchResultsResponseModel": {
"required": [
"movies",
"shows",
"seasons",
"artists",
"episodes",
"musicVideos",
"songs",
"otherVideos",
"images",
"remoteStreams"
],
"type": "object",
"properties": {
"movies": {
"$ref": "#/components/schemas/SearchResultGroupResponseModel"
},
"shows": {
"$ref": "#/components/schemas/SearchResultGroupResponseModel"
},
"seasons": {
"$ref": "#/components/schemas/SearchResultGroupResponseModel"
},
"artists": {
"$ref": "#/components/schemas/SearchResultGroupResponseModel"
},
"episodes": {
"$ref": "#/components/schemas/SearchResultGroupResponseModel"
},
"musicVideos": {
"$ref": "#/components/schemas/SearchResultGroupResponseModel"
},
"songs": {
"$ref": "#/components/schemas/SearchResultGroupResponseModel"
},
"otherVideos": {
"$ref": "#/components/schemas/SearchResultGroupResponseModel"
},
"images": {
"$ref": "#/components/schemas/SearchResultGroupResponseModel"
},
"remoteStreams": {
"$ref": "#/components/schemas/SearchResultGroupResponseModel"
}
}
},
"SmartCollectionResponseModel": { "SmartCollectionResponseModel": {
"required": [ "required": [
"id", "id",
@@ -12252,6 +12477,9 @@
{ {
"name": "Maintenance" "name": "Maintenance"
}, },
{
"name": "Media Items"
},
{ {
"name": "Media Sources" "name": "Media Sources"
}, },
@@ -12267,6 +12495,9 @@
{ {
"name": "Schedules" "name": "Schedules"
}, },
{
"name": "Search"
},
{ {
"name": "Sessions" "name": "Sessions"
}, },
+21 -1
View File
@@ -358,6 +358,9 @@ export interface components {
"opacityExpression": null | string; "opacityExpression": null | string;
"zIndex": number; "zIndex": number;
"placeWithinSourceContent": boolean; "placeWithinSourceContent": boolean;
};
"DeleteMediaItemsRequest": {
"ids": null | Array<number>;
}; };
"FFmpegFullProfileResponseModel": { "FFmpegFullProfileResponseModel": {
"id": number; "id": number;
@@ -504,8 +507,9 @@ export interface components {
"rerunCollectionId": null | number; "rerunCollectionId": null | number;
"mediaItemId": null | number; "mediaItemId": null | number;
"playlistId": null | number; "playlistId": null | number;
"subtitle"?: null | string;
}; };
"LibraryBrowseMediaType": "Movie" | "TelevisionShow" | "TelevisionSeason" | "Artist" | "Collection" | "SmartCollection" | "MultiCollection" | "RerunCollection" | "Playlist"; "LibraryBrowseMediaType": "Movie" | "TelevisionShow" | "TelevisionSeason" | "Artist" | "Collection" | "SmartCollection" | "MultiCollection" | "RerunCollection" | "Playlist" | "Episode" | "MusicVideo" | "Song" | "OtherVideo" | "Image" | "RemoteStream";
"LibraryMediaKind": "Movies" | "Shows" | "MusicVideos" | "OtherVideos" | "Songs" | "Images" | "RemoteStreams"; "LibraryMediaKind": "Movies" | "Shows" | "MusicVideos" | "OtherVideos" | "Songs" | "Images" | "RemoteStreams";
"LibraryScanStatusResponseModel": { "LibraryScanStatusResponseModel": {
"libraryId": number; "libraryId": number;
@@ -774,6 +778,22 @@ export interface components {
"preferredAudioTitle": null | string; "preferredAudioTitle": null | string;
"preferredSubtitleLanguageCode": null | string; "preferredSubtitleLanguageCode": null | string;
"subtitleMode": null | components["schemas"]["ChannelSubtitleMode"]; "subtitleMode": null | components["schemas"]["ChannelSubtitleMode"];
};
"SearchResultGroupResponseModel": {
"totalCount": number;
"items": Array<components["schemas"]["LibraryBrowseItemResponseModel"]>;
};
"SearchResultsResponseModel": {
"movies": components["schemas"]["SearchResultGroupResponseModel"];
"shows": components["schemas"]["SearchResultGroupResponseModel"];
"seasons": components["schemas"]["SearchResultGroupResponseModel"];
"artists": components["schemas"]["SearchResultGroupResponseModel"];
"episodes": components["schemas"]["SearchResultGroupResponseModel"];
"musicVideos": components["schemas"]["SearchResultGroupResponseModel"];
"songs": components["schemas"]["SearchResultGroupResponseModel"];
"otherVideos": components["schemas"]["SearchResultGroupResponseModel"];
"images": components["schemas"]["SearchResultGroupResponseModel"];
"remoteStreams": components["schemas"]["SearchResultGroupResponseModel"];
}; };
"SmartCollectionResponseModel": { "SmartCollectionResponseModel": {
"id": number; "id": number;