Merge pull request 'feat(web+api): media browse, search & trash for SPA parity (#141)' (#168) from feat/141-media-browse into main
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
Build ErsatzTV Image / Build & test (.NET) (push) Has been cancelled
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Has been cancelled

This commit was merged in pull request #168.
This commit is contained in:
2026-07-07 14:20:23 +00:00
35 changed files with 2243 additions and 81 deletions
@@ -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<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> 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 = [];
@@ -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<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(
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))
@@ -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? RerunCollectionId,
int? MediaItemId,
int? PlaylistId);
int? PlaylistId,
string? Subtitle = null);
@@ -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
}
@@ -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(LogsController),
typeof(MaintenanceController),
typeof(MediaItemsController),
typeof(PlayoutController),
typeof(ResolutionController),
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", "404")]
[TestCase("/api/settings/resolutions/{id}", "delete", "422")]
[TestCase("/api/search", "get", "422")]
[TestCase("/api/media-items", "delete", "422")]
[TestCase("/api/trakt/lists/{id}", "get", "404")]
[TestCase("/api/trakt/lists", "post", "422")]
[TestCase("/api/trakt/lists", "post", "409")]
@@ -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": {
"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": [
@@ -9115,6 +9244,24 @@
}
}
},
"DeleteMediaItemsRequest": {
"required": [
"ids"
],
"type": "object",
"properties": {
"ids": {
"type": [
"null",
"array"
],
"items": {
"type": "integer",
"format": "int32"
}
}
}
},
"FFmpegFullProfileResponseModel": {
"required": [
"id",
@@ -9916,6 +10063,12 @@
"integer"
],
"format": "int32"
},
"subtitle": {
"type": [
"null",
"string"
]
}
}
},
@@ -9929,7 +10082,13 @@
"SmartCollection",
"MultiCollection",
"RerunCollection",
"Playlist"
"Playlist",
"Episode",
"MusicVideo",
"Song",
"OtherVideo",
"Image",
"RemoteStream"
],
"type": "string"
},
@@ -11372,6 +11531,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",
@@ -12806,6 +13031,9 @@
{
"name": "Maintenance"
},
{
"name": "Media Items"
},
{
"name": "Media Sources"
},
@@ -12821,6 +13049,9 @@
{
"name": "Schedules"
},
{
"name": "Search"
},
{
"name": "Sessions"
},
+34 -6
View File
@@ -2074,11 +2074,14 @@ describe('ChicoryTV SPA scaffold', () => {
...builderDefaults(),
browseHandler: (search) => {
const query = (search.get('query') ?? '').toLowerCase();
const mediaType = search.get('mediaType');
const all = [
browseItem(),
browseItem({ id: 2, mediaItemId: 2, mediaType: 'Movie', collectionType: 'Movie', title: 'Blade Runner', libraryName: 'Movies' })
];
const page = query ? all.filter((item) => String(item.title).toLowerCase().includes(query)) : all;
const page = all
.filter((item) => !mediaType || item.mediaType === mediaType)
.filter((item) => !query || String(item.title).toLowerCase().includes(query));
return { page, totalCount: page.length };
}
});
@@ -2196,7 +2199,11 @@ describe('ChicoryTV SPA scaffold', () => {
await renderBuilder();
fireEvent.doubleClick(await screen.findByText('Looney Tunes'));
fireEvent.doubleClick(screen.getByText('Saturday Block'));
// RerunCollection is a collections kind, not one of the 4 library kinds —
// find it under the Collections tab.
fireEvent.click(screen.getByRole('button', { name: 'Collections' }));
fireEvent.doubleClick(await screen.findByText('Saturday Block'));
fireEvent.change(screen.getByLabelText('Channel name'), { target: { value: 'Mixed' } });
expect(await screen.findByText('Only valid alone')).toBeInTheDocument();
@@ -2213,6 +2220,9 @@ describe('ChicoryTV SPA scaffold', () => {
const { container } = await renderBuilder();
// MultiCollection is a collections kind, not one of the 4 library kinds —
// find it under the Collections tab.
fireEvent.click(screen.getByRole('button', { name: 'Collections' }));
fireEvent.doubleClick(await screen.findByText('Prime Time'));
fireEvent.change(screen.getByLabelText('Channel name'), { target: { value: 'Multi' } });
@@ -2372,6 +2382,10 @@ describe('ChicoryTV SPA scaffold', () => {
const { container } = await renderBuilder();
// The initial render already fanned out over the 4 library kinds; only
// count requests made after switching to the Collections tab.
vi.mocked(window.fetch).mockClear();
fireEvent.click(screen.getByRole('button', { name: 'Collections' }));
expect(await screen.findByText('Action Movies')).toBeInTheDocument();
@@ -2398,6 +2412,12 @@ describe('ChicoryTV SPA scaffold', () => {
mockDashboardApi({
...builderDefaults(),
browseHandler: (search) => {
// The builder fans out per-kind (Movie/TelevisionShow/TelevisionSeason/Artist);
// only return real pages for the kind these fixtures use (TelevisionShow) so
// the other 3 kinds don't duplicate the same items.
if (search.get('mediaType') !== 'TelevisionShow') {
return { page: [], totalCount: 0 };
}
const pageNum = Number(search.get('pageNum') ?? '0');
return { page: pages[pageNum] ?? [], totalCount: 3 };
}
@@ -2524,6 +2544,9 @@ describe('ChicoryTV SPA scaffold', () => {
await renderBuilder();
// Collection is a collections kind, not one of the 4 library kinds —
// find it under the Collections tab.
fireEvent.click(screen.getByRole('button', { name: 'Collections' }));
fireEvent.doubleClick(await screen.findByText('Saturday Cartoons'));
fireEvent.change(screen.getByLabelText('Channel name'), { target: { value: 'Marathon' } });
@@ -3447,10 +3470,15 @@ function mockDashboardApi({
if (path.startsWith('/api/library/browse')) {
const search = new URL(path, window.location.origin).searchParams;
const result = browseHandler
? browseHandler(search)
: { page: browseItems, totalCount: browseItems.length };
return Promise.resolve(jsonResponse(result));
if (browseHandler) {
return Promise.resolve(jsonResponse(browseHandler(search)));
}
// Mirror the real API: an unfiltered request returns everything, a
// mediaType-scoped request (the builder now fans out per-kind) only
// returns items of that kind.
const mediaType = search.get('mediaType');
const page = mediaType ? browseItems.filter((item) => (item as { mediaType?: string }).mediaType === mediaType) : browseItems;
return Promise.resolve(jsonResponse({ page, totalCount: page.length }));
}
if (path === '/api/channel-templates') {
+82 -4
View File
@@ -63,8 +63,11 @@ import { CollectionsScreen } from './screens/CollectionsScreen';
import { FFmpegProfilesScreen } from './screens/FFmpegProfilesScreen';
import { FillerPresetsScreen } from './screens/FillerPresetsScreen';
import { LogsScreen } from './screens/LogsScreen';
import { MediaBrowseScreen } from './screens/MediaBrowseScreen';
import { SearchScreen } from './screens/SearchScreen';
import { SettingsScreen } from './screens/SettingsScreen';
import { TraktListsScreen } from './screens/TraktListsScreen';
import { TrashScreen } from './screens/TrashScreen';
import { TroubleshootingScreen } from './screens/TroubleshootingScreen';
import { WatermarksScreen } from './screens/WatermarksScreen';
import { navigateToPath } from './routing';
@@ -140,6 +143,9 @@ type ScreenId =
| 'guide'
| 'schedules'
| 'playouts'
| 'media'
| 'search'
| 'trash'
| 'collections'
| 'fillerPresets'
| 'libraries'
@@ -249,6 +255,41 @@ const routes: ScreenRoute[] = [
placeholder: 'Playouts workspace',
badge: 3
},
{
id: 'media',
path: '/app/media',
label: 'Browse',
title: 'Browse Media',
kicker: 'Media',
description: 'Browse indexed movies, shows, episodes, music and more by kind.',
icon: <Clapperboard aria-hidden="true" size={16} />,
primaryAction: '',
placeholder: 'Media browse workspace',
allowSubPaths: true
},
{
id: 'search',
path: '/app/search',
label: 'Search',
title: 'Search',
kicker: 'Media',
description: 'Search across every media kind at once.',
icon: <Search aria-hidden="true" size={16} />,
primaryAction: '',
placeholder: 'Search workspace',
allowSubPaths: true
},
{
id: 'trash',
path: '/app/trash',
label: 'Trash',
title: 'Trash',
kicker: 'Media',
description: 'Library items whose files have gone missing.',
icon: <Trash2 aria-hidden="true" size={16} />,
primaryAction: '',
placeholder: 'Trash workspace'
},
{
id: 'collections',
path: '/app/collections',
@@ -367,7 +408,15 @@ const primaryNavIds: ScreenId[] = [
'schedules',
'playouts'
];
const mediaNavIds: ScreenId[] = ['collections', 'fillerPresets', 'libraries', 'traktLists'];
const mediaNavIds: ScreenId[] = [
'media',
'search',
'trash',
'collections',
'fillerPresets',
'libraries',
'traktLists'
];
const systemNavIds: ScreenId[] = [
'settings',
'logs',
@@ -628,6 +677,7 @@ function TopBar({ route }: { route: ScreenRoute | null }) {
const title = route?.title ?? 'Page not found';
const kicker = route?.kicker ?? 'Unknown route';
const primaryAction = route?.primaryAction ?? 'Go To Dashboard';
const [searchValue, setSearchValue] = useState('');
return (
<header className="ctv-topbar">
@@ -636,10 +686,26 @@ function TopBar({ route }: { route: ScreenRoute | null }) {
<h1>{title}</h1>
</div>
<label className="ctv-topbar-search">
<form
className="ctv-topbar-search"
onSubmit={(event) => {
event.preventDefault();
const trimmed = searchValue.trim();
if (trimmed) {
navigateToPath(`/app/search?query=${encodeURIComponent(trimmed)}`);
}
}}
role="search"
>
<Search aria-hidden="true" size={15} />
<input type="search" aria-label="Search" placeholder="Search channels, schedules..." />
</label>
<input
aria-label="Search"
onChange={(event) => setSearchValue(event.target.value)}
placeholder="Search movies, shows, music..."
type="search"
value={searchValue}
/>
</form>
<div className="ctv-topbar-spacer" />
@@ -2977,6 +3043,18 @@ function ScreenContent({
return <SettingsScreen />;
}
if (route.id === 'media') {
return <MediaBrowseScreen key={window.location.search} />;
}
if (route.id === 'search') {
return <SearchScreen key={window.location.search} />;
}
if (route.id === 'trash') {
return <TrashScreen />;
}
if (route.id === 'collections') {
return <CollectionsScreen />;
}
+21 -1
View File
@@ -361,6 +361,9 @@ export interface components {
"opacityExpression": null | string;
"zIndex": number;
"placeWithinSourceContent": boolean;
};
"DeleteMediaItemsRequest": {
"ids": null | Array<number>;
};
"FFmpegFullProfileResponseModel": {
"id": number;
@@ -507,8 +510,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;
@@ -781,6 +785,22 @@ export interface components {
"preferredAudioTitle": null | string;
"preferredSubtitleLanguageCode": null | string;
"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": {
"id": number;
+3
View File
@@ -11,9 +11,12 @@ export * from './guide';
export * from './libraries';
export * from './libraryBrowse';
export * from './logs';
export * from './maintenance';
export * from './mediaItems';
export * from './pickers';
export * from './playouts';
export * from './schedules';
export * from './search';
export * from './settings';
export * from './trakt';
export * from './troubleshoot';
+25
View File
@@ -0,0 +1,25 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { emptyTrash } from './maintenance';
describe('emptyTrash', () => {
beforeEach(() => {
window.localStorage.clear();
vi.restoreAllMocks();
});
it('POSTs /api/maintenance/empty_trash', async () => {
const fetchSpy = vi.spyOn(window, 'fetch').mockResolvedValue(new Response(null, { status: 200 }));
await emptyTrash();
const [url, init] = fetchSpy.mock.calls[0];
expect(url).toBe('/api/maintenance/empty_trash');
expect((init?.method ?? 'GET').toUpperCase()).toBe('POST');
});
it('rejects with the ApiError status on failure', async () => {
vi.spyOn(window, 'fetch').mockResolvedValue(new Response(null, { status: 500 }));
await expect(emptyTrash()).rejects.toMatchObject({ status: 500 });
});
});
+17
View File
@@ -0,0 +1,17 @@
import { ApiError, request } from './client';
export function emptyTrash(): Promise<void> {
return request<void>('/api/maintenance/empty_trash', { method: 'POST' });
}
export function messageFromMaintenanceError(error: unknown, fallback = 'Maintenance action failed'): string {
if (error instanceof ApiError) {
return error.detail ?? error.message;
}
if (error instanceof Error) {
return error.message;
}
return fallback;
}
+31
View File
@@ -0,0 +1,31 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { deleteMediaItems } from './mediaItems';
describe('deleteMediaItems', () => {
beforeEach(() => {
window.localStorage.clear();
vi.restoreAllMocks();
});
it('DELETEs /api/media-items with the ids in the body', async () => {
const fetchSpy = vi.spyOn(window, 'fetch').mockResolvedValue(new Response(null, { status: 204 }));
await deleteMediaItems([1, 2, 3]);
const [url, init] = fetchSpy.mock.calls[0];
expect(url).toBe('/api/media-items');
expect((init?.method ?? 'GET').toUpperCase()).toBe('DELETE');
expect(JSON.parse(init?.body as string)).toEqual({ ids: [1, 2, 3] });
});
it('rejects with the ApiError status on failure', async () => {
vi.spyOn(window, 'fetch').mockResolvedValue(
new Response(JSON.stringify({ status: 422, title: 'Validation failed' }), {
headers: { 'Content-Type': 'application/json' },
status: 422
})
);
await expect(deleteMediaItems([])).rejects.toMatchObject({ status: 422 });
});
});
+17
View File
@@ -0,0 +1,17 @@
import { ApiError, request } from './client';
export function deleteMediaItems(ids: number[]): Promise<void> {
return request<void>('/api/media-items', { body: { ids }, method: 'DELETE' });
}
export function messageFromMediaItemsError(error: unknown, fallback = 'Unable to delete media items'): string {
if (error instanceof ApiError) {
return error.detail ?? error.message;
}
if (error instanceof Error) {
return error.message;
}
return fallback;
}
+63
View File
@@ -0,0 +1,63 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { getSearchResults } from './search';
const emptyGroup = { totalCount: 0, items: [] };
const sampleResults = {
movies: emptyGroup,
shows: emptyGroup,
seasons: emptyGroup,
artists: emptyGroup,
episodes: emptyGroup,
musicVideos: emptyGroup,
songs: emptyGroup,
otherVideos: emptyGroup,
images: emptyGroup,
remoteStreams: emptyGroup
};
describe('getSearchResults', () => {
beforeEach(() => {
window.localStorage.clear();
vi.restoreAllMocks();
});
it('GETs /api/search with the query', async () => {
const fetchSpy = vi.spyOn(window, 'fetch').mockResolvedValue(
new Response(JSON.stringify(sampleResults), {
headers: { 'Content-Type': 'application/json' },
status: 200
})
);
await getSearchResults({ query: 'star' });
const [url, init] = fetchSpy.mock.calls[0];
expect(url).toBe('/api/search?query=star');
expect((init?.method ?? 'GET').toUpperCase()).toBe('GET');
});
it('includes pageSize when provided', async () => {
const fetchSpy = vi.spyOn(window, 'fetch').mockResolvedValue(
new Response(JSON.stringify(sampleResults), {
headers: { 'Content-Type': 'application/json' },
status: 200
})
);
await getSearchResults({ query: 'star wars', pageSize: 50 });
const [url] = fetchSpy.mock.calls[0];
expect(url).toBe('/api/search?query=star+wars&pageSize=50');
});
it('rejects with the ApiError status on failure', async () => {
vi.spyOn(window, 'fetch').mockResolvedValue(
new Response(JSON.stringify({ status: 422, title: 'Validation failed' }), {
headers: { 'Content-Type': 'application/json' },
status: 422
})
);
await expect(getSearchResults({ query: '' })).rejects.toMatchObject({ status: 422 });
});
});
+33
View File
@@ -0,0 +1,33 @@
import { ApiError, request } from './client';
import type { components } from './generated/v1';
export type SearchResults = components['schemas']['SearchResultsResponseModel'];
export type SearchResultGroup = components['schemas']['SearchResultGroupResponseModel'];
export interface GetSearchResultsParams {
query: string;
pageSize?: number;
}
export function getSearchResults(params: GetSearchResultsParams): Promise<SearchResults> {
const searchParams = new URLSearchParams();
searchParams.set('query', params.query);
if (params.pageSize != null) {
searchParams.set('pageSize', String(params.pageSize));
}
return request<SearchResults>(`/api/search?${searchParams.toString()}`);
}
export function messageFromSearchError(error: unknown, fallback = 'Unable to search library'): string {
if (error instanceof ApiError) {
return error.detail ?? error.message;
}
if (error instanceof Error) {
return error.message;
}
return fallback;
}
+44 -45
View File
@@ -20,26 +20,22 @@ import {
GripVertical,
Image as ImageIcon,
Info,
Layers,
Library,
LayoutGrid,
ListOrdered,
ListVideo,
Music,
Plus,
Radio,
Repeat,
Rows3,
Save,
Search,
Shuffle,
SlidersHorizontal,
Sparkles,
SquarePen,
Tv,
X
} from 'lucide-react';
import { navigateToPath } from '../routing';
import { TYPE_ICON, TYPE_LABEL, hueOf } from '../media/mediaKinds';
import {
ApiError,
createChannelFromLineup,
@@ -133,29 +129,8 @@ const STREAMING_MODE_LABELS: Record<StreamingMode, string> = {
};
// ---- Media-type presentation ----------------------------------------------
const TYPE_ICON: Record<LibraryBrowseMediaType, typeof Film> = {
Movie: Film,
TelevisionShow: Tv,
TelevisionSeason: Tv,
Artist: Music,
Collection: FolderTree,
SmartCollection: Sparkles,
MultiCollection: Layers,
RerunCollection: Repeat,
Playlist: ListVideo
};
const TYPE_LABEL: Record<LibraryBrowseMediaType, string> = {
Movie: 'Movie',
TelevisionShow: 'Show',
TelevisionSeason: 'Season',
Artist: 'Artist',
Collection: 'Collection',
SmartCollection: 'Smart',
MultiCollection: 'Multi',
RerunCollection: 'Rerun',
Playlist: 'Playlist'
};
// TYPE_ICON / TYPE_LABEL / hueOf are shared with the media browse/search/trash screens
// (see ../media/mediaKinds) so the per-kind icon+label map has a single source of truth.
const COLLECTION_MEDIA_TYPES: LibraryBrowseMediaType[] = [
'Collection',
@@ -165,18 +140,20 @@ const COLLECTION_MEDIA_TYPES: LibraryBrowseMediaType[] = [
'Playlist'
];
// The pre-#168 API default: browsing a library shows the "pickable" top-level
// kinds only (shows/seasons/movies/artists), not every episode/song/etc.
// nested underneath them. Kept explicit here since `GET /api/library/browse`
// now spans all 10 media kinds when `mediaType` is omitted.
const LIBRARY_MEDIA_TYPES: LibraryBrowseMediaType[] = [
'Movie',
'TelevisionShow',
'TelevisionSeason',
'Artist'
];
// ---- Small pure helpers ----------------------------------------------------
const mono: CSSProperties = { fontFamily: 'var(--font-mono)', fontVariantNumeric: 'tabular-nums' };
// deterministic low-chroma poster hue from a title (ported from prototype)
function hueOf(value: string): number {
let hue = 0;
for (let index = 0; index < value.length; index += 1) {
hue = (hue * 31 + value.charCodeAt(index)) % 360;
}
return hue;
}
// Parse a .NET TimeSpan (`[d.]hh:mm:ss[.fffffff]`) or ISO8601 duration to seconds.
function parseDurationSeconds(value: string | null | undefined): number | null {
if (!value) {
@@ -445,6 +422,35 @@ async function loadCollections(query: string): Promise<{ page: LibraryBrowseItem
return { page: merged, totalCount: merged.length };
}
// Fan out across the four pickable library kinds (movies/shows/seasons/artists)
// instead of the unscoped browse, which now also returns episodes/songs/etc.
// Mirrors loadCollections's per-kind Promise.all pattern, but preserves real
// paging (each kind keeps its own pageNum/totalCount, summed across kinds) so
// canLoadMore stays meaningful for large libraries.
async function loadLibraryItems(
query: string,
libraryId: number | null,
pageNum: number,
pageSize: number
): Promise<{ page: LibraryBrowseItem[]; totalCount: number }> {
const results = await Promise.all(
LIBRARY_MEDIA_TYPES.map((mediaType) =>
getLibraryBrowseItems({
query: query || undefined,
libraryId: libraryId ?? undefined,
mediaType,
pageNum,
pageSize
})
)
);
const merged = results
.flatMap((result) => result.page)
.sort((left, right) => left.title.localeCompare(right.title, undefined, { sensitivity: 'base' }));
const totalCount = results.reduce((sum, result) => sum + result.totalCount, 0);
return { page: merged, totalCount };
}
function useLibraryBrowse(source: 'library' | 'collections', query: string, libraryId: number | null) {
const [state, setState] = useState<BrowseState>({ status: 'loading', items: [], totalCount: 0, error: null });
const [loadingMore, setLoadingMore] = useState(false);
@@ -454,14 +460,7 @@ function useLibraryBrowse(source: 'library' | 'collections', query: string, libr
const runFetch = useCallback(
(pageNum: number, reqId: number, append: boolean) => {
const promise =
source === 'collections'
? loadCollections(query)
: getLibraryBrowseItems({
query: query || undefined,
libraryId: libraryId ?? undefined,
pageNum,
pageSize: 100
});
source === 'collections' ? loadCollections(query) : loadLibraryItems(query, libraryId, pageNum, 100);
promise
.then((result) => {
+73
View File
@@ -0,0 +1,73 @@
import type { CSSProperties } from 'react';
import { Film } from 'lucide-react';
import type { LibraryBrowseItem } from '../api';
import { TYPE_ICON, TYPE_LABEL, hueOf, itemSubtitle } from './mediaKinds';
// A poster/thumbnail grid card for a browse/search item. Selectable + clickable for the media
// browse/search/trash screens. Falls back to a deterministic gradient + type icon when no artwork.
export function MediaPosterCard({
item,
selected,
onToggleSelect,
height = 150
}: {
item: LibraryBrowseItem;
selected?: boolean;
onToggleSelect?: (item: LibraryBrowseItem) => void;
height?: number;
}) {
const hue = hueOf(item.title);
const Icon = TYPE_ICON[item.mediaType] ?? Film;
const subtitle = itemSubtitle(item);
const posterStyle: CSSProperties = {
width: '100%',
height,
background: `linear-gradient(160deg, hsl(${hue} 24% 17%), hsl(${hue} 26% 12%))`
};
const selectable = typeof onToggleSelect === 'function';
return (
<div
className={`ctv-media-card${selected ? ' ctv-media-card-selected' : ''}${selectable ? ' ctv-press' : ''}`}
onClick={selectable ? () => onToggleSelect?.(item) : undefined}
role={selectable ? 'button' : undefined}
tabIndex={selectable ? 0 : undefined}
onKeyDown={
selectable
? (event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
onToggleSelect?.(item);
}
}
: undefined
}
aria-pressed={selectable ? selected ?? false : undefined}
>
<div className="ctv-media-card-poster" style={posterStyle}>
{item.artwork ? (
<img src={item.artwork} alt="" loading="lazy" />
) : (
<div
style={{
position: 'absolute',
inset: 0,
backgroundImage: `repeating-linear-gradient(135deg, hsl(${hue} 22% 10% / .55) 0 1px, transparent 1px 9px)`
}}
/>
)}
<span className="ctv-media-card-icon" style={{ color: `hsl(${hue} 30% 68%)` }}>
<Icon size={15} aria-hidden="true" />
</span>
{selected && <div className="ctv-media-card-check">✓</div>}
</div>
<div className="ctv-media-card-body">
<div className="ctv-media-card-title" title={item.title}>
{item.title}
</div>
<div className="ctv-media-card-meta">{subtitle || TYPE_LABEL[item.mediaType]}</div>
</div>
</div>
);
}
+113
View File
@@ -0,0 +1,113 @@
import {
Film,
FolderTree,
Image as ImageIcon,
Layers,
ListVideo,
Music,
Radio,
Repeat,
Sparkles,
Tv,
Video
} from 'lucide-react';
import type { LibraryBrowseItem, LibraryBrowseMediaType } from '../api';
export const TYPE_ICON: Record<LibraryBrowseMediaType, typeof Film> = {
Movie: Film,
TelevisionShow: Tv,
TelevisionSeason: Tv,
Artist: Music,
Collection: FolderTree,
SmartCollection: Sparkles,
MultiCollection: Layers,
RerunCollection: Repeat,
Playlist: ListVideo,
Episode: Tv,
MusicVideo: Video,
Song: Music,
OtherVideo: Film,
Image: ImageIcon,
RemoteStream: Radio
};
export const TYPE_LABEL: Record<LibraryBrowseMediaType, string> = {
Movie: 'Movie',
TelevisionShow: 'Show',
TelevisionSeason: 'Season',
Artist: 'Artist',
Collection: 'Collection',
SmartCollection: 'Smart',
MultiCollection: 'Multi',
RerunCollection: 'Rerun',
Playlist: 'Playlist',
Episode: 'Episode',
MusicVideo: 'Music Video',
Song: 'Song',
OtherVideo: 'Other Video',
Image: 'Image',
RemoteStream: 'Remote Stream'
};
// Deterministic hue so the same title always gets the same placeholder gradient.
export function hueOf(value: string): number {
let hue = 0;
for (let index = 0; index < value.length; index += 1) {
hue = (hue * 31 + value.charCodeAt(index)) % 360;
}
return hue;
}
export function parseDurationSeconds(value: string | null | undefined): number | null {
if (!value) {
return null;
}
const timeSpan = /^(?:(\d+)\.)?(\d{1,2}):(\d{2}):(\d{2})(?:\.\d+)?$/.exec(value);
if (timeSpan) {
const [, days, hours, minutes, seconds] = timeSpan;
return (days ? Number(days) * 86400 : 0) + Number(hours) * 3600 + Number(minutes) * 60 + Number(seconds);
}
const iso = /^P(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/.exec(value);
if (iso) {
const [, days, hours, minutes, seconds] = iso;
return (
(days ? Number(days) * 86400 : 0) +
(hours ? Number(hours) * 3600 : 0) +
(minutes ? Number(minutes) * 60 : 0) +
(seconds ? Number(seconds) : 0)
);
}
return null;
}
export function formatDuration(totalSeconds: number): string {
const totalMinutes = Math.round(totalSeconds / 60);
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
if (hours && minutes) {
return `${hours}h ${String(minutes).padStart(2, '0')}m`;
}
if (hours) {
return `${hours}h`;
}
return `${minutes}m`;
}
// The subtitle the API supplies (episode "Show - S1E2", song "Artist - Album", …) with a duration
// fallback so cards always have a useful second line.
export function itemSubtitle(item: LibraryBrowseItem): string {
if (item.subtitle) {
return item.subtitle;
}
const durationSeconds = parseDurationSeconds(item.duration);
if (durationSeconds) {
return formatDuration(durationSeconds);
}
if (item.itemCount) {
return `${item.itemCount} item${item.itemCount === 1 ? '' : 's'}`;
}
return '';
}
@@ -265,4 +265,61 @@ describe('CollectionsScreen', () => {
);
expect(new URL(String(browseCall?.[0]), 'http://localhost').searchParams.get('query')).toBe('collection:"Favorites"');
});
it('fans the add-items search out over the 4 addable kinds and merges the results', async () => {
const byType: Record<string, { id: number; mediaType: string; title: string }> = {
Movie: { id: 1, mediaType: 'Movie', title: 'Zathura' },
TelevisionShow: { id: 2, mediaType: 'TelevisionShow', title: 'Adventure Time' },
TelevisionSeason: { id: 3, mediaType: 'TelevisionSeason', title: 'Melon Season 1' },
Artist: { id: 4, mediaType: 'Artist', title: 'Between Movie and Show' }
};
const fetchMock = mockApi({
onRequest: (url) => {
if (url.startsWith('/api/library/browse')) {
const params = new URL(url, 'http://localhost').searchParams;
const query = params.get('query') ?? '';
// The manual-collection preview query (collection:"Favorites") should not
// reach the add-items search path.
if (query.startsWith('collection:')) {
return jsonResponse({ page: [], totalCount: 0 });
}
const mediaType = params.get('mediaType');
const item = mediaType ? byType[mediaType] : undefined;
return jsonResponse(item ? { page: [item], totalCount: 1 } : { page: [], totalCount: 0 });
}
return null;
}
});
render(<CollectionsScreen />);
await screen.findByText('Favorites');
fireEvent.click(screen.getByText('Favorites'));
await screen.findByText(/best-effort search preview/);
fireEvent.click(screen.getByRole('button', { name: 'Add items' }));
const dialog = screen.getByRole('dialog');
fireEvent.change(within(dialog).getByPlaceholderText('Search movies, shows, seasons, artists…'), {
target: { value: 'a' }
});
fireEvent.click(within(dialog).getByRole('button', { name: 'Search' }));
expect(await within(dialog).findByText('Adventure Time')).toBeInTheDocument();
expect(within(dialog).getByText('Zathura')).toBeInTheDocument();
expect(within(dialog).getByText('Melon Season 1')).toBeInTheDocument();
expect(within(dialog).getByText('Between Movie and Show')).toBeInTheDocument();
const browseTypes = fetchMock.mock.calls
.map(([u]) => u.toString())
.filter((u) => u.startsWith('/api/library/browse'))
.map((u) => new URL(u, 'http://localhost').searchParams.get('mediaType'))
.filter((mediaType): mediaType is string => mediaType != null);
expect(new Set(browseTypes)).toEqual(new Set(['Movie', 'TelevisionShow', 'TelevisionSeason', 'Artist']));
});
});
+12 -20
View File
@@ -42,27 +42,12 @@ import {
type MediaCollection,
type SmartCollection
} from '../api';
import { TYPE_LABEL } from '../media/mediaKinds';
type Tab = 'manual' | 'smart';
const ADDABLE_TYPES = new Set<LibraryBrowseItem['mediaType']>([
'Movie',
'TelevisionShow',
'TelevisionSeason',
'Artist'
]);
const TYPE_LABEL: Record<LibraryBrowseItem['mediaType'], string> = {
Artist: 'Artist',
Collection: 'Collection',
Movie: 'Movie',
MultiCollection: 'Multi',
Playlist: 'Playlist',
RerunCollection: 'Rerun',
SmartCollection: 'Smart',
TelevisionSeason: 'Season',
TelevisionShow: 'Show'
};
const ADDABLE_TYPE_LIST: LibraryBrowseItem['mediaType'][] = ['Movie', 'TelevisionShow', 'TelevisionSeason', 'Artist'];
const ADDABLE_TYPES = new Set<LibraryBrowseItem['mediaType']>(ADDABLE_TYPE_LIST);
function sortByName<T extends { name?: null | string }>(items: T[]): T[] {
return [...items].sort((left, right) => (left.name ?? '').localeCompare(right.name ?? ''));
@@ -322,8 +307,15 @@ function AddItemsDialog({
setError(null);
try {
const result = await getLibraryBrowseItems({ pageSize: 50, query: query.trim() });
setResults((result.page ?? []).filter((item) => ADDABLE_TYPES.has(item.mediaType)));
const trimmed = query.trim();
const perKind = await Promise.all(
ADDABLE_TYPE_LIST.map((mediaType) => getLibraryBrowseItems({ pageSize: 50, query: trimmed, mediaType }))
);
const merged = perKind
.flatMap((result) => result.page ?? [])
.filter((item) => ADDABLE_TYPES.has(item.mediaType))
.sort((left, right) => left.title.localeCompare(right.title, undefined, { sensitivity: 'base' }));
setResults(merged.slice(0, 50));
} catch (searchError) {
setError(messageFromCollectionError(searchError, 'Unable to search library'));
} finally {
+201
View File
@@ -0,0 +1,201 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { ChevronLeft, ChevronRight, Info, RefreshCw, Search, TriangleAlert } from 'lucide-react';
import { Button, Card, IconButton, Input, Select, Spinner } from '../components';
import {
getLibraryBrowseItems,
messageFromLibraryBrowseError,
type LibraryBrowseItem,
type LibraryBrowseMediaType
} from '../api';
import { MediaPosterCard } from '../media/MediaPosterCard';
const PAGE_SIZE = 100;
interface MediaKind {
slug: string;
label: string;
mediaType: LibraryBrowseMediaType;
}
// Matches the legacy Blazor media IA (no top-level Seasons page; seasons live under a show).
const KINDS: MediaKind[] = [
{ slug: 'movies', label: 'Movies', mediaType: 'Movie' },
{ slug: 'shows', label: 'TV Shows', mediaType: 'TelevisionShow' },
{ slug: 'episodes', label: 'Episodes', mediaType: 'Episode' },
{ slug: 'artists', label: 'Artists', mediaType: 'Artist' },
{ slug: 'music-videos', label: 'Music Videos', mediaType: 'MusicVideo' },
{ slug: 'songs', label: 'Songs', mediaType: 'Song' },
{ slug: 'other-videos', label: 'Other Videos', mediaType: 'OtherVideo' },
{ slug: 'images', label: 'Images', mediaType: 'Image' },
{ slug: 'remote-streams', label: 'Remote Streams', mediaType: 'RemoteStream' }
];
function kindFromSlug(slug: string | null): MediaKind {
return KINDS.find((kind) => kind.slug === slug) ?? KINDS[0];
}
type BrowseState =
| { items: LibraryBrowseItem[]; error: null; status: 'success'; totalCount: number }
| { items: []; error: string; status: 'error'; totalCount: 0 }
| { items: []; error: null; status: 'loading'; totalCount: 0 };
export function MediaBrowseScreen() {
const params = new URLSearchParams(window.location.search);
const initialKind = kindFromSlug(params.get('kind'));
const initialQuery = params.get('q') ?? '';
const [kind, setKind] = useState<MediaKind>(initialKind);
const [queryInput, setQueryInput] = useState(initialQuery);
const [query, setQuery] = useState(initialQuery);
const [pageNum, setPageNum] = useState(0);
const [state, setState] = useState<BrowseState>({ items: [], error: null, status: 'loading', totalCount: 0 });
const activeRef = useRef(true);
const seqRef = useRef(0);
useEffect(() => {
activeRef.current = true;
return () => {
activeRef.current = false;
};
}, []);
// Debounce the per-kind free-text box; it composes straight into the browse (Lucene) query.
useEffect(() => {
const handle = window.setTimeout(() => {
setPageNum(0);
setQuery(queryInput.trim());
}, 300);
return () => window.clearTimeout(handle);
}, [queryInput]);
const load = useCallback(() => {
const id = ++seqRef.current;
getLibraryBrowseItems({ mediaType: kind.mediaType, query: query || undefined, pageNum, pageSize: PAGE_SIZE })
.then((paged) => {
if (activeRef.current && id === seqRef.current) {
setState({ items: paged.page ?? [], error: null, status: 'success', totalCount: paged.totalCount ?? 0 });
}
})
.catch((error: unknown) => {
if (activeRef.current && id === seqRef.current) {
setState({ items: [], error: messageFromLibraryBrowseError(error), status: 'error', totalCount: 0 });
}
});
}, [kind.mediaType, query, pageNum]);
useEffect(() => {
load();
}, [load]);
const changeKind = (slug: string) => {
const next = kindFromSlug(slug);
setKind(next);
setPageNum(0);
setQueryInput('');
setQuery('');
// Keep the URL shareable/back-navigable without remounting the whole screen.
window.history.replaceState(null, '', `/app/media?kind=${next.slug}`);
};
const refresh = () => {
setState({ items: [], error: null, status: 'loading', totalCount: 0 });
load();
};
const totalPages = state.status === 'success' ? Math.max(1, Math.ceil(state.totalCount / PAGE_SIZE)) : 1;
return (
<div className="ctv-collections">
<div className="ctv-channels-actionbar">
<Select
fullWidth={false}
onChange={(event) => changeKind(event.target.value)}
options={KINDS.map((entry) => ({ label: entry.label, value: entry.slug }))}
style={{ width: 180 }}
value={kind.slug}
/>
<Input
leadingIcon={<Search aria-hidden="true" size={14} />}
onChange={(event) => setQueryInput(event.target.value)}
placeholder={`Search ${kind.label.toLowerCase()}…`}
value={queryInput}
/>
<span className="ctv-channels-spacer" />
<Button
onClick={refresh}
size="sm"
startIcon={<RefreshCw aria-hidden="true" size={14} />}
variant="secondary"
>
Refresh
</Button>
</div>
{state.status === 'error' && (
<div className="ctv-channels-error" role="alert">
<TriangleAlert aria-hidden="true" size={15} />
<span>{state.error}</span>
<span className="ctv-channels-spacer" />
<Button onClick={refresh} size="sm" variant="secondary">
Retry
</Button>
</div>
)}
{state.status === 'loading' ? (
<div className="ctv-collections-loading" role="status">
<Spinner size={18} />
<span>Loading {kind.label.toLowerCase()}…</span>
</div>
) : state.items.length === 0 ? (
<Card>
<div className="ctv-collections-empty">
{query ? `No ${kind.label.toLowerCase()} match this search.` : `No ${kind.label.toLowerCase()} found.`}
</div>
</Card>
) : (
<>
<div className="ctv-media-grid">
{state.items.map((item) => (
<MediaPosterCard item={item} key={`${item.mediaType}-${item.id}`} />
))}
</div>
<div className="ctv-channels-footer" style={{ marginTop: 'var(--space-6, 12px)' }}>
<span>
{state.totalCount} {kind.label.toLowerCase()}
</span>
<span className="ctv-channels-spacer" />
<IconButton
disabled={pageNum === 0}
onClick={() => setPageNum((current) => Math.max(0, current - 1))}
size="sm"
title="Previous page"
>
<ChevronLeft aria-hidden="true" size={15} />
</IconButton>
<span>
Page <code>{pageNum + 1}</code> of <code>{totalPages}</code>
</span>
<IconButton
disabled={pageNum + 1 >= totalPages}
onClick={() => setPageNum((current) => current + 1)}
size="sm"
title="Next page"
>
<ChevronRight aria-hidden="true" size={15} />
</IconButton>
</div>
</>
)}
<div className="ctv-settings-callout">
<Info aria-hidden="true" size={14} />
<span>
Browsing indexed library items. Use the search box for a quick per-kind filter, or the global search for
results across every media kind.
</span>
</div>
</div>
);
}
+176
View File
@@ -0,0 +1,176 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { Search, TriangleAlert } from 'lucide-react';
import { Button, Card, Input, Spinner } from '../components';
import { getSearchResults, messageFromSearchError, type SearchResults } from '../api';
import { navigateToPath } from '../routing';
import { MediaPosterCard } from '../media/MediaPosterCard';
const PAGE_SIZE = 50;
interface GroupDef {
key: keyof SearchResults;
label: string;
// MediaBrowse slug for "See all"; null for kinds without a top-level browse page (seasons).
slug: string | null;
}
// Order + labels mirror the legacy Blazor search page.
const GROUPS: GroupDef[] = [
{ key: 'movies', label: 'Movies', slug: 'movies' },
{ key: 'shows', label: 'TV Shows', slug: 'shows' },
{ key: 'seasons', label: 'Seasons', slug: null },
{ key: 'episodes', label: 'Episodes', slug: 'episodes' },
{ key: 'artists', label: 'Artists', slug: 'artists' },
{ key: 'musicVideos', label: 'Music Videos', slug: 'music-videos' },
{ key: 'songs', label: 'Songs', slug: 'songs' },
{ key: 'otherVideos', label: 'Other Videos', slug: 'other-videos' },
{ key: 'images', label: 'Images', slug: 'images' },
{ key: 'remoteStreams', label: 'Remote Streams', slug: 'remote-streams' }
];
type SearchState =
| { results: SearchResults; error: null; status: 'success' }
| { results: null; error: string; status: 'error' }
| { results: null; error: null; status: 'loading' };
export function SearchScreen() {
const initialQuery = new URLSearchParams(window.location.search).get('query') ?? '';
const [queryInput, setQueryInput] = useState(initialQuery);
const [query, setQuery] = useState(initialQuery);
const [state, setState] = useState<SearchState>({ results: null, error: null, status: 'loading' });
const activeRef = useRef(true);
const seqRef = useRef(0);
const hasQuery = query.trim().length > 0;
useEffect(() => {
activeRef.current = true;
return () => {
activeRef.current = false;
};
}, []);
useEffect(() => {
const handle = window.setTimeout(() => {
const trimmed = queryInput.trim();
setQuery(trimmed);
// Keep the URL shareable without remounting.
const next = trimmed ? `/app/search?query=${encodeURIComponent(trimmed)}` : '/app/search';
window.history.replaceState(null, '', next);
}, 300);
return () => window.clearTimeout(handle);
}, [queryInput]);
// Fetch only; state updates happen in the async callbacks (never synchronously in the effect
// body) so results stay visible during a refetch. The empty-query "idle" case is derived from
// `hasQuery` in render rather than stored, so this never fires for a blank query.
const load = useCallback(() => {
const trimmed = query.trim();
if (!trimmed) {
return;
}
const id = ++seqRef.current;
getSearchResults({ query: trimmed, pageSize: PAGE_SIZE })
.then((results) => {
if (activeRef.current && id === seqRef.current) {
setState({ results, error: null, status: 'success' });
}
})
.catch((error: unknown) => {
if (activeRef.current && id === seqRef.current) {
setState({ results: null, error: messageFromSearchError(error), status: 'error' });
}
});
}, [query]);
useEffect(() => {
load();
}, [load]);
const seeAll = (slug: string) => {
navigateToPath(`/app/media?kind=${slug}&q=${encodeURIComponent(query.trim())}`);
};
const totalMatches =
state.status === 'success' && state.results
? GROUPS.reduce((sum, group) => sum + (state.results?.[group.key].totalCount ?? 0), 0)
: 0;
return (
<div className="ctv-collections">
<div className="ctv-channels-actionbar">
<Input
leadingIcon={<Search aria-hidden="true" size={14} />}
onChange={(event) => setQueryInput(event.target.value)}
placeholder="Search movies, shows, episodes, music…"
value={queryInput}
/>
</div>
{!hasQuery && (
<Card>
<div className="ctv-collections-empty">Type a query to search across every media kind.</div>
</Card>
)}
{hasQuery && state.status === 'error' && (
<div className="ctv-channels-error" role="alert">
<TriangleAlert aria-hidden="true" size={15} />
<span>{state.error}</span>
<span className="ctv-channels-spacer" />
<Button onClick={load} size="sm" variant="secondary">
Retry
</Button>
</div>
)}
{hasQuery && state.status === 'loading' && (
<div className="ctv-collections-loading" role="status">
<Spinner size={18} />
<span>Searching…</span>
</div>
)}
{hasQuery && state.status === 'success' && state.results && totalMatches === 0 && (
<Card>
<div className="ctv-collections-empty">No results for “{query.trim()}”.</div>
</Card>
)}
{hasQuery &&
state.status === 'success' &&
state.results &&
GROUPS.map((group) => {
const data = state.results![group.key];
if (data.totalCount === 0 || data.items.length === 0) {
return null;
}
const hasMore = group.slug !== null && data.totalCount > data.items.length;
return (
<section key={group.key}>
<div className="ctv-media-section-header">
<h2>{group.label}</h2>
<span className="ctv-media-section-count">
{data.totalCount} result{data.totalCount === 1 ? '' : 's'}
</span>
<span className="ctv-channels-spacer" />
{hasMore && (
<Button onClick={() => seeAll(group.slug!)} size="sm" variant="ghost">
See all
</Button>
)}
</div>
<div className="ctv-media-grid">
{data.items.map((item) => (
<MediaPosterCard item={item} key={`${item.mediaType}-${item.id}`} />
))}
</div>
</section>
);
})}
</div>
);
}
+1 -1
View File
@@ -861,7 +861,7 @@ function SystemPane({
Open Dashboard
</Button>
</Row>
<Row control={220} help="Media browse/search, blocks/decos/templates and playout editors still live here." label="Classic UI">
<Row control={220} help="Blocks/decos/templates and playout editors still live here." label="Classic UI">
<a className="ctv-button ctv-button-secondary ctv-button-sm" href="/system/health">
<span>Open Classic UI</span>
<ExternalLink aria-hidden="true" size={13} />
+243
View File
@@ -0,0 +1,243 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { RefreshCw, Trash2, TriangleAlert } from 'lucide-react';
import { Button, Card, ConfirmDialog, Spinner } from '../components';
import {
deleteMediaItems,
emptyTrash,
getSearchResults,
messageFromSearchError,
type LibraryBrowseItem,
type SearchResults
} from '../api';
import { MediaPosterCard } from '../media/MediaPosterCard';
const PAGE_SIZE = 50;
// Lucene state filter for items whose files have gone missing (matches the legacy Blazor Trash page).
const TRASH_QUERY = 'state:FileNotFound';
interface GroupDef {
key: keyof SearchResults;
label: string;
}
const GROUPS: GroupDef[] = [
{ key: 'movies', label: 'Movies' },
{ key: 'shows', label: 'TV Shows' },
{ key: 'seasons', label: 'Seasons' },
{ key: 'episodes', label: 'Episodes' },
{ key: 'artists', label: 'Artists' },
{ key: 'musicVideos', label: 'Music Videos' },
{ key: 'songs', label: 'Songs' },
{ key: 'otherVideos', label: 'Other Videos' },
{ key: 'images', label: 'Images' },
{ key: 'remoteStreams', label: 'Remote Streams' }
];
type TrashState =
| { results: SearchResults; error: null; status: 'success' }
| { results: null; error: string; status: 'error' }
| { results: null; error: null; status: 'loading' };
function mediaItemIdOf(item: LibraryBrowseItem): number | null {
return item.mediaItemId ?? null;
}
export function TrashScreen() {
const [state, setState] = useState<TrashState>({ results: null, error: null, status: 'loading' });
const [selected, setSelected] = useState<Set<number>>(new Set());
const [confirm, setConfirm] = useState<null | 'selected' | 'all'>(null);
const [busy, setBusy] = useState(false);
const [actionError, setActionError] = useState<string | null>(null);
const activeRef = useRef(true);
const seqRef = useRef(0);
useEffect(() => {
activeRef.current = true;
return () => {
activeRef.current = false;
};
}, []);
// Fetch only; state updates happen in the async callbacks so the list stays visible during a
// refetch (the initial 'loading' state and the Refresh button provide the loading affordance).
const load = useCallback(() => {
const id = ++seqRef.current;
getSearchResults({ query: TRASH_QUERY, pageSize: PAGE_SIZE })
.then((results) => {
if (activeRef.current && id === seqRef.current) {
setState({ results, error: null, status: 'success' });
}
})
.catch((error: unknown) => {
if (activeRef.current && id === seqRef.current) {
setState({ results: null, error: messageFromSearchError(error), status: 'error' });
}
});
}, []);
useEffect(() => {
load();
}, [load]);
const refresh = () => {
setState({ results: null, error: null, status: 'loading' });
load();
};
const toggle = (item: LibraryBrowseItem) => {
const mediaItemId = mediaItemIdOf(item);
if (mediaItemId == null) {
return;
}
setSelected((current) => {
const next = new Set(current);
if (next.has(mediaItemId)) {
next.delete(mediaItemId);
} else {
next.add(mediaItemId);
}
return next;
});
};
const totalMatches =
state.status === 'success' && state.results
? GROUPS.reduce((sum, group) => sum + state.results![group.key].totalCount, 0)
: 0;
const runDelete = async () => {
setBusy(true);
setActionError(null);
try {
if (confirm === 'all') {
await emptyTrash();
} else {
await deleteMediaItems([...selected]);
}
if (activeRef.current) {
setSelected(new Set());
setConfirm(null);
load();
}
} catch (error: unknown) {
if (activeRef.current) {
setActionError(messageFromSearchError(error, 'Unable to delete items'));
}
} finally {
if (activeRef.current) {
setBusy(false);
}
}
};
return (
<div className="ctv-collections">
<div className="ctv-channels-actionbar">
{selected.size > 0 && (
<Button
onClick={() => setConfirm('selected')}
size="sm"
startIcon={<Trash2 aria-hidden="true" size={14} />}
variant="danger"
>
Delete {selected.size} selected
</Button>
)}
<span className="ctv-channels-spacer" />
<Button
onClick={refresh}
size="sm"
startIcon={<RefreshCw aria-hidden="true" size={14} />}
variant="secondary"
>
Refresh
</Button>
<Button
disabled={totalMatches === 0}
onClick={() => setConfirm('all')}
size="sm"
startIcon={<Trash2 aria-hidden="true" size={14} />}
variant="danger"
>
Empty Trash
</Button>
</div>
{(state.status === 'error' || actionError) && (
<div className="ctv-channels-error" role="alert">
<TriangleAlert aria-hidden="true" size={15} />
<span>{actionError ?? (state.status === 'error' ? state.error : '')}</span>
<span className="ctv-channels-spacer" />
<Button onClick={refresh} size="sm" variant="secondary">
Retry
</Button>
</div>
)}
{state.status === 'loading' && (
<div className="ctv-collections-loading" role="status">
<Spinner size={18} />
<span>Loading trash…</span>
</div>
)}
{state.status === 'success' && state.results && totalMatches === 0 && (
<Card>
<div className="ctv-collections-empty">Trash is empty — no missing files detected.</div>
</Card>
)}
{state.status === 'success' &&
state.results &&
GROUPS.map((group) => {
const data = state.results![group.key];
if (data.totalCount === 0 || data.items.length === 0) {
return null;
}
return (
<section key={group.key}>
<div className="ctv-media-section-header">
<h2>{group.label}</h2>
<span className="ctv-media-section-count">
{data.totalCount} missing
</span>
</div>
<div className="ctv-media-grid">
{data.items.map((item) => {
const mediaItemId = mediaItemIdOf(item);
return (
<MediaPosterCard
item={item}
key={`${item.mediaType}-${item.id}`}
onToggleSelect={mediaItemId == null ? undefined : toggle}
selected={mediaItemId != null && selected.has(mediaItemId)}
/>
);
})}
</div>
</section>
);
})}
<ConfirmDialog
busy={busy}
confirmLabel={confirm === 'all' ? 'Empty Trash' : 'Delete'}
message={
confirm === 'all'
? 'Permanently remove every item with a missing file from the database? This cannot be undone.'
: `Permanently remove ${selected.size} selected item${selected.size === 1 ? '' : 's'} from the database? This cannot be undone.`
}
onCancel={() => {
if (!busy) {
setConfirm(null);
}
}}
onConfirm={runDelete}
open={confirm !== null}
title={confirm === 'all' ? 'Empty Trash' : 'Delete selected items'}
tone="danger"
/>
</div>
);
}
+104
View File
@@ -2443,6 +2443,110 @@
}
}
/* media browse / search / trash poster grid */
.ctv-media-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: var(--space-6, 12px);
}
.ctv-media-card {
position: relative;
border-radius: var(--radius-sm);
border: 1px solid var(--border-hairline);
background: var(--surface-card);
overflow: hidden;
transition:
border-color 120ms ease,
box-shadow 120ms ease;
}
.ctv-media-card[role='button'] {
cursor: pointer;
}
.ctv-media-card-selected {
border-color: var(--action-primary);
box-shadow: 0 0 0 1px var(--action-primary);
}
.ctv-media-card-poster {
position: relative;
overflow: hidden;
}
.ctv-media-card-poster img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
.ctv-media-card-icon {
position: absolute;
top: 7px;
left: 7px;
display: inline-flex;
z-index: 1;
}
.ctv-media-card-check {
position: absolute;
top: 6px;
right: 6px;
width: 22px;
height: 22px;
border-radius: 50%;
background: var(--action-primary);
color: var(--text-on-accent);
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
box-shadow: var(--shadow-sm);
z-index: 2;
}
.ctv-media-card-body {
padding: 8px 10px 10px;
}
.ctv-media-card-title {
font: var(--weight-semibold) var(--text-xs) / 1.2 var(--font-sans);
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ctv-media-card-meta {
margin-top: 3px;
font: var(--text-2xs) / 1.2 var(--font-sans);
color: var(--text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ctv-media-section-header {
display: flex;
align-items: baseline;
gap: var(--space-4, 8px);
margin: var(--space-7, 16px) 0 var(--space-5, 10px);
}
.ctv-media-section-header h2 {
font: var(--weight-semibold) var(--text-md) / 1.2 var(--font-sans);
color: var(--text-primary);
margin: 0;
}
.ctv-media-section-count {
font: var(--text-xs) / 1 var(--font-sans);
color: var(--text-secondary);
}
/* lineup */
.ctv-builder-lineup-list {
display: flex;