merge main into feat/145-block-history (post-#181); regenerate OpenAPI artifacts
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -7,4 +7,5 @@ public record GetLibraryBrowseItems(
|
|||||||
int? LibraryId,
|
int? LibraryId,
|
||||||
LibraryBrowseMediaType? MediaType,
|
LibraryBrowseMediaType? MediaType,
|
||||||
int PageNum,
|
int PageNum,
|
||||||
int PageSize) : IRequest<PagedLibraryBrowseItemsResponseModel>;
|
int PageSize,
|
||||||
|
int? ParentId = null) : IRequest<PagedLibraryBrowseItemsResponseModel>;
|
||||||
|
|||||||
@@ -22,6 +22,14 @@ public class GetLibraryBrowseItemsHandler(
|
|||||||
GetLibraryBrowseItems request,
|
GetLibraryBrowseItems request,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
// Drill-in: seasons of a specific show. Bypasses Lucene and reads the show's seasons directly
|
||||||
|
// (in season-number order) so the SPA can expand a show tile into its seasons (issue #180).
|
||||||
|
if (request.ParentId.HasValue && request.MediaType == LibraryBrowseMediaType.TelevisionSeason)
|
||||||
|
{
|
||||||
|
await using TvContext seasonContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||||
|
return await BrowseSeasonsForShow(seasonContext, request, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
int offset = request.PageNum * request.PageSize;
|
int offset = request.PageNum * request.PageSize;
|
||||||
SearchResult mediaResult = await SearchMedia(request, offset, request.PageSize, cancellationToken);
|
SearchResult mediaResult = await SearchMedia(request, offset, request.PageSize, cancellationToken);
|
||||||
|
|
||||||
@@ -285,6 +293,36 @@ public class GetLibraryBrowseItemsHandler(
|
|||||||
null)).ToList());
|
null)).ToList());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static async Task<PagedLibraryBrowseItemsResponseModel> BrowseSeasonsForShow(
|
||||||
|
TvContext dbContext,
|
||||||
|
GetLibraryBrowseItems request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
List<int> allSeasonIds = await dbContext.Seasons
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(s => s.ShowId == request.ParentId.Value)
|
||||||
|
.OrderBy(s => s.SeasonNumber)
|
||||||
|
.Select(s => s.Id)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
int total = allSeasonIds.Count;
|
||||||
|
List<int> pageIds = allSeasonIds
|
||||||
|
.Skip(request.PageNum * request.PageSize)
|
||||||
|
.Take(request.PageSize)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
List<LibraryBrowseItemResponseModel> seasons = await GetSeasons(dbContext, pageIds, cancellationToken);
|
||||||
|
|
||||||
|
// GetSeasons groups by season id, so restore the requested season-number order.
|
||||||
|
Dictionary<int, LibraryBrowseItemResponseModel> byId = seasons.ToDictionary(s => s.Id);
|
||||||
|
List<LibraryBrowseItemResponseModel> ordered = pageIds
|
||||||
|
.Where(byId.ContainsKey)
|
||||||
|
.Select(id => byId[id])
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return new PagedLibraryBrowseItemsResponseModel(total, ordered);
|
||||||
|
}
|
||||||
|
|
||||||
private static async Task<List<LibraryBrowseItemResponseModel>> GetSeasons(
|
private static async Task<List<LibraryBrowseItemResponseModel>> GetSeasons(
|
||||||
TvContext dbContext,
|
TvContext dbContext,
|
||||||
List<int> ids,
|
List<int> ids,
|
||||||
@@ -309,6 +347,7 @@ public class GetLibraryBrowseItemsHandler(
|
|||||||
.Include(sm => sm.Season)
|
.Include(sm => sm.Season)
|
||||||
.ThenInclude(s => s.Show)
|
.ThenInclude(s => s.Show)
|
||||||
.ThenInclude(s => s.ShowMetadata)
|
.ThenInclude(s => s.ShowMetadata)
|
||||||
|
.ThenInclude(shm => shm.Artwork)
|
||||||
.Include(sm => sm.Season)
|
.Include(sm => sm.Season)
|
||||||
.ThenInclude(s => s.LibraryPath)
|
.ThenInclude(s => s.LibraryPath)
|
||||||
.ThenInclude(lp => lp.Library)
|
.ThenInclude(lp => lp.Library)
|
||||||
@@ -322,7 +361,7 @@ public class GetLibraryBrowseItemsHandler(
|
|||||||
SeasonTitle(sm),
|
SeasonTitle(sm),
|
||||||
sm.Season.LibraryPath.LibraryId,
|
sm.Season.LibraryPath.LibraryId,
|
||||||
sm.Season.LibraryPath.Library.Name,
|
sm.Season.LibraryPath.Library.Name,
|
||||||
Artwork(sm, ArtworkKind.Poster),
|
SeasonArtwork(sm),
|
||||||
null,
|
null,
|
||||||
counts.TryGetValue(sm.SeasonId, out int count) ? count : 0,
|
counts.TryGetValue(sm.SeasonId, out int count) ? count : 0,
|
||||||
null,
|
null,
|
||||||
@@ -1266,17 +1305,47 @@ public class GetLibraryBrowseItemsHandler(
|
|||||||
return string.IsNullOrWhiteSpace(showTitle) ? seasonTitle : $"{showTitle} - {seasonTitle}";
|
return string.IsNullOrWhiteSpace(showTitle) ? seasonTitle : $"{showTitle} - {seasonTitle}";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Seasons often have no poster of their own; fall back to the parent show's poster (issue #180).
|
||||||
|
private static string SeasonArtwork(SeasonMetadata metadata)
|
||||||
|
{
|
||||||
|
string artwork = Artwork(metadata, ArtworkKind.Poster);
|
||||||
|
if (!string.IsNullOrWhiteSpace(artwork))
|
||||||
|
{
|
||||||
|
return artwork;
|
||||||
|
}
|
||||||
|
|
||||||
|
return metadata.Season.Show.ShowMetadata.HeadOrNone()
|
||||||
|
.Match(sm => Artwork(sm, ArtworkKind.Poster), string.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
private static string ArtworkWithFallback(Metadata metadata, ArtworkKind primary, ArtworkKind fallback)
|
private static string ArtworkWithFallback(Metadata metadata, ArtworkKind primary, ArtworkKind fallback)
|
||||||
{
|
{
|
||||||
string artwork = Artwork(metadata, primary);
|
string artwork = Artwork(metadata, primary);
|
||||||
return string.IsNullOrWhiteSpace(artwork) ? Artwork(metadata, fallback) : artwork;
|
return string.IsNullOrWhiteSpace(artwork) ? Artwork(metadata, fallback) : artwork;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Returns a rooted, directly-usable artwork URL for the SPA's <img src>. Blazor pages rely on
|
||||||
|
// GetPosterUrl to prefix "artwork/posters/" and resolve relative to <base href="/">, but the SPA
|
||||||
|
// renders the value raw from under /app/, so the API must root the URL itself (issue #180).
|
||||||
private static string Artwork(Metadata metadata, ArtworkKind artworkKind)
|
private static string Artwork(Metadata metadata, ArtworkKind artworkKind)
|
||||||
{
|
{
|
||||||
string artwork = Optional(metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == artworkKind))
|
string artwork = Optional(metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == artworkKind))
|
||||||
.Match(a => a.Path, string.Empty);
|
.Match(a => a.Path, string.Empty);
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(artwork))
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Absolute URLs are already usable as-is (matches Blazor's GetPosterUrl guard).
|
||||||
|
if (artwork.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
artwork.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return artwork;
|
||||||
|
}
|
||||||
|
|
||||||
|
string folder = artworkKind is ArtworkKind.Thumbnail ? "thumbnails" : "posters";
|
||||||
|
|
||||||
if (artwork.StartsWith("jellyfin://", StringComparison.OrdinalIgnoreCase))
|
if (artwork.StartsWith("jellyfin://", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
Url url = JellyfinUrl.RelativeProxyForArtwork(artwork);
|
Url url = JellyfinUrl.RelativeProxyForArtwork(artwork);
|
||||||
@@ -1285,7 +1354,7 @@ public class GetLibraryBrowseItemsHandler(
|
|||||||
url.SetQueryParam("fillHeight", 440);
|
url.SetQueryParam("fillHeight", 440);
|
||||||
}
|
}
|
||||||
|
|
||||||
return url;
|
return $"/artwork/{folder}/{url}";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (artwork.StartsWith("emby://", StringComparison.OrdinalIgnoreCase))
|
if (artwork.StartsWith("emby://", StringComparison.OrdinalIgnoreCase))
|
||||||
@@ -1296,10 +1365,10 @@ public class GetLibraryBrowseItemsHandler(
|
|||||||
url.SetQueryParam("maxHeight", 440);
|
url.SetQueryParam("maxHeight", 440);
|
||||||
}
|
}
|
||||||
|
|
||||||
return url;
|
return $"/artwork/{folder}/{url}";
|
||||||
}
|
}
|
||||||
|
|
||||||
return artwork;
|
return $"/artwork/{folder}/{artwork}";
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string EscapeLike(string searchQuery) =>
|
private static string EscapeLike(string searchQuery) =>
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ public class GetLibraryBrowseItemsHandlerTests
|
|||||||
result.Page[0].MediaItemId.ShouldBe(20);
|
result.Page[0].MediaItemId.ShouldBe(20);
|
||||||
result.Page[1].MediaType.ShouldBe(LibraryBrowseMediaType.Movie);
|
result.Page[1].MediaType.ShouldBe(LibraryBrowseMediaType.Movie);
|
||||||
result.Page[1].Title.ShouldBe("Collision Movie");
|
result.Page[1].Title.ShouldBe("Collision Movie");
|
||||||
result.Page[1].Artwork.ShouldBe("movie-poster.jpg");
|
result.Page[1].Artwork.ShouldBe("/artwork/posters/movie-poster.jpg");
|
||||||
result.Page[1].Duration.ShouldBe(TimeSpan.FromMinutes(95));
|
result.Page[1].Duration.ShouldBe(TimeSpan.FromMinutes(95));
|
||||||
result.Page[1].MediaItemId.ShouldBe(10);
|
result.Page[1].MediaItemId.ShouldBe(10);
|
||||||
}
|
}
|
||||||
@@ -203,7 +203,7 @@ public class GetLibraryBrowseItemsHandlerTests
|
|||||||
result.TotalCount.ShouldBe(1);
|
result.TotalCount.ShouldBe(1);
|
||||||
result.Page.Count.ShouldBe(1);
|
result.Page.Count.ShouldBe(1);
|
||||||
result.Page[0].Title.ShouldBe("Manual Metadata");
|
result.Page[0].Title.ShouldBe("Manual Metadata");
|
||||||
result.Page[0].Artwork.ShouldBe("first-poster.jpg");
|
result.Page[0].Artwork.ShouldBe("/artwork/posters/first-poster.jpg");
|
||||||
result.Page[0].Duration.ShouldBe(TimeSpan.FromMinutes(75));
|
result.Page[0].Duration.ShouldBe(TimeSpan.FromMinutes(75));
|
||||||
result.Page[0].ItemCount.ShouldBe(2);
|
result.Page[0].ItemCount.ShouldBe(2);
|
||||||
}
|
}
|
||||||
@@ -236,9 +236,62 @@ public class GetLibraryBrowseItemsHandlerTests
|
|||||||
result.Page[0].ItemCount.ShouldBe(2);
|
result.Page[0].ItemCount.ShouldBe(2);
|
||||||
result.Page[0].CollectionType.ShouldBe(CollectionType.TelevisionSeason);
|
result.Page[0].CollectionType.ShouldBe(CollectionType.TelevisionSeason);
|
||||||
result.Page[0].MediaItemId.ShouldBe(301);
|
result.Page[0].MediaItemId.ShouldBe(301);
|
||||||
|
// Season 301 has its own poster.
|
||||||
|
result.Page[0].Artwork.ShouldBe("/artwork/posters/season-2.jpg");
|
||||||
result.Page[1].Title.ShouldBe("Season Show - Specials");
|
result.Page[1].Title.ShouldBe("Season Show - Specials");
|
||||||
result.Page[1].ItemCount.ShouldBe(1);
|
result.Page[1].ItemCount.ShouldBe(1);
|
||||||
result.Page[1].MediaItemId.ShouldBe(302);
|
result.Page[1].MediaItemId.ShouldBe(302);
|
||||||
|
// Specials has no poster of its own, so it falls back to the show's poster.
|
||||||
|
result.Page[1].Artwork.ShouldBe("/artwork/posters/show.jpg");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Handle_Should_Browse_Seasons_For_A_Specific_Show_By_ParentId()
|
||||||
|
{
|
||||||
|
await SeedSeasonGraph();
|
||||||
|
// ParentId drill-in bypasses the search index entirely.
|
||||||
|
var handler = new GetLibraryBrowseItemsHandler(_searchIndex, _db.Factory);
|
||||||
|
|
||||||
|
PagedLibraryBrowseItemsResponseModel result = await handler.Handle(
|
||||||
|
new GetLibraryBrowseItems("", null, LibraryBrowseMediaType.TelevisionSeason, 0, 10, 300),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
result.TotalCount.ShouldBe(2);
|
||||||
|
// Ordered by season number: Specials (0) then Season 2.
|
||||||
|
result.Page[0].Title.ShouldBe("Season Show - Specials");
|
||||||
|
result.Page[0].MediaItemId.ShouldBe(302);
|
||||||
|
result.Page[1].Title.ShouldBe("Season Show - Season 2");
|
||||||
|
result.Page[1].MediaItemId.ShouldBe(301);
|
||||||
|
|
||||||
|
await _searchIndex.DidNotReceive().Search(
|
||||||
|
Arg.Any<string>(),
|
||||||
|
Arg.Any<string>(),
|
||||||
|
Arg.Any<int>(),
|
||||||
|
Arg.Any<int>(),
|
||||||
|
Arg.Any<List<string>>(),
|
||||||
|
Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Handle_Should_Root_Jellyfin_Artwork_Urls_With_FillHeight()
|
||||||
|
{
|
||||||
|
await SeedJellyfinMovieGraph();
|
||||||
|
_searchIndex.Search(
|
||||||
|
Arg.Any<string>(),
|
||||||
|
Arg.Any<string>(),
|
||||||
|
Arg.Any<int>(),
|
||||||
|
Arg.Any<int>(),
|
||||||
|
Arg.Any<List<string>>(),
|
||||||
|
Arg.Any<CancellationToken>())
|
||||||
|
.Returns(new SearchResult([new SearchItem(LuceneSearchIndex.MovieType, 601)], 1));
|
||||||
|
var handler = new GetLibraryBrowseItemsHandler(_searchIndex, _db.Factory);
|
||||||
|
|
||||||
|
PagedLibraryBrowseItemsResponseModel result = await handler.Handle(
|
||||||
|
new GetLibraryBrowseItems("", null, LibraryBrowseMediaType.Movie, 0, 10),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
result.TotalCount.ShouldBe(1);
|
||||||
|
result.Page[0].Artwork.ShouldBe("/artwork/posters/jellyfin/Items/abc/Images/Primary?tag=xyz&fillHeight=440");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
@@ -262,7 +315,7 @@ public class GetLibraryBrowseItemsHandlerTests
|
|||||||
result.TotalCount.ShouldBe(1);
|
result.TotalCount.ShouldBe(1);
|
||||||
result.Page[0].Title.ShouldBe("Counted Artist");
|
result.Page[0].Title.ShouldBe("Counted Artist");
|
||||||
result.Page[0].ItemCount.ShouldBe(2);
|
result.Page[0].ItemCount.ShouldBe(2);
|
||||||
result.Page[0].Artwork.ShouldBe("artist-thumb.jpg");
|
result.Page[0].Artwork.ShouldBe("/artwork/thumbnails/artist-thumb.jpg");
|
||||||
result.Page[0].CollectionType.ShouldBe(CollectionType.Artist);
|
result.Page[0].CollectionType.ShouldBe(CollectionType.Artist);
|
||||||
result.Page[0].MediaItemId.ShouldBe(401);
|
result.Page[0].MediaItemId.ShouldBe(401);
|
||||||
}
|
}
|
||||||
@@ -456,8 +509,8 @@ public class GetLibraryBrowseItemsHandlerTests
|
|||||||
Seasons = [],
|
Seasons = [],
|
||||||
ShowMetadata = [MakeShowMetadata("Season Show", "show.jpg")]
|
ShowMetadata = [MakeShowMetadata("Season Show", "show.jpg")]
|
||||||
};
|
};
|
||||||
var season = MakeSeason(301, path, show, 2);
|
var season = MakeSeason(301, path, show, 2, "season-2.jpg");
|
||||||
var specials = MakeSeason(302, path, show, 0);
|
var specials = MakeSeason(302, path, show, 0, string.Empty);
|
||||||
season.Episodes.AddRange([
|
season.Episodes.AddRange([
|
||||||
MakeEpisode(311, path, season),
|
MakeEpisode(311, path, season),
|
||||||
MakeEpisode(312, path, season)
|
MakeEpisode(312, path, season)
|
||||||
@@ -497,6 +550,50 @@ public class GetLibraryBrowseItemsHandlerTests
|
|||||||
await context.SaveChangesAsync();
|
await context.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task SeedJellyfinMovieGraph()
|
||||||
|
{
|
||||||
|
await using TvContext context = _db.CreateContext();
|
||||||
|
(LocalLibrary library, LibraryPath path) = MakeLibrary(600, "Jellyfin Library");
|
||||||
|
var movie = new Movie
|
||||||
|
{
|
||||||
|
Id = 601,
|
||||||
|
LibraryPath = path,
|
||||||
|
Collections = [],
|
||||||
|
CollectionItems = [],
|
||||||
|
TraktListItems = [],
|
||||||
|
MediaVersions = [new MediaVersion { Duration = TimeSpan.FromMinutes(100) }],
|
||||||
|
MovieMetadata =
|
||||||
|
[
|
||||||
|
new MovieMetadata
|
||||||
|
{
|
||||||
|
Title = "Jellyfin Movie",
|
||||||
|
SortTitle = "Jellyfin Movie",
|
||||||
|
Artwork =
|
||||||
|
[
|
||||||
|
new Artwork
|
||||||
|
{
|
||||||
|
Path = "jellyfin://Items/abc/Images/Primary?tag=xyz",
|
||||||
|
ArtworkKind = ArtworkKind.Poster
|
||||||
|
}
|
||||||
|
],
|
||||||
|
Genres = [],
|
||||||
|
Tags = [],
|
||||||
|
Studios = [],
|
||||||
|
Actors = [],
|
||||||
|
Guids = [],
|
||||||
|
Subtitles = [],
|
||||||
|
Directors = [],
|
||||||
|
Writers = []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
path.MediaItems.Add(movie);
|
||||||
|
|
||||||
|
context.LocalLibraries.Add(library);
|
||||||
|
context.Movies.Add(movie);
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
private async Task SeedLibraryFilteredCollectionsGraph()
|
private async Task SeedLibraryFilteredCollectionsGraph()
|
||||||
{
|
{
|
||||||
await using TvContext context = _db.CreateContext();
|
await using TvContext context = _db.CreateContext();
|
||||||
@@ -563,7 +660,7 @@ public class GetLibraryBrowseItemsHandlerTests
|
|||||||
MediaVersions = [new MediaVersion { Duration = duration }]
|
MediaVersions = [new MediaVersion { Duration = duration }]
|
||||||
};
|
};
|
||||||
|
|
||||||
private static Season MakeSeason(int id, LibraryPath path, Show show, int seasonNumber) =>
|
private static Season MakeSeason(int id, LibraryPath path, Show show, int seasonNumber, string poster) =>
|
||||||
new()
|
new()
|
||||||
{
|
{
|
||||||
Id = id,
|
Id = id,
|
||||||
@@ -574,7 +671,7 @@ public class GetLibraryBrowseItemsHandlerTests
|
|||||||
CollectionItems = [],
|
CollectionItems = [],
|
||||||
TraktListItems = [],
|
TraktListItems = [],
|
||||||
Episodes = [],
|
Episodes = [],
|
||||||
SeasonMetadata = [MakeSeasonMetadata()]
|
SeasonMetadata = [MakeSeasonMetadata(poster)]
|
||||||
};
|
};
|
||||||
|
|
||||||
private static Episode MakeEpisode(int id, LibraryPath path, Season season) =>
|
private static Episode MakeEpisode(int id, LibraryPath path, Season season) =>
|
||||||
@@ -633,12 +730,14 @@ public class GetLibraryBrowseItemsHandlerTests
|
|||||||
Subtitles = []
|
Subtitles = []
|
||||||
};
|
};
|
||||||
|
|
||||||
private static SeasonMetadata MakeSeasonMetadata() =>
|
private static SeasonMetadata MakeSeasonMetadata(string poster = "") =>
|
||||||
new()
|
new()
|
||||||
{
|
{
|
||||||
Title = string.Empty,
|
Title = string.Empty,
|
||||||
SortTitle = string.Empty,
|
SortTitle = string.Empty,
|
||||||
Artwork = [],
|
Artwork = string.IsNullOrEmpty(poster)
|
||||||
|
? []
|
||||||
|
: [new Artwork { Path = poster, ArtworkKind = ArtworkKind.Poster }],
|
||||||
Genres = [],
|
Genres = [],
|
||||||
Tags = [],
|
Tags = [],
|
||||||
Studios = [],
|
Studios = [],
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ public class LibraryBrowseControllerTests
|
|||||||
LibraryBrowseMediaType.Movie,
|
LibraryBrowseMediaType.Movie,
|
||||||
-1,
|
-1,
|
||||||
500,
|
500,
|
||||||
|
77,
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
|
|
||||||
await _mediator.Received(1).Send(
|
await _mediator.Received(1).Send(
|
||||||
@@ -56,7 +57,8 @@ public class LibraryBrowseControllerTests
|
|||||||
q.LibraryId == 123 &&
|
q.LibraryId == 123 &&
|
||||||
q.MediaType == LibraryBrowseMediaType.Movie &&
|
q.MediaType == LibraryBrowseMediaType.Movie &&
|
||||||
q.PageNum == 0 &&
|
q.PageNum == 0 &&
|
||||||
q.PageSize == 100),
|
q.PageSize == 100 &&
|
||||||
|
q.ParentId == 77),
|
||||||
Arg.Any<CancellationToken>());
|
Arg.Any<CancellationToken>());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.ComponentModel;
|
||||||
using ErsatzTV.Application.LibraryBrowse;
|
using ErsatzTV.Application.LibraryBrowse;
|
||||||
using ErsatzTV.Core.Api.LibraryBrowse;
|
using ErsatzTV.Core.Api.LibraryBrowse;
|
||||||
using MediatR;
|
using MediatR;
|
||||||
@@ -22,12 +23,15 @@ public class LibraryBrowseController(IMediator mediator) : ControllerBase
|
|||||||
[FromQuery] LibraryBrowseMediaType? mediaType = null,
|
[FromQuery] LibraryBrowseMediaType? mediaType = null,
|
||||||
[FromQuery] int pageNum = 0,
|
[FromQuery] int pageNum = 0,
|
||||||
[FromQuery] int pageSize = 100,
|
[FromQuery] int pageSize = 100,
|
||||||
|
[FromQuery]
|
||||||
|
[Description("Parent television show id; only used with mediaType=TelevisionSeason (lists that show's seasons), ignored otherwise")]
|
||||||
|
int? parentId = null,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
int clampedPageNum = Math.Max(0, pageNum);
|
int clampedPageNum = Math.Max(0, pageNum);
|
||||||
int clampedPageSize = Math.Clamp(pageSize, 1, MaxPageSize);
|
int clampedPageSize = Math.Clamp(pageSize, 1, MaxPageSize);
|
||||||
return await mediator.Send(
|
return await mediator.Send(
|
||||||
new GetLibraryBrowseItems(query, libraryId, mediaType, clampedPageNum, clampedPageSize),
|
new GetLibraryBrowseItems(query, libraryId, mediaType, clampedPageNum, clampedPageSize, parentId),
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4968,6 +4968,15 @@
|
|||||||
"format": "int32",
|
"format": "int32",
|
||||||
"default": 100
|
"default": 100
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "parentId",
|
||||||
|
"in": "query",
|
||||||
|
"description": "Parent television show id; only used with mediaType=TelevisionSeason (lists that show's seasons), ignored otherwise",
|
||||||
|
"schema": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { getLibraryBrowseItems } from './libraryBrowse';
|
||||||
|
|
||||||
|
function jsonResponse(body: unknown, status = 200): Response {
|
||||||
|
return new Response(JSON.stringify(body), {
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
status
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function browseUrl(fetchMock: ReturnType<typeof vi.spyOn>): URL {
|
||||||
|
return new URL(String(fetchMock.mock.calls[0][0]), 'http://localhost');
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('getLibraryBrowseItems', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps the paging/library/mediaType params into the query string', async () => {
|
||||||
|
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({ page: [], totalCount: 0 }));
|
||||||
|
|
||||||
|
await getLibraryBrowseItems({
|
||||||
|
query: 'star',
|
||||||
|
libraryId: 5,
|
||||||
|
mediaType: 'TelevisionShow',
|
||||||
|
pageNum: 2,
|
||||||
|
pageSize: 25
|
||||||
|
});
|
||||||
|
|
||||||
|
const url = browseUrl(fetchMock);
|
||||||
|
expect(url.pathname).toBe('/api/library/browse');
|
||||||
|
expect(url.searchParams.get('query')).toBe('star');
|
||||||
|
expect(url.searchParams.get('libraryId')).toBe('5');
|
||||||
|
expect(url.searchParams.get('mediaType')).toBe('TelevisionShow');
|
||||||
|
expect(url.searchParams.get('pageNum')).toBe('2');
|
||||||
|
expect(url.searchParams.get('pageSize')).toBe('25');
|
||||||
|
expect(url.searchParams.has('parentId')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends parentId to scope seasons to a specific show', async () => {
|
||||||
|
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({ page: [], totalCount: 0 }));
|
||||||
|
|
||||||
|
await getLibraryBrowseItems({ mediaType: 'TelevisionSeason', parentId: 42, pageSize: 100 });
|
||||||
|
|
||||||
|
const url = browseUrl(fetchMock);
|
||||||
|
expect(url.searchParams.get('mediaType')).toBe('TelevisionSeason');
|
||||||
|
expect(url.searchParams.get('parentId')).toBe('42');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits parentId when it is not provided', async () => {
|
||||||
|
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({ page: [], totalCount: 0 }));
|
||||||
|
|
||||||
|
await getLibraryBrowseItems({ mediaType: 'Movie' });
|
||||||
|
|
||||||
|
expect(browseUrl(fetchMock).searchParams.has('parentId')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -10,6 +10,8 @@ export interface GetLibraryBrowseItemsParams {
|
|||||||
mediaType?: LibraryBrowseMediaType;
|
mediaType?: LibraryBrowseMediaType;
|
||||||
pageNum?: number;
|
pageNum?: number;
|
||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
|
// Only meaningful with mediaType 'TelevisionSeason': filters seasons to the given show id.
|
||||||
|
parentId?: number;
|
||||||
query?: string;
|
query?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,6 +38,10 @@ export function getLibraryBrowseItems(params: GetLibraryBrowseItemsParams = {}):
|
|||||||
searchParams.set('pageSize', String(params.pageSize));
|
searchParams.set('pageSize', String(params.pageSize));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (params.parentId != null) {
|
||||||
|
searchParams.set('parentId', String(params.parentId));
|
||||||
|
}
|
||||||
|
|
||||||
const queryString = searchParams.toString();
|
const queryString = searchParams.toString();
|
||||||
|
|
||||||
return request<PagedLibraryBrowseItems>(`/api/library/browse${queryString ? `?${queryString}` : ''}`);
|
return request<PagedLibraryBrowseItems>(`/api/library/browse${queryString ? `?${queryString}` : ''}`);
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { cleanup, render, waitFor } from '@testing-library/react';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { ChannelBuilderScreen } from './ChannelBuilder';
|
||||||
|
|
||||||
|
function jsonResponse(body: unknown, status = 200): Response {
|
||||||
|
return new Response(JSON.stringify(body), {
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
status
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mocks every endpoint the builder loads on mount so it can render its library
|
||||||
|
// browser, plus /api/library/browse which the browse hook fans out across.
|
||||||
|
function mockBuilderApi() {
|
||||||
|
return vi.spyOn(window, 'fetch').mockImplementation((input: RequestInfo | URL) => {
|
||||||
|
const url = input.toString();
|
||||||
|
|
||||||
|
if (url.startsWith('/api/library/browse')) {
|
||||||
|
return Promise.resolve(jsonResponse({ page: [], totalCount: 0 }));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url === '/api/channel-templates/default') {
|
||||||
|
// Builder treats a 404 here as "no default template".
|
||||||
|
return Promise.resolve(jsonResponse({ status: 404, title: 'Not Found' }, 404));
|
||||||
|
}
|
||||||
|
|
||||||
|
// channels, channel-templates, ffmpeg/profiles, filler-presets, watermarks, media-sources
|
||||||
|
return Promise.resolve(jsonResponse([]));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function browseMediaTypes(fetchMock: ReturnType<typeof mockBuilderApi>): string[] {
|
||||||
|
return 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 && mediaType !== '');
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ChannelBuilder library browse', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
window.localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fans out over movies/shows/artists and never requests TelevisionSeason', async () => {
|
||||||
|
const fetchMock = mockBuilderApi();
|
||||||
|
|
||||||
|
render(<ChannelBuilderScreen />);
|
||||||
|
|
||||||
|
// Wait until the initial library browse fan-out has fired.
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(browseMediaTypes(fetchMock).length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
const types = new Set(browseMediaTypes(fetchMock));
|
||||||
|
expect(types).toEqual(new Set(['Movie', 'TelevisionShow', 'Artist']));
|
||||||
|
expect(types.has('TelevisionSeason')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -140,14 +140,15 @@ const COLLECTION_MEDIA_TYPES: LibraryBrowseMediaType[] = [
|
|||||||
'Playlist'
|
'Playlist'
|
||||||
];
|
];
|
||||||
|
|
||||||
// The pre-#168 API default: browsing a library shows the "pickable" top-level
|
// Browsing a library shows the "pickable" top-level kinds only, not every
|
||||||
// kinds only (shows/seasons/movies/artists), not every episode/song/etc.
|
// episode/song/etc. nested underneath them. Kept explicit here since
|
||||||
// nested underneath them. Kept explicit here since `GET /api/library/browse`
|
// `GET /api/library/browse` now spans all 10 media kinds when `mediaType` is
|
||||||
// now spans all 10 media kinds when `mediaType` is omitted.
|
// omitted. TelevisionSeason is intentionally excluded so a multi-season show
|
||||||
|
// renders as a single tile instead of flooding the grid with per-season tiles
|
||||||
|
// (issue #180); seasons are reachable via the show tile's Seasons drill-in.
|
||||||
const LIBRARY_MEDIA_TYPES: LibraryBrowseMediaType[] = [
|
const LIBRARY_MEDIA_TYPES: LibraryBrowseMediaType[] = [
|
||||||
'Movie',
|
'Movie',
|
||||||
'TelevisionShow',
|
'TelevisionShow',
|
||||||
'TelevisionSeason',
|
|
||||||
'Artist'
|
'Artist'
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -326,6 +327,7 @@ function LibCard({
|
|||||||
added,
|
added,
|
||||||
compact,
|
compact,
|
||||||
onAdd,
|
onAdd,
|
||||||
|
onSeasons,
|
||||||
onDragStart,
|
onDragStart,
|
||||||
onDragEnd
|
onDragEnd
|
||||||
}: {
|
}: {
|
||||||
@@ -333,6 +335,7 @@ function LibCard({
|
|||||||
added: boolean;
|
added: boolean;
|
||||||
compact: boolean;
|
compact: boolean;
|
||||||
onAdd: () => void;
|
onAdd: () => void;
|
||||||
|
onSeasons?: () => void;
|
||||||
onDragStart: () => void;
|
onDragStart: () => void;
|
||||||
onDragEnd: () => void;
|
onDragEnd: () => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -369,6 +372,18 @@ function LibCard({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{onSeasons && (
|
||||||
|
<IconButton
|
||||||
|
size="sm"
|
||||||
|
title="Browse seasons"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onSeasons();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FolderTree size={15} aria-hidden="true" />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
<IconButton size="sm" title={added ? 'Added' : 'Add to lineup'} onClick={onAdd}>
|
<IconButton size="sm" title={added ? 'Added' : 'Add to lineup'} onClick={onAdd}>
|
||||||
{added ? (
|
{added ? (
|
||||||
<Check size={15} color="var(--action-primary)" aria-hidden="true" />
|
<Check size={15} color="var(--action-primary)" aria-hidden="true" />
|
||||||
@@ -399,11 +414,111 @@ function LibCard({
|
|||||||
<Check size={14} aria-hidden="true" />
|
<Check size={14} aria-hidden="true" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{onSeasons && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ctv-builder-seasons-btn ctv-press"
|
||||||
|
title="Browse seasons"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onSeasons();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FolderTree size={13} aria-hidden="true" />
|
||||||
|
Seasons
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Seasons drill-in ------------------------------------------------------
|
||||||
|
// Expands a TelevisionShow tile into its seasons so a specific season can be
|
||||||
|
// added to the lineup without flooding the main grid with per-season tiles
|
||||||
|
// (issue #180). Mounted fresh per show (keyed on show id) so initial state is
|
||||||
|
// 'loading' and the fetch effect only ever calls setState in its callbacks.
|
||||||
|
function SeasonsDialog({
|
||||||
|
show,
|
||||||
|
addedKeys,
|
||||||
|
onAdd,
|
||||||
|
onClose
|
||||||
|
}: {
|
||||||
|
show: LibraryBrowseItem;
|
||||||
|
addedKeys: Set<string>;
|
||||||
|
onAdd: (item: LibraryBrowseItem) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const [seasons, setSeasons] = useState<LibraryBrowseItem[]>([]);
|
||||||
|
const [status, setStatus] = useState<'loading' | 'error' | 'success'>('loading');
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
getLibraryBrowseItems({ mediaType: 'TelevisionSeason', parentId: show.id, pageSize: 100 })
|
||||||
|
.then((result) => {
|
||||||
|
if (active) {
|
||||||
|
setSeasons(result.page ?? []);
|
||||||
|
setStatus('success');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((loadError: unknown) => {
|
||||||
|
if (active) {
|
||||||
|
setError(messageFromError(loadError));
|
||||||
|
setStatus('error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
};
|
||||||
|
}, [show.id]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open
|
||||||
|
onClose={onClose}
|
||||||
|
title={`Seasons — ${show.title}`}
|
||||||
|
width={520}
|
||||||
|
footer={
|
||||||
|
<Button variant="secondary" onClick={onClose}>
|
||||||
|
Done
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{status === 'loading' ? (
|
||||||
|
<div className="ctv-builder-empty">
|
||||||
|
<Spinner size={18} tone="accent" />
|
||||||
|
</div>
|
||||||
|
) : status === 'error' ? (
|
||||||
|
<div className="ctv-builder-empty" role="alert">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
) : seasons.length === 0 ? (
|
||||||
|
<div className="ctv-builder-empty">This show has no seasons.</div>
|
||||||
|
) : (
|
||||||
|
<div className="ctv-builder-seasons-list">
|
||||||
|
{seasons.map((season) => {
|
||||||
|
const added = addedKeys.has(lineupKey(season));
|
||||||
|
return (
|
||||||
|
<div key={lineupKey(season)} className="ctv-builder-seasons-row">
|
||||||
|
<Poster item={season} width={40} height={54} mini />
|
||||||
|
<div className="ctv-builder-seasons-row-title">{season.title}</div>
|
||||||
|
<IconButton size="sm" title={added ? 'Added' : 'Add to lineup'} onClick={() => onAdd(season)}>
|
||||||
|
{added ? (
|
||||||
|
<Check size={15} color="var(--action-primary)" aria-hidden="true" />
|
||||||
|
) : (
|
||||||
|
<Plus size={15} aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Library browse data hook ---------------------------------------------
|
// ---- Library browse data hook ---------------------------------------------
|
||||||
interface BrowseState {
|
interface BrowseState {
|
||||||
status: 'loading' | 'error' | 'success';
|
status: 'loading' | 'error' | 'success';
|
||||||
@@ -422,7 +537,7 @@ async function loadCollections(query: string): Promise<{ page: LibraryBrowseItem
|
|||||||
return { page: merged, totalCount: merged.length };
|
return { page: merged, totalCount: merged.length };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fan out across the four pickable library kinds (movies/shows/seasons/artists)
|
// Fan out across the pickable top-level library kinds (movies/shows/artists)
|
||||||
// instead of the unscoped browse, which now also returns episodes/songs/etc.
|
// instead of the unscoped browse, which now also returns episodes/songs/etc.
|
||||||
// Mirrors loadCollections's per-kind Promise.all pattern, but preserves real
|
// Mirrors loadCollections's per-kind Promise.all pattern, but preserves real
|
||||||
// paging (each kind keeps its own pageNum/totalCount, summed across kinds) so
|
// paging (each kind keeps its own pageNum/totalCount, summed across kinds) so
|
||||||
@@ -674,6 +789,9 @@ function ChannelBuilder({
|
|||||||
const [compact, setCompact] = useState(false);
|
const [compact, setCompact] = useState(false);
|
||||||
const [libraryId, setLibraryId] = useState<number | null>(null);
|
const [libraryId, setLibraryId] = useState<number | null>(null);
|
||||||
|
|
||||||
|
// Show whose seasons are being browsed in the drill-in dialog (issue #180).
|
||||||
|
const [seasonsShow, setSeasonsShow] = useState<LibraryBrowseItem | null>(null);
|
||||||
|
|
||||||
// debounce search input -> query
|
// debounce search input -> query
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = window.setTimeout(() => setQuery(searchInput.trim()), 280);
|
const timer = window.setTimeout(() => setQuery(searchInput.trim()), 280);
|
||||||
@@ -1144,6 +1262,9 @@ function ChannelBuilder({
|
|||||||
compact={compact}
|
compact={compact}
|
||||||
added={addedKeys.has(lineupKey(item))}
|
added={addedKeys.has(lineupKey(item))}
|
||||||
onAdd={() => addItem(item)}
|
onAdd={() => addItem(item)}
|
||||||
|
onSeasons={
|
||||||
|
item.mediaType === 'TelevisionShow' ? () => setSeasonsShow(item) : undefined
|
||||||
|
}
|
||||||
onDragStart={() => setDragLib(lineupKey(item))}
|
onDragStart={() => setDragLib(lineupKey(item))}
|
||||||
onDragEnd={() => {
|
onDragEnd={() => {
|
||||||
if (dragLib != null) {
|
if (dragLib != null) {
|
||||||
@@ -1571,6 +1692,16 @@ function ChannelBuilder({
|
|||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{seasonsShow && (
|
||||||
|
<SeasonsDialog
|
||||||
|
key={seasonsShow.id}
|
||||||
|
show={seasonsShow}
|
||||||
|
addedKeys={addedKeys}
|
||||||
|
onAdd={addItem}
|
||||||
|
onClose={() => setSeasonsShow(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={confirmClear}
|
open={confirmClear}
|
||||||
title="Clear the lineup?"
|
title="Clear the lineup?"
|
||||||
|
|||||||
@@ -266,15 +266,15 @@ describe('CollectionsScreen', () => {
|
|||||||
expect(new URL(String(browseCall?.[0]), 'http://localhost').searchParams.get('query')).toBe('collection:"Favorites"');
|
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 addItemsByType: Record<string, { id: number; mediaType: string; title: string }> = {
|
||||||
const byType: Record<string, { id: number; mediaType: string; title: string }> = {
|
Movie: { id: 1, mediaType: 'Movie', title: 'Zathura' },
|
||||||
Movie: { id: 1, mediaType: 'Movie', title: 'Zathura' },
|
TelevisionShow: { id: 2, mediaType: 'TelevisionShow', title: 'Adventure Time' },
|
||||||
TelevisionShow: { id: 2, mediaType: 'TelevisionShow', title: 'Adventure Time' },
|
TelevisionSeason: { id: 3, mediaType: 'TelevisionSeason', title: 'Melon Season 1' },
|
||||||
TelevisionSeason: { id: 3, mediaType: 'TelevisionSeason', title: 'Melon Season 1' },
|
Artist: { id: 4, mediaType: 'Artist', title: 'Between Movie and Show' }
|
||||||
Artist: { id: 4, mediaType: 'Artist', title: 'Between Movie and Show' }
|
};
|
||||||
};
|
|
||||||
|
|
||||||
const fetchMock = mockApi({
|
function mockAddItemsApi() {
|
||||||
|
return mockApi({
|
||||||
onRequest: (url) => {
|
onRequest: (url) => {
|
||||||
if (url.startsWith('/api/library/browse')) {
|
if (url.startsWith('/api/library/browse')) {
|
||||||
const params = new URL(url, 'http://localhost').searchParams;
|
const params = new URL(url, 'http://localhost').searchParams;
|
||||||
@@ -287,14 +287,16 @@ describe('CollectionsScreen', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const mediaType = params.get('mediaType');
|
const mediaType = params.get('mediaType');
|
||||||
const item = mediaType ? byType[mediaType] : undefined;
|
const item = mediaType ? addItemsByType[mediaType] : undefined;
|
||||||
return jsonResponse(item ? { page: [item], totalCount: 1 } : { page: [], totalCount: 0 });
|
return jsonResponse(item ? { page: [item], totalCount: 1 } : { page: [], totalCount: 0 });
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openAddItemsDialog() {
|
||||||
render(<CollectionsScreen />);
|
render(<CollectionsScreen />);
|
||||||
await screen.findByText('Favorites');
|
await screen.findByText('Favorites');
|
||||||
|
|
||||||
@@ -302,8 +304,21 @@ describe('CollectionsScreen', () => {
|
|||||||
await screen.findByText(/best-effort search preview/);
|
await screen.findByText(/best-effort search preview/);
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Add items' }));
|
fireEvent.click(screen.getByRole('button', { name: 'Add items' }));
|
||||||
|
return screen.getByRole('dialog');
|
||||||
|
}
|
||||||
|
|
||||||
const dialog = screen.getByRole('dialog');
|
function addItemsBrowseTypes(fetchMock: ReturnType<typeof mockApi>): string[] {
|
||||||
|
return 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 && mediaType !== '');
|
||||||
|
}
|
||||||
|
|
||||||
|
it('default add-items search excludes seasons and fans out over movies/shows/artists', async () => {
|
||||||
|
const fetchMock = mockAddItemsApi();
|
||||||
|
|
||||||
|
const dialog = await openAddItemsDialog();
|
||||||
fireEvent.change(within(dialog).getByPlaceholderText('Search movies, shows, seasons, artists…'), {
|
fireEvent.change(within(dialog).getByPlaceholderText('Search movies, shows, seasons, artists…'), {
|
||||||
target: { value: 'a' }
|
target: { value: 'a' }
|
||||||
});
|
});
|
||||||
@@ -311,15 +326,30 @@ describe('CollectionsScreen', () => {
|
|||||||
|
|
||||||
expect(await within(dialog).findByText('Adventure Time')).toBeInTheDocument();
|
expect(await within(dialog).findByText('Adventure Time')).toBeInTheDocument();
|
||||||
expect(within(dialog).getByText('Zathura')).toBeInTheDocument();
|
expect(within(dialog).getByText('Zathura')).toBeInTheDocument();
|
||||||
expect(within(dialog).getByText('Melon Season 1')).toBeInTheDocument();
|
|
||||||
expect(within(dialog).getByText('Between Movie and Show')).toBeInTheDocument();
|
expect(within(dialog).getByText('Between Movie and Show')).toBeInTheDocument();
|
||||||
|
// Seasons are excluded from the default fan-out (issue #180).
|
||||||
|
expect(within(dialog).queryByText('Melon Season 1')).not.toBeInTheDocument();
|
||||||
|
|
||||||
const browseTypes = fetchMock.mock.calls
|
expect(new Set(addItemsBrowseTypes(fetchMock))).toEqual(new Set(['Movie', 'TelevisionShow', 'Artist']));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('selecting the Seasons filter searches TelevisionSeason and surfaces seasons', async () => {
|
||||||
|
const fetchMock = mockAddItemsApi();
|
||||||
|
|
||||||
|
const dialog = await openAddItemsDialog();
|
||||||
|
fireEvent.change(within(dialog).getByPlaceholderText('Search movies, shows, seasons, artists…'), {
|
||||||
|
target: { value: 'a' }
|
||||||
|
});
|
||||||
|
fireEvent.click(within(dialog).getByRole('button', { name: 'Seasons' }));
|
||||||
|
|
||||||
|
expect(await within(dialog).findByText('Melon Season 1')).toBeInTheDocument();
|
||||||
|
// The explicit filter narrows the fan-out to seasons only.
|
||||||
|
expect(within(dialog).queryByText('Zathura')).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
const seasonCall = fetchMock.mock.calls
|
||||||
.map(([u]) => u.toString())
|
.map(([u]) => u.toString())
|
||||||
.filter((u) => u.startsWith('/api/library/browse'))
|
.filter((u) => u.startsWith('/api/library/browse'))
|
||||||
.map((u) => new URL(u, 'http://localhost').searchParams.get('mediaType'))
|
.some((u) => new URL(u, 'http://localhost').searchParams.get('mediaType') === 'TelevisionSeason');
|
||||||
.filter((mediaType): mediaType is string => mediaType != null);
|
expect(seasonCall).toBe(true);
|
||||||
|
|
||||||
expect(new Set(browseTypes)).toEqual(new Set(['Movie', 'TelevisionShow', 'TelevisionSeason', 'Artist']));
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -46,9 +46,25 @@ import { TYPE_LABEL } from '../media/mediaKinds';
|
|||||||
|
|
||||||
type Tab = 'manual' | 'smart';
|
type Tab = 'manual' | 'smart';
|
||||||
|
|
||||||
|
// Every kind that can be added to a manual collection from the picker.
|
||||||
const ADDABLE_TYPE_LIST: LibraryBrowseItem['mediaType'][] = ['Movie', 'TelevisionShow', 'TelevisionSeason', 'Artist'];
|
const ADDABLE_TYPE_LIST: LibraryBrowseItem['mediaType'][] = ['Movie', 'TelevisionShow', 'TelevisionSeason', 'Artist'];
|
||||||
const ADDABLE_TYPES = new Set<LibraryBrowseItem['mediaType']>(ADDABLE_TYPE_LIST);
|
const ADDABLE_TYPES = new Set<LibraryBrowseItem['mediaType']>(ADDABLE_TYPE_LIST);
|
||||||
|
|
||||||
|
// The default fan-out excludes seasons so a multi-season show doesn't flood the
|
||||||
|
// results with per-season rows (issue #180); seasons stay reachable via the
|
||||||
|
// explicit media-kind filter below.
|
||||||
|
const DEFAULT_SEARCH_KINDS: LibraryBrowseItem['mediaType'][] = ['Movie', 'TelevisionShow', 'Artist'];
|
||||||
|
|
||||||
|
type MediaKindFilter = 'all' | LibraryBrowseItem['mediaType'];
|
||||||
|
|
||||||
|
const MEDIA_KIND_FILTERS: { label: string; value: MediaKindFilter }[] = [
|
||||||
|
{ label: 'All', value: 'all' },
|
||||||
|
{ label: 'Movies', value: 'Movie' },
|
||||||
|
{ label: 'Shows', value: 'TelevisionShow' },
|
||||||
|
{ label: 'Seasons', value: 'TelevisionSeason' },
|
||||||
|
{ label: 'Artists', value: 'Artist' }
|
||||||
|
];
|
||||||
|
|
||||||
function sortByName<T extends { name?: null | string }>(items: T[]): T[] {
|
function sortByName<T extends { name?: null | string }>(items: T[]): T[] {
|
||||||
return [...items].sort((left, right) => (left.name ?? '').localeCompare(right.name ?? ''));
|
return [...items].sort((left, right) => (left.name ?? '').localeCompare(right.name ?? ''));
|
||||||
}
|
}
|
||||||
@@ -301,15 +317,17 @@ function AddItemsDialog({
|
|||||||
const [selected, setSelected] = useState<Map<string, LibraryBrowseItem>>(() => new Map());
|
const [selected, setSelected] = useState<Map<string, LibraryBrowseItem>>(() => new Map());
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [adding, setAdding] = useState(false);
|
const [adding, setAdding] = useState(false);
|
||||||
|
const [kindFilter, setKindFilter] = useState<MediaKindFilter>('all');
|
||||||
|
|
||||||
const runSearch = async () => {
|
const runSearch = async (filter: MediaKindFilter = kindFilter) => {
|
||||||
setSearching(true);
|
setSearching(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const trimmed = query.trim();
|
const trimmed = query.trim();
|
||||||
|
const kinds = filter === 'all' ? DEFAULT_SEARCH_KINDS : [filter];
|
||||||
const perKind = await Promise.all(
|
const perKind = await Promise.all(
|
||||||
ADDABLE_TYPE_LIST.map((mediaType) => getLibraryBrowseItems({ pageSize: 50, query: trimmed, mediaType }))
|
kinds.map((mediaType) => getLibraryBrowseItems({ pageSize: 50, query: trimmed, mediaType }))
|
||||||
);
|
);
|
||||||
const merged = perKind
|
const merged = perKind
|
||||||
.flatMap((result) => result.page ?? [])
|
.flatMap((result) => result.page ?? [])
|
||||||
@@ -323,6 +341,11 @@ function AddItemsDialog({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const selectKindFilter = (filter: MediaKindFilter) => {
|
||||||
|
setKindFilter(filter);
|
||||||
|
void runSearch(filter);
|
||||||
|
};
|
||||||
|
|
||||||
const toggle = (item: LibraryBrowseItem) => {
|
const toggle = (item: LibraryBrowseItem) => {
|
||||||
const key = `${item.mediaType}:${item.id}`;
|
const key = `${item.mediaType}:${item.id}`;
|
||||||
setSelected((current) => {
|
setSelected((current) => {
|
||||||
@@ -397,9 +420,23 @@ function AddItemsDialog({
|
|||||||
Search
|
Search
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
<div className="ctv-collections-picker-filters" role="group" aria-label="Filter by media kind">
|
||||||
|
{MEDIA_KIND_FILTERS.map((filter) => (
|
||||||
|
<button
|
||||||
|
aria-pressed={kindFilter === filter.value}
|
||||||
|
className={`ctv-collections-picker-filter ctv-press${kindFilter === filter.value ? ' ctv-collections-picker-filter-active' : ''}`}
|
||||||
|
key={filter.value}
|
||||||
|
onClick={() => selectKindFilter(filter.value)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{filter.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
<p className="ctv-collections-picker-note">
|
<p className="ctv-collections-picker-note">
|
||||||
The library search returns movies, shows, seasons and artists. Episodes, music, images and other item kinds
|
Add movies, shows, seasons and artists. “All” searches movies, shows and artists; pick
|
||||||
can't be added from here yet.
|
“Seasons” to find a specific season. Episodes, music, images and other item kinds can’t be
|
||||||
|
added from here yet.
|
||||||
</p>
|
</p>
|
||||||
{error && (
|
{error && (
|
||||||
<span className="ctv-field-error" role="alert">
|
<span className="ctv-field-error" role="alert">
|
||||||
|
|||||||
@@ -2432,6 +2432,57 @@
|
|||||||
animation: ctv-pop 260ms cubic-bezier(0.16, 1, 0.3, 1);
|
animation: ctv-pop 260ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Seasons drill-in affordance on show tiles (issue #180) */
|
||||||
|
.ctv-builder-seasons-btn {
|
||||||
|
position: absolute;
|
||||||
|
left: 6px;
|
||||||
|
bottom: 6px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid var(--border-hairline);
|
||||||
|
background: color-mix(in srgb, var(--surface-card) 82%, transparent);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font: var(--weight-medium) var(--text-2xs) / 1 var(--font-sans);
|
||||||
|
cursor: pointer;
|
||||||
|
backdrop-filter: blur(2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctv-builder-seasons-btn:hover {
|
||||||
|
border-color: color-mix(in srgb, var(--action-primary) 45%, transparent);
|
||||||
|
color: var(--action-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctv-builder-seasons-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
max-height: 420px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctv-builder-seasons-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-5, 10px);
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--surface-card);
|
||||||
|
border: 1px solid var(--border-hairline);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctv-builder-seasons-row-title {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
font: var(--weight-medium) var(--text-xs) / 1.15 var(--font-sans);
|
||||||
|
color: var(--text-primary);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes ctv-pop {
|
@keyframes ctv-pop {
|
||||||
from {
|
from {
|
||||||
transform: scale(0.2);
|
transform: scale(0.2);
|
||||||
@@ -3703,6 +3754,35 @@
|
|||||||
color: var(--text-disabled);
|
color: var(--text-disabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Media-kind filter chips in the add-items dialog (issue #180) */
|
||||||
|
.ctv-collections-picker-filters {
|
||||||
|
margin-top: 10px;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctv-collections-picker-filter {
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: var(--radius-pill, 999px);
|
||||||
|
border: 1px solid var(--border-hairline);
|
||||||
|
background: var(--surface-card);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font: var(--weight-medium) var(--text-2xs) / 1 var(--font-sans);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctv-collections-picker-filter:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-color: color-mix(in srgb, var(--action-primary) 35%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctv-collections-picker-filter-active {
|
||||||
|
background: color-mix(in srgb, var(--action-primary) 14%, transparent);
|
||||||
|
border-color: color-mix(in srgb, var(--action-primary) 45%, transparent);
|
||||||
|
color: var(--action-primary);
|
||||||
|
}
|
||||||
|
|
||||||
.ctv-collections-picker-results {
|
.ctv-collections-picker-results {
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
Reference in New Issue
Block a user