Merge pull request 'feat(web+api): media browse, search & trash for SPA parity (#141)' (#168) from feat/141-media-browse into main
This commit was merged in pull request #168.
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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 />;
|
||||
}
|
||||
|
||||
Vendored
+21
-1
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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']));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user