From 6c68c291a0e64034c8e94d0fc041b5933c11a5a8 Mon Sep 17 00:00:00 2001 From: Timothy Date: Tue, 7 Jul 2026 15:21:37 +0200 Subject: [PATCH] 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 --- .../Queries/GetLibraryBrowseItemsHandler.cs | 361 +++++++++++++++++- .../Search/Queries/GetSearchResults.cs | 5 + .../Search/Queries/GetSearchResultsHandler.cs | 39 ++ .../LibraryBrowseItemResponseModel.cs | 3 +- .../LibraryBrowse/LibraryBrowseMediaType.cs | 8 +- .../Search/SearchResultGroupResponseModel.cs | 8 + .../Api/Search/SearchResultsResponseModel.cs | 14 + .../Controllers/ApiControllerSecurityTests.cs | 1 + .../Controllers/MediaItemsControllerTests.cs | 66 ++++ .../OpenApiErrorResponseContractTests.cs | 2 + .../Controllers/SearchControllerTests.cs | 79 ++++ .../Controllers/Api/MediaItemsController.cs | 32 ++ .../Api/Requests/DeleteMediaItemsRequest.cs | 8 + ErsatzTV/Controllers/Api/SearchController.cs | 38 ++ ErsatzTV/wwwroot/openapi/v1.json | 233 ++++++++++- web/src/api/generated/v1.d.ts | 22 +- 16 files changed, 914 insertions(+), 5 deletions(-) create mode 100644 ErsatzTV.Application/Search/Queries/GetSearchResults.cs create mode 100644 ErsatzTV.Application/Search/Queries/GetSearchResultsHandler.cs create mode 100644 ErsatzTV.Core/Api/Search/SearchResultGroupResponseModel.cs create mode 100644 ErsatzTV.Core/Api/Search/SearchResultsResponseModel.cs create mode 100644 ErsatzTV.Tests/Controllers/MediaItemsControllerTests.cs create mode 100644 ErsatzTV.Tests/Controllers/SearchControllerTests.cs create mode 100644 ErsatzTV/Controllers/Api/MediaItemsController.cs create mode 100644 ErsatzTV/Controllers/Api/Requests/DeleteMediaItemsRequest.cs create mode 100644 ErsatzTV/Controllers/Api/SearchController.cs diff --git a/ErsatzTV.Application/LibraryBrowse/Queries/GetLibraryBrowseItemsHandler.cs b/ErsatzTV.Application/LibraryBrowse/Queries/GetLibraryBrowseItemsHandler.cs index 96854f113..defa3a442 100644 --- a/ErsatzTV.Application/LibraryBrowse/Queries/GetLibraryBrowseItemsHandler.cs +++ b/ErsatzTV.Application/LibraryBrowse/Queries/GetLibraryBrowseItemsHandler.cs @@ -91,12 +91,24 @@ public class GetLibraryBrowseItemsHandler( LibraryBrowseMediaType.TelevisionShow => [LuceneSearchIndex.ShowType], LibraryBrowseMediaType.TelevisionSeason => [LuceneSearchIndex.SeasonType], 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 => [ LuceneSearchIndex.MovieType, LuceneSearchIndex.ShowType, 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 showIds = searchItems.Where(i => i.Type == LuceneSearchIndex.ShowType).Select(i => i.Id).ToList(); List seasonIds = searchItems.Where(i => i.Type == LuceneSearchIndex.SeasonType).Select(i => i.Id).ToList(); List artistIds = searchItems.Where(i => i.Type == LuceneSearchIndex.ArtistType).Select(i => i.Id).ToList(); + List episodeIds = searchItems.Where(i => i.Type == LuceneSearchIndex.EpisodeType).Select(i => i.Id).ToList(); + List musicVideoIds = + searchItems.Where(i => i.Type == LuceneSearchIndex.MusicVideoType).Select(i => i.Id).ToList(); + List songIds = searchItems.Where(i => i.Type == LuceneSearchIndex.SongType).Select(i => i.Id).ToList(); + List otherVideoIds = + searchItems.Where(i => i.Type == LuceneSearchIndex.OtherVideoType).Select(i => i.Id).ToList(); + List imageIds = searchItems.Where(i => i.Type == LuceneSearchIndex.ImageType).Select(i => i.Id).ToList(); + List remoteStreamIds = + searchItems.Where(i => i.Type == LuceneSearchIndex.RemoteStreamType).Select(i => i.Id).ToList(); Dictionary<(string Type, int Id), LibraryBrowseItemResponseModel> hydrated = []; @@ -138,6 +159,37 @@ public class GetLibraryBrowseItemsHandler( 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 .Where(i => hydrated.ContainsKey((i.Type, i.Id))) .Select(i => hydrated[(i.Type, i.Id)]) @@ -330,6 +382,307 @@ public class GetLibraryBrowseItemsHandler( null)).ToList()); } + private static async Task> GetEpisodes( + TvContext dbContext, + List 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> GetMusicVideos( + TvContext dbContext, + List 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> GetSongs( + TvContext dbContext, + List 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> GetOtherVideos( + TvContext dbContext, + List 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> GetImages( + TvContext dbContext, + List 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> GetRemoteStreams( + TvContext dbContext, + List 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 CountCollections( TvContext dbContext, GetLibraryBrowseItems request, @@ -913,6 +1266,12 @@ public class GetLibraryBrowseItemsHandler( 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) { string artwork = Optional(metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == artworkKind)) diff --git a/ErsatzTV.Application/Search/Queries/GetSearchResults.cs b/ErsatzTV.Application/Search/Queries/GetSearchResults.cs new file mode 100644 index 000000000..73af3f668 --- /dev/null +++ b/ErsatzTV.Application/Search/Queries/GetSearchResults.cs @@ -0,0 +1,5 @@ +using ErsatzTV.Core.Api.Search; + +namespace ErsatzTV.Application.Search; + +public record GetSearchResults(string Query, int PageSize) : IRequest; diff --git a/ErsatzTV.Application/Search/Queries/GetSearchResultsHandler.cs b/ErsatzTV.Application/Search/Queries/GetSearchResultsHandler.cs new file mode 100644 index 000000000..bad6ebc99 --- /dev/null +++ b/ErsatzTV.Application/Search/Queries/GetSearchResultsHandler.cs @@ -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 +{ + public async Task Handle( + GetSearchResults request, + CancellationToken cancellationToken) + { + async Task 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)); + } +} diff --git a/ErsatzTV.Core/Api/LibraryBrowse/LibraryBrowseItemResponseModel.cs b/ErsatzTV.Core/Api/LibraryBrowse/LibraryBrowseItemResponseModel.cs index 36ccfce41..67f6cffd2 100644 --- a/ErsatzTV.Core/Api/LibraryBrowse/LibraryBrowseItemResponseModel.cs +++ b/ErsatzTV.Core/Api/LibraryBrowse/LibraryBrowseItemResponseModel.cs @@ -19,4 +19,5 @@ public record LibraryBrowseItemResponseModel( int? SmartCollectionId, int? RerunCollectionId, int? MediaItemId, - int? PlaylistId); + int? PlaylistId, + string? Subtitle = null); diff --git a/ErsatzTV.Core/Api/LibraryBrowse/LibraryBrowseMediaType.cs b/ErsatzTV.Core/Api/LibraryBrowse/LibraryBrowseMediaType.cs index 1a12225a0..1440dbccc 100644 --- a/ErsatzTV.Core/Api/LibraryBrowse/LibraryBrowseMediaType.cs +++ b/ErsatzTV.Core/Api/LibraryBrowse/LibraryBrowseMediaType.cs @@ -11,5 +11,11 @@ public enum LibraryBrowseMediaType SmartCollection = 6, MultiCollection = 7, RerunCollection = 8, - Playlist = 9 + Playlist = 9, + Episode = 10, + MusicVideo = 11, + Song = 12, + OtherVideo = 13, + Image = 14, + RemoteStream = 15 } diff --git a/ErsatzTV.Core/Api/Search/SearchResultGroupResponseModel.cs b/ErsatzTV.Core/Api/Search/SearchResultGroupResponseModel.cs new file mode 100644 index 000000000..1930dc6e3 --- /dev/null +++ b/ErsatzTV.Core/Api/Search/SearchResultGroupResponseModel.cs @@ -0,0 +1,8 @@ +#nullable enable +using ErsatzTV.Core.Api.LibraryBrowse; + +namespace ErsatzTV.Core.Api.Search; + +public record SearchResultGroupResponseModel( + int TotalCount, + List Items); diff --git a/ErsatzTV.Core/Api/Search/SearchResultsResponseModel.cs b/ErsatzTV.Core/Api/Search/SearchResultsResponseModel.cs new file mode 100644 index 000000000..e0b78cdf3 --- /dev/null +++ b/ErsatzTV.Core/Api/Search/SearchResultsResponseModel.cs @@ -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); diff --git a/ErsatzTV.Tests/Controllers/ApiControllerSecurityTests.cs b/ErsatzTV.Tests/Controllers/ApiControllerSecurityTests.cs index 5c428dac3..5f93cc9c4 100644 --- a/ErsatzTV.Tests/Controllers/ApiControllerSecurityTests.cs +++ b/ErsatzTV.Tests/Controllers/ApiControllerSecurityTests.cs @@ -32,6 +32,7 @@ public class ApiControllerSecurityTests typeof(LibrariesController), typeof(LogsController), typeof(MaintenanceController), + typeof(MediaItemsController), typeof(PlayoutController), typeof(ResolutionController), typeof(ScannerController), diff --git a/ErsatzTV.Tests/Controllers/MediaItemsControllerTests.cs b/ErsatzTV.Tests/Controllers/MediaItemsControllerTests.cs new file mode 100644 index 000000000..89d43e0ba --- /dev/null +++ b/ErsatzTV.Tests/Controllers/MediaItemsControllerTests.cs @@ -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(); + _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().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(); + unprocessable.StatusCode.ShouldBe(422); + await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); + } + + [Test] + public async Task Delete_Should_Return_204_On_Success() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Right(LanguageExt.Unit.Default)); + + IActionResult result = await _controller.Delete( + new DeleteMediaItemsRequest([1, 2, 3]), + CancellationToken.None); + + result.ShouldBeOfType(); + await _mediator.Received(1).Send( + Arg.Is(c => c.MediaItemIds.SequenceEqual(new[] { 1, 2, 3 })), + Arg.Any()); + } +} diff --git a/ErsatzTV.Tests/Controllers/OpenApiErrorResponseContractTests.cs b/ErsatzTV.Tests/Controllers/OpenApiErrorResponseContractTests.cs index c24174c2d..438cd39a4 100644 --- a/ErsatzTV.Tests/Controllers/OpenApiErrorResponseContractTests.cs +++ b/ErsatzTV.Tests/Controllers/OpenApiErrorResponseContractTests.cs @@ -213,6 +213,8 @@ public class OpenApiErrorResponseContractTests [TestCase("/api/settings/resolutions/{id}", "delete", "401")] [TestCase("/api/settings/resolutions/{id}", "delete", "404")] [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( string path, string method, diff --git a/ErsatzTV.Tests/Controllers/SearchControllerTests.cs b/ErsatzTV.Tests/Controllers/SearchControllerTests.cs new file mode 100644 index 000000000..f48878486 --- /dev/null +++ b/ErsatzTV.Tests/Controllers/SearchControllerTests.cs @@ -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(); + _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().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(); + unprocessable.StatusCode.ShouldBe(422); + await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); + } + + [Test] + public async Task Search_Should_Clamp_PageSize_And_Send_Query() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(EmptyResults()); + + IActionResult result = await _controller.Search("star", 500, CancellationToken.None); + + result.ShouldBeOfType(); + await _mediator.Received(1).Send( + Arg.Is(q => q.Query == "star" && q.PageSize == 100), + Arg.Any()); + } + + [Test] + public async Task Search_Should_Return_Grouped_Results() + { + SearchResultsResponseModel results = EmptyResults(); + _mediator.Send(Arg.Any(), Arg.Any()).Returns(results); + + IActionResult result = await _controller.Search("star", 50, CancellationToken.None); + + var ok = result.ShouldBeOfType(); + 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); + } +} diff --git a/ErsatzTV/Controllers/Api/MediaItemsController.cs b/ErsatzTV/Controllers/Api/MediaItemsController.cs new file mode 100644 index 000000000..8b08f9b47 --- /dev/null +++ b/ErsatzTV/Controllers/Api/MediaItemsController.cs @@ -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 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 result = await mediator.Send(request.ToCommand(), cancellationToken); + return result.ToDeletedResult(); + } +} diff --git a/ErsatzTV/Controllers/Api/Requests/DeleteMediaItemsRequest.cs b/ErsatzTV/Controllers/Api/Requests/DeleteMediaItemsRequest.cs new file mode 100644 index 000000000..f0cfb0b03 --- /dev/null +++ b/ErsatzTV/Controllers/Api/Requests/DeleteMediaItemsRequest.cs @@ -0,0 +1,8 @@ +using ErsatzTV.Application.Maintenance; + +namespace ErsatzTV.Controllers.Api.Requests; + +public record DeleteMediaItemsRequest(List Ids) +{ + public DeleteItemsFromDatabase ToCommand() => new(Ids); +} diff --git a/ErsatzTV/Controllers/Api/SearchController.cs b/ErsatzTV/Controllers/Api/SearchController.cs new file mode 100644 index 000000000..457a814e0 --- /dev/null +++ b/ErsatzTV/Controllers/Api/SearchController.cs @@ -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 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); + } +} diff --git a/ErsatzTV/wwwroot/openapi/v1.json b/ErsatzTV/wwwroot/openapi/v1.json index d637af549..98240251b 100644 --- a/ErsatzTV/wwwroot/openapi/v1.json +++ b/ErsatzTV/wwwroot/openapi/v1.json @@ -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": { "get": { "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": { "get": { "tags": [ @@ -8649,6 +8778,24 @@ } } }, + "DeleteMediaItemsRequest": { + "required": [ + "ids" + ], + "type": "object", + "properties": { + "ids": { + "type": [ + "null", + "array" + ], + "items": { + "type": "integer", + "format": "int32" + } + } + } + }, "FFmpegFullProfileResponseModel": { "required": [ "id", @@ -9450,6 +9597,12 @@ "integer" ], "format": "int32" + }, + "subtitle": { + "type": [ + "null", + "string" + ] } } }, @@ -9463,7 +9616,13 @@ "SmartCollection", "MultiCollection", "RerunCollection", - "Playlist" + "Playlist", + "Episode", + "MusicVideo", + "Song", + "OtherVideo", + "Image", + "RemoteStream" ], "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": { "required": [ "id", @@ -12252,6 +12477,9 @@ { "name": "Maintenance" }, + { + "name": "Media Items" + }, { "name": "Media Sources" }, @@ -12267,6 +12495,9 @@ { "name": "Schedules" }, + { + "name": "Search" + }, { "name": "Sessions" }, diff --git a/web/src/api/generated/v1.d.ts b/web/src/api/generated/v1.d.ts index d9ff74f04..787880b2c 100644 --- a/web/src/api/generated/v1.d.ts +++ b/web/src/api/generated/v1.d.ts @@ -358,6 +358,9 @@ export interface components { "opacityExpression": null | string; "zIndex": number; "placeWithinSourceContent": boolean; + }; + "DeleteMediaItemsRequest": { + "ids": null | Array; }; "FFmpegFullProfileResponseModel": { "id": number; @@ -504,8 +507,9 @@ export interface components { "rerunCollectionId": null | number; "mediaItemId": 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"; "LibraryScanStatusResponseModel": { "libraryId": number; @@ -774,6 +778,22 @@ export interface components { "preferredAudioTitle": null | string; "preferredSubtitleLanguageCode": null | string; "subtitleMode": null | components["schemas"]["ChannelSubtitleMode"]; + }; + "SearchResultGroupResponseModel": { + "totalCount": number; + "items": Array; + }; + "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": { "id": number;