Merge pull request 'feat(web+api): media detail endpoints+pages, image folder browser (#141, #161 items 3+5)' (#183) from feat/141-media-detail into main
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m21s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 4m30s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 5m20s

This commit was merged in pull request #183.
This commit is contained in:
2026-07-07 21:08:43 +00:00
41 changed files with 4093 additions and 19 deletions
@@ -0,0 +1,3 @@
namespace ErsatzTV.Application.Images;
public record ImageFolderExists(int LibraryFolderId) : IRequest<bool>;
@@ -0,0 +1,21 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Images;
public class ImageFolderExistsHandler(IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<ImageFolderExists, bool>
{
public async Task<bool> Handle(ImageFolderExists request, CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
return await dbContext.LibraryFolders
.AsNoTracking()
.AnyAsync(
lf => lf.Id == request.LibraryFolderId
&& lf.LibraryPath.Library.MediaKind == LibraryMediaKind.Images,
cancellationToken);
}
}
@@ -22,12 +22,32 @@ public class GetLibraryBrowseItemsHandler(
GetLibraryBrowseItems request,
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)
// Drill-in for detail pages: read a parent's children directly (bypassing Lucene) so the SPA can
// expand a show into its seasons (#180), a season into its episodes, or an artist into its music
// videos (#141/#161). Each reads in the natural display order for that kind.
if (request.ParentId.HasValue)
{
await using TvContext seasonContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
return await BrowseSeasonsForShow(seasonContext, request, cancellationToken);
switch (request.MediaType)
{
case LibraryBrowseMediaType.TelevisionSeason:
{
await using TvContext seasonContext =
await dbContextFactory.CreateDbContextAsync(cancellationToken);
return await BrowseSeasonsForShow(seasonContext, request, cancellationToken);
}
case LibraryBrowseMediaType.Episode:
{
await using TvContext episodeContext =
await dbContextFactory.CreateDbContextAsync(cancellationToken);
return await BrowseEpisodesForSeason(episodeContext, request, cancellationToken);
}
case LibraryBrowseMediaType.MusicVideo:
{
await using TvContext musicVideoContext =
await dbContextFactory.CreateDbContextAsync(cancellationToken);
return await BrowseMusicVideosForArtist(musicVideoContext, request, cancellationToken);
}
}
}
int offset = request.PageNum * request.PageSize;
@@ -323,6 +343,75 @@ public class GetLibraryBrowseItemsHandler(
return new PagedLibraryBrowseItemsResponseModel(total, ordered);
}
// Drill-in: episodes of a specific season, in episode-number order (#141/#161).
private static async Task<PagedLibraryBrowseItemsResponseModel> BrowseEpisodesForSeason(
TvContext dbContext,
GetLibraryBrowseItems request,
CancellationToken cancellationToken)
{
List<int> allEpisodeIds = await dbContext.EpisodeMetadata
.AsNoTracking()
.Where(em => em.Episode.SeasonId == request.ParentId.Value)
.OrderBy(em => em.EpisodeNumber)
.ThenBy(em => em.EpisodeId)
.Select(em => em.EpisodeId)
.ToListAsync(cancellationToken);
// Distinct preserves order (LINQ-to-Objects) for episodes with multiple metadata rows.
allEpisodeIds = allEpisodeIds.Distinct().ToList();
int total = allEpisodeIds.Count;
List<int> pageIds = allEpisodeIds
.Skip(request.PageNum * request.PageSize)
.Take(request.PageSize)
.ToList();
List<LibraryBrowseItemResponseModel> episodes = await GetEpisodes(dbContext, pageIds, cancellationToken);
Dictionary<int, LibraryBrowseItemResponseModel> byId = episodes.ToDictionary(e => e.Id);
List<LibraryBrowseItemResponseModel> ordered = pageIds
.Where(byId.ContainsKey)
.Select(id => byId[id])
.ToList();
return new PagedLibraryBrowseItemsResponseModel(total, ordered);
}
// Drill-in: music videos of a specific artist, in album/track/title order (#141/#161).
private static async Task<PagedLibraryBrowseItemsResponseModel> BrowseMusicVideosForArtist(
TvContext dbContext,
GetLibraryBrowseItems request,
CancellationToken cancellationToken)
{
List<int> allMusicVideoIds = await dbContext.MusicVideoMetadata
.AsNoTracking()
.Where(mvm => mvm.MusicVideo.ArtistId == request.ParentId.Value)
.OrderBy(mvm => mvm.Album)
.ThenBy(mvm => mvm.Track)
.ThenBy(mvm => mvm.Title)
.ThenBy(mvm => mvm.MusicVideoId)
.Select(mvm => mvm.MusicVideoId)
.ToListAsync(cancellationToken);
allMusicVideoIds = allMusicVideoIds.Distinct().ToList();
int total = allMusicVideoIds.Count;
List<int> pageIds = allMusicVideoIds
.Skip(request.PageNum * request.PageSize)
.Take(request.PageSize)
.ToList();
List<LibraryBrowseItemResponseModel> musicVideos = await GetMusicVideos(dbContext, pageIds, cancellationToken);
Dictionary<int, LibraryBrowseItemResponseModel> byId = musicVideos.ToDictionary(mv => mv.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(
TvContext dbContext,
List<int> ids,
+66
View File
@@ -0,0 +1,66 @@
#nullable enable
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Emby;
using ErsatzTV.Core.Jellyfin;
using Flurl;
namespace ErsatzTV.Core.Api;
/// <summary>
/// Roots an artwork value (as produced by the Application view-model mappers) into a directly-usable
/// <c>&lt;img src&gt;</c> URL for the React SPA. The Blazor pages prefix "artwork/{folder}/" themselves and
/// resolve relative to <c>&lt;base href="/"&gt;</c>, but the SPA renders the value raw from under <c>/app/</c>,
/// so the API must root the URL itself (issue #180/#181). Mirrors the projection helper in
/// GetLibraryBrowseItemsHandler so detail endpoints stay consistent with the browse grid.
/// </summary>
public static class ApiArtwork
{
public static string Root(string? artwork, ArtworkKind artworkKind)
{
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 switch
{
ArtworkKind.Thumbnail => "thumbnails",
ArtworkKind.FanArt => "fanart",
_ => "posters"
};
// Some mappers (e.g. Artists) leave the raw jellyfin://emby:// scheme in the value; convert it here so
// the SPA gets a working proxy URL even when the source mapper didn't pre-convert. Movie/TV mappers
// already produce a relative "jellyfin/{id}?..." path, which falls through to the plain prefix below.
if (artwork.StartsWith("jellyfin://", StringComparison.OrdinalIgnoreCase))
{
Url url = JellyfinUrl.RelativeProxyForArtwork(artwork);
if (artworkKind is ArtworkKind.Poster or ArtworkKind.Thumbnail)
{
url.SetQueryParam("fillHeight", 440);
}
return $"/artwork/{folder}/{url}";
}
if (artwork.StartsWith("emby://", StringComparison.OrdinalIgnoreCase))
{
Url url = EmbyUrl.RelativeProxyForArtwork(artwork);
if (artworkKind is ArtworkKind.Poster or ArtworkKind.Thumbnail)
{
url.SetQueryParam("maxHeight", 440);
}
return $"/artwork/{folder}/{url}";
}
return $"/artwork/{folder}/{artwork}";
}
}
@@ -0,0 +1,14 @@
#nullable enable
namespace ErsatzTV.Core.Api.Artists;
public record ArtistDetailResponseModel(
int Id,
string Name,
string? Disambiguation,
string? Biography,
string Thumbnail,
string FanArt,
List<string> Genres,
List<string> Styles,
List<string> Moods,
List<string> Languages);
@@ -0,0 +1,10 @@
#nullable enable
namespace ErsatzTV.Core.Api.Images;
public record ImageFolderResponseModel(
int LibraryFolderId,
string Name,
string FullPath,
int SubfolderCount,
int ImageCount,
double? DurationSeconds);
@@ -0,0 +1,4 @@
#nullable enable
namespace ErsatzTV.Core.Api.Images;
public record UpdateImageFolderDurationResponseModel(double? DurationSeconds);
@@ -0,0 +1,8 @@
#nullable enable
namespace ErsatzTV.Core.Api.Media;
public record ActorResponseModel(
int Id,
string Name,
string? Role,
string Thumb);
@@ -0,0 +1,49 @@
#nullable enable
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Core.Api.MediaItems;
public record MediaItemInfoResponseModel(
int Id,
string Title,
string Kind,
string LibraryKind,
string? ServerName,
string LibraryName,
MediaItemState State,
TimeSpan Duration,
string? SampleAspectRatio,
string? DisplayAspectRatio,
string? RFrameRate,
VideoScanKind VideoScanKind,
double? InterlacedRatio,
int Width,
int Height,
List<MediaItemInfoStreamResponseModel> Streams,
List<MediaItemInfoChapterResponseModel> Chapters);
public record MediaItemInfoStreamResponseModel(
int? Index,
MediaStreamKind Kind,
string? Title,
string? Codec,
string? Profile,
string? Language,
int? Channels,
bool? Default,
bool? Forced,
bool? AttachedPic,
string? PixelFormat,
string? ColorRange,
string? ColorSpace,
string? ColorTransfer,
string? ColorPrimaries,
int? BitsPerRawSample,
string? MimeType,
string? FileName,
bool? IsExtracted);
public record MediaItemInfoChapterResponseModel(
string? Title,
TimeSpan StartTime,
TimeSpan EndTime);
@@ -0,0 +1,24 @@
#nullable enable
using ErsatzTV.Core.Api.Media;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Core.Api.Movies;
public record MovieDetailResponseModel(
int Id,
string Title,
string? Year,
string? Plot,
List<string> Genres,
List<string> Tags,
List<string> Studios,
List<string> ContentRatings,
List<string> Languages,
List<ActorResponseModel> Actors,
List<string> Directors,
List<string> Writers,
string? Path,
string? LocalPath,
MediaItemState State,
string Poster,
string FanArt);
@@ -0,0 +1,11 @@
#nullable enable
namespace ErsatzTV.Core.Api.Television;
public record SeasonDetailResponseModel(
int Id,
int ShowId,
string Title,
string? Year,
string Name,
string Poster,
string FanArt);
@@ -0,0 +1,22 @@
#nullable enable
using ErsatzTV.Core.Api.Media;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Core.Api.Television;
public record ShowDetailResponseModel(
int Id,
int LibraryId,
MediaSourceKind MediaSourceKind,
string Title,
string? Year,
string? Plot,
string Poster,
string FanArt,
List<string> Genres,
List<string> Tags,
List<string> Studios,
List<string> Networks,
List<string> ContentRatings,
List<string> Languages,
List<ActorResponseModel> Actors);
@@ -272,6 +272,55 @@ public class GetLibraryBrowseItemsHandlerTests
Arg.Any<CancellationToken>());
}
[Test]
public async Task Handle_Should_Browse_Episodes_For_A_Specific_Season_By_ParentId()
{
await SeedEpisodeDrillInGraph();
var handler = new GetLibraryBrowseItemsHandler(_searchIndex, _db.Factory);
PagedLibraryBrowseItemsResponseModel result = await handler.Handle(
new GetLibraryBrowseItems("", null, LibraryBrowseMediaType.Episode, 0, 10, 701),
CancellationToken.None);
result.TotalCount.ShouldBe(3);
// Ordered by episode number regardless of insertion order.
result.Page[0].MediaItemId.ShouldBe(712);
result.Page[1].MediaItemId.ShouldBe(711);
result.Page[2].MediaItemId.ShouldBe(713);
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_Browse_Music_Videos_For_A_Specific_Artist_By_ParentId()
{
await SeedMusicVideoDrillInGraph();
var handler = new GetLibraryBrowseItemsHandler(_searchIndex, _db.Factory);
PagedLibraryBrowseItemsResponseModel result = await handler.Handle(
new GetLibraryBrowseItems("", null, LibraryBrowseMediaType.MusicVideo, 0, 10, 801),
CancellationToken.None);
result.TotalCount.ShouldBe(2);
// Ordered by album then track: "Album A"/track 1 before "Album B"/track 1.
result.Page[0].MediaItemId.ShouldBe(812);
result.Page[1].MediaItemId.ShouldBe(811);
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()
{
@@ -525,6 +574,116 @@ public class GetLibraryBrowseItemsHandlerTests
await context.SaveChangesAsync();
}
private async Task SeedEpisodeDrillInGraph()
{
await using TvContext context = _db.CreateContext();
(LocalLibrary library, LibraryPath path) = MakeLibrary(700, "Episode Library");
var show = new Show
{
Id = 700,
LibraryPath = path,
Collections = [],
CollectionItems = [],
TraktListItems = [],
Seasons = [],
ShowMetadata = [MakeShowMetadata("Drill Show", "show.jpg")]
};
var season = MakeSeason(701, path, show, 1, "season.jpg");
// Inserted out of episode-number order to prove the handler sorts by number.
Episode e2 = MakeNumberedEpisode(711, path, season, 2);
Episode e1 = MakeNumberedEpisode(712, path, season, 1);
Episode e3 = MakeNumberedEpisode(713, path, season, 3);
season.Episodes.AddRange([e2, e1, e3]);
show.Seasons.Add(season);
path.MediaItems.AddRange([show, season, e2, e1, e3]);
context.LocalLibraries.Add(library);
context.Shows.Add(show);
context.Seasons.Add(season);
context.Episodes.AddRange(e2, e1, e3);
await context.SaveChangesAsync();
}
private async Task SeedMusicVideoDrillInGraph()
{
await using TvContext context = _db.CreateContext();
(LocalLibrary library, LibraryPath path) = MakeLibrary(800, "Music Video Library");
var artist = new Artist
{
Id = 801,
LibraryPath = path,
Collections = [],
CollectionItems = [],
TraktListItems = [],
MusicVideos = [],
ArtistMetadata = [MakeArtistMetadata("Drill Artist", "artist.jpg")]
};
MusicVideo mvB = MakeTitledMusicVideo(811, path, artist, "Track On B", "Album B", 1);
MusicVideo mvA = MakeTitledMusicVideo(812, path, artist, "Track On A", "Album A", 1);
artist.MusicVideos.AddRange([mvB, mvA]);
path.MediaItems.AddRange([artist, mvB, mvA]);
context.LocalLibraries.Add(library);
context.Artists.Add(artist);
context.MusicVideos.AddRange(mvB, mvA);
await context.SaveChangesAsync();
}
private static Episode MakeNumberedEpisode(int id, LibraryPath path, Season season, int episodeNumber)
{
Episode episode = MakeEpisode(id, path, season);
episode.EpisodeMetadata =
[
new EpisodeMetadata
{
Title = $"Episode {episodeNumber}",
SortTitle = $"Episode {episodeNumber}",
EpisodeNumber = episodeNumber,
Artwork = [],
Genres = [],
Tags = [],
Studios = [],
Actors = [],
Guids = [],
Subtitles = [],
Directors = [],
Writers = []
}
];
return episode;
}
private static MusicVideo MakeTitledMusicVideo(
int id,
LibraryPath path,
Artist artist,
string title,
string album,
int track)
{
MusicVideo musicVideo = MakeMusicVideo(id, path, artist);
musicVideo.MusicVideoMetadata =
[
new MusicVideoMetadata
{
Title = title,
SortTitle = title,
Album = album,
Track = track,
Artwork = [],
Genres = [],
Tags = [],
Studios = [],
Actors = [],
Guids = [],
Subtitles = []
}
];
return musicVideo;
}
private async Task SeedArtistGraph()
{
await using TvContext context = _db.CreateContext();
@@ -25,6 +25,7 @@ public class ApiControllerSecurityTests
{
Type[] apiControllers =
[
typeof(ArtistsController),
typeof(BlockController),
typeof(ChannelController),
typeof(CollectionController),
@@ -32,16 +33,20 @@ public class ApiControllerSecurityTests
typeof(DecoTemplateController),
typeof(FFmpegProfileController),
typeof(FillerPresetController),
typeof(ImagesController),
typeof(LibrariesController),
typeof(LogsController),
typeof(MaintenanceController),
typeof(MediaItemsController),
typeof(MoviesController),
typeof(PlayoutController),
typeof(ResolutionController),
typeof(ScannerController),
typeof(ScheduleController),
typeof(ScriptedScheduleController),
typeof(SeasonsController),
typeof(SessionController),
typeof(ShowsController),
typeof(SettingsController),
typeof(SmartCollectionController),
typeof(TemplateController),
@@ -0,0 +1,147 @@
using System.Reflection;
using ErsatzTV.Application.Images;
using ErsatzTV.Controllers.Api;
using ErsatzTV.Controllers.Api.Requests;
using ErsatzTV.Core.Api.Images;
using LanguageExt;
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 ImagesControllerTests
{
private ImagesController _controller = null!;
private IMediator _mediator = null!;
[SetUp]
public void SetUp()
{
_mediator = Substitute.For<IMediator>();
_controller = new ImagesController(_mediator);
}
[Test]
public void Controller_Should_Expose_Idiomatic_Rest_Routes()
{
ShouldHaveActionRoute(nameof(ImagesController.GetFolders), "GET", "/api/images/folders");
ShouldHaveActionRoute(
nameof(ImagesController.UpdateDuration),
"PUT",
"/api/images/folders/{id:int}/duration");
}
[Test]
public async Task GetFolders_Should_Map_Duration_Option_To_Nullable()
{
_mediator.Send(Arg.Any<GetImageFolders>(), Arg.Any<CancellationToken>())
.Returns(
[
new ImageFolderViewModel(1, "Root", "/images", 2, 5, Option<double>.None),
new ImageFolderViewModel(2, "Child", "/images/child", 0, 3, Option<double>.Some(4.5))
]);
List<ImageFolderResponseModel> result = await _controller.GetFolders(null, CancellationToken.None);
result.Count.ShouldBe(2);
result[0].DurationSeconds.ShouldBeNull();
result[1].DurationSeconds.ShouldBe(4.5);
result[1].Name.ShouldBe("Child");
}
[Test]
public async Task GetFolders_Should_Pass_None_When_ParentId_Omitted()
{
_mediator.Send(Arg.Any<GetImageFolders>(), Arg.Any<CancellationToken>()).Returns([]);
await _controller.GetFolders(null, CancellationToken.None);
await _mediator.Received().Send(
Arg.Is<GetImageFolders>(q => q.LibraryFolderId.IsNone),
Arg.Any<CancellationToken>());
}
[Test]
public async Task GetFolders_Should_Pass_Some_When_ParentId_Given()
{
_mediator.Send(Arg.Any<GetImageFolders>(), Arg.Any<CancellationToken>()).Returns([]);
await _controller.GetFolders(42, CancellationToken.None);
await _mediator.Received().Send(
Arg.Is<GetImageFolders>(q => q.LibraryFolderId == Option<int>.Some(42)),
Arg.Any<CancellationToken>());
}
[Test]
public async Task UpdateDuration_Should_Return_400_For_Non_Positive_Duration()
{
IActionResult result = await _controller.UpdateDuration(
1,
new UpdateImageFolderDurationRequest(0),
CancellationToken.None);
result.ShouldBeOfType<BadRequestObjectResult>().StatusCode.ShouldBe(400);
await _mediator.DidNotReceive().Send(Arg.Any<ImageFolderExists>(), Arg.Any<CancellationToken>());
}
[Test]
public async Task UpdateDuration_Should_Return_404_When_Folder_Missing()
{
_mediator.Send(Arg.Any<ImageFolderExists>(), Arg.Any<CancellationToken>()).Returns(false);
IActionResult result = await _controller.UpdateDuration(
1,
new UpdateImageFolderDurationRequest(3.0),
CancellationToken.None);
result.ShouldBeOfType<NotFoundObjectResult>().StatusCode.ShouldBe(404);
await _mediator.DidNotReceive().Send(Arg.Any<UpdateImageFolderDuration>(), Arg.Any<CancellationToken>());
}
[Test]
public async Task UpdateDuration_Should_Return_200_And_Update_When_Valid()
{
_mediator.Send(Arg.Any<ImageFolderExists>(), Arg.Any<CancellationToken>()).Returns(true);
_mediator.Send(Arg.Any<UpdateImageFolderDuration>(), Arg.Any<CancellationToken>()).Returns(3.0);
IActionResult result = await _controller.UpdateDuration(
1,
new UpdateImageFolderDurationRequest(3.0),
CancellationToken.None);
var ok = result.ShouldBeOfType<OkObjectResult>();
ok.Value.ShouldBeOfType<UpdateImageFolderDurationResponseModel>().DurationSeconds.ShouldBe(3.0);
}
[Test]
public async Task UpdateDuration_Should_Allow_Null_To_Clear()
{
_mediator.Send(Arg.Any<ImageFolderExists>(), Arg.Any<CancellationToken>()).Returns(true);
_mediator.Send(Arg.Any<UpdateImageFolderDuration>(), Arg.Any<CancellationToken>())
.Returns((double?)null);
IActionResult result = await _controller.UpdateDuration(
1,
new UpdateImageFolderDurationRequest(null),
CancellationToken.None);
result.ShouldBeOfType<OkObjectResult>()
.Value.ShouldBeOfType<UpdateImageFolderDurationResponseModel>().DurationSeconds.ShouldBeNull();
}
private static void ShouldHaveActionRoute(string actionName, string httpMethod, string route)
{
MethodInfo action = typeof(ImagesController).GetMethod(actionName)
?? throw new AssertionException($"Missing action {actionName}");
HttpMethodAttribute attribute = action.GetCustomAttributes<HttpMethodAttribute>(inherit: true).Single();
attribute.HttpMethods.ShouldContain(httpMethod);
attribute.Template.ShouldBe(route);
}
}
@@ -0,0 +1,262 @@
using System.Globalization;
using System.Reflection;
using ErsatzTV.Application.Artists;
using ErsatzTV.Application.MediaCards;
using ErsatzTV.Application.MediaItems;
using ErsatzTV.Application.Movies;
using ErsatzTV.Application.Television;
using ErsatzTV.Controllers.Api;
using ErsatzTV.Core;
using ErsatzTV.Core.Api.Artists;
using ErsatzTV.Core.Api.MediaItems;
using ErsatzTV.Core.Api.Movies;
using ErsatzTV.Core.Api.Television;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
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 MediaDetailControllerTests
{
private IMediator _mediator = null!;
[SetUp]
public void SetUp() => _mediator = Substitute.For<IMediator>();
[Test]
public void Controllers_Should_Expose_Idiomatic_Rest_Routes()
{
ShouldHaveActionRoute<MoviesController>(nameof(MoviesController.GetById), "GET", "/api/movies/{id:int}");
ShouldHaveActionRoute<ShowsController>(nameof(ShowsController.GetById), "GET", "/api/shows/{id:int}");
ShouldHaveActionRoute<SeasonsController>(nameof(SeasonsController.GetById), "GET", "/api/seasons/{id:int}");
ShouldHaveActionRoute<ArtistsController>(nameof(ArtistsController.GetById), "GET", "/api/artists/{id:int}");
ShouldHaveActionRoute<MediaItemsController>(
nameof(MediaItemsController.GetInfo),
"GET",
"/api/media-items/{id:int}/info");
}
[Test]
public async Task Movie_Should_Return_200_With_Rooted_Artwork()
{
var vm = new MovieViewModel(
"The Movie",
"1999",
"A plot",
["Drama"],
["tag"],
["Studio"],
["PG"],
["English"],
[new ActorCardViewModel(7, "Actor", "Role", "actor.jpg", MediaItemState.Normal)],
["Director"],
["Writer"],
"/media/movie.mkv",
"/local/movie.mkv",
MediaItemState.Normal)
{
Poster = "poster.jpg",
FanArt = "https://example.com/fan.jpg"
};
_mediator.Send(Arg.Any<GetMovieById>(), Arg.Any<CancellationToken>())
.Returns(Option<MovieViewModel>.Some(vm));
var controller = new MoviesController(_mediator);
IActionResult result = await controller.GetById(5, CancellationToken.None);
var ok = result.ShouldBeOfType<OkObjectResult>();
var body = ok.Value.ShouldBeOfType<MovieDetailResponseModel>();
body.Id.ShouldBe(5);
body.Title.ShouldBe("The Movie");
body.Poster.ShouldBe("/artwork/posters/poster.jpg");
body.FanArt.ShouldBe("https://example.com/fan.jpg");
body.Actors.Single().Thumb.ShouldBe("/artwork/thumbnails/actor.jpg");
body.State.ShouldBe(MediaItemState.Normal);
}
[Test]
public async Task Movie_Should_Return_404_When_Missing()
{
_mediator.Send(Arg.Any<GetMovieById>(), Arg.Any<CancellationToken>())
.Returns(Option<MovieViewModel>.None);
var controller = new MoviesController(_mediator);
IActionResult result = await controller.GetById(5, CancellationToken.None);
result.ShouldBeOfType<NotFoundObjectResult>().StatusCode.ShouldBe(404);
}
[Test]
public async Task Show_Should_Map_Languages_To_English_Names()
{
var vm = new TelevisionShowViewModel(
3,
2,
MediaSourceKind.Local,
"The Show",
"2010",
"Plot",
"poster.jpg",
"fan.jpg",
["Comedy"],
["tag"],
["Studio"],
["Network"],
["TV-14"],
[new CultureInfo("en")],
[]);
_mediator.Send(Arg.Any<GetTelevisionShowById>(), Arg.Any<CancellationToken>())
.Returns(Option<TelevisionShowViewModel>.Some(vm));
var controller = new ShowsController(_mediator);
IActionResult result = await controller.GetById(3, CancellationToken.None);
var body = result.ShouldBeOfType<OkObjectResult>().Value.ShouldBeOfType<ShowDetailResponseModel>();
body.Id.ShouldBe(3);
body.Poster.ShouldBe("/artwork/posters/poster.jpg");
body.FanArt.ShouldBe("/artwork/fanart/fan.jpg");
body.Languages.ShouldContain(new CultureInfo("en").EnglishName);
}
[Test]
public async Task Show_Should_Return_404_When_Missing()
{
_mediator.Send(Arg.Any<GetTelevisionShowById>(), Arg.Any<CancellationToken>())
.Returns(Option<TelevisionShowViewModel>.None);
var controller = new ShowsController(_mediator);
(await controller.GetById(3, CancellationToken.None)).ShouldBeOfType<NotFoundObjectResult>();
}
[Test]
public async Task Season_Should_Return_200()
{
var vm = new TelevisionSeasonViewModel(4, 3, "Show", "2010", "Season 1", "s.jpg", "f.jpg");
_mediator.Send(Arg.Any<GetTelevisionSeasonById>(), Arg.Any<CancellationToken>())
.Returns(Option<TelevisionSeasonViewModel>.Some(vm));
var controller = new SeasonsController(_mediator);
var body = (await controller.GetById(4, CancellationToken.None))
.ShouldBeOfType<OkObjectResult>().Value.ShouldBeOfType<SeasonDetailResponseModel>();
body.ShowId.ShouldBe(3);
body.Poster.ShouldBe("/artwork/posters/s.jpg");
}
[Test]
public async Task Season_Should_Return_404_When_Missing()
{
_mediator.Send(Arg.Any<GetTelevisionSeasonById>(), Arg.Any<CancellationToken>())
.Returns(Option<TelevisionSeasonViewModel>.None);
var controller = new SeasonsController(_mediator);
(await controller.GetById(4, CancellationToken.None)).ShouldBeOfType<NotFoundObjectResult>();
}
[Test]
public async Task Artist_Should_Root_Thumbnail_And_FanArt()
{
var vm = new ArtistViewModel(
"Artist",
"Disambig",
"Bio",
"thumb.jpg",
"fan.jpg",
["Rock"],
["Style"],
["Mood"],
[new CultureInfo("en")]);
_mediator.Send(Arg.Any<GetArtistById>(), Arg.Any<CancellationToken>())
.Returns(Option<ArtistViewModel>.Some(vm));
var controller = new ArtistsController(_mediator);
var body = (await controller.GetById(6, CancellationToken.None))
.ShouldBeOfType<OkObjectResult>().Value.ShouldBeOfType<ArtistDetailResponseModel>();
body.Id.ShouldBe(6);
body.Thumbnail.ShouldBe("/artwork/thumbnails/thumb.jpg");
body.FanArt.ShouldBe("/artwork/fanart/fan.jpg");
body.Languages.ShouldContain(new CultureInfo("en").EnglishName);
}
[Test]
public async Task Artist_Should_Return_404_When_Missing()
{
_mediator.Send(Arg.Any<GetArtistById>(), Arg.Any<CancellationToken>())
.Returns(Option<ArtistViewModel>.None);
var controller = new ArtistsController(_mediator);
(await controller.GetById(6, CancellationToken.None)).ShouldBeOfType<NotFoundObjectResult>();
}
[Test]
public async Task MediaItemInfo_Should_Return_200_With_Mapped_Streams()
{
var info = new MediaItemInfo(
9,
"Title",
"Movie",
"LocalLibrary",
null,
"Movies",
MediaItemState.Normal,
TimeSpan.FromMinutes(90),
"1:1",
"16:9",
"24/1",
VideoScanKind.Progressive,
null,
1920,
1080,
[new MediaItemInfoStream(0, MediaStreamKind.Video, "v", "h264", "high", "eng", null, true, null, null, "yuv420p", null, null, null, null, 8, null)],
[new MediaItemInfoChapter("Chapter 1", TimeSpan.Zero, TimeSpan.FromMinutes(10))]);
_mediator.Send(Arg.Any<GetMediaItemInfo>(), Arg.Any<CancellationToken>())
.Returns(Either<BaseError, MediaItemInfo>.Right(info));
var controller = new MediaItemsController(_mediator);
var body = (await controller.GetInfo(9, CancellationToken.None))
.ShouldBeOfType<OkObjectResult>().Value.ShouldBeOfType<MediaItemInfoResponseModel>();
body.Id.ShouldBe(9);
body.Streams.Single().Codec.ShouldBe("h264");
body.Chapters.Single().Title.ShouldBe("Chapter 1");
}
[Test]
public async Task MediaItemInfo_Should_Return_404_When_Not_Located()
{
_mediator.Send(Arg.Any<GetMediaItemInfo>(), Arg.Any<CancellationToken>())
.Returns(Either<BaseError, MediaItemInfo>.Left(new UnableToLocateMediaItem()));
var controller = new MediaItemsController(_mediator);
(await controller.GetInfo(9, CancellationToken.None))
.ShouldBeOfType<NotFoundObjectResult>().StatusCode.ShouldBe(404);
}
[Test]
public async Task MediaItemInfo_Should_Return_422_On_Other_Error()
{
_mediator.Send(Arg.Any<GetMediaItemInfo>(), Arg.Any<CancellationToken>())
.Returns(Either<BaseError, MediaItemInfo>.Left(BaseError.New("boom")));
var controller = new MediaItemsController(_mediator);
(await controller.GetInfo(9, CancellationToken.None))
.ShouldBeOfType<UnprocessableEntityObjectResult>().StatusCode.ShouldBe(422);
}
private static void ShouldHaveActionRoute<TController>(string actionName, string httpMethod, string route)
{
MethodInfo action = typeof(TController).GetMethod(actionName)
?? throw new AssertionException($"Missing action {actionName}");
HttpMethodAttribute attribute = action.GetCustomAttributes<HttpMethodAttribute>(inherit: true).Single();
attribute.HttpMethods.ShouldContain(httpMethod);
attribute.Template.ShouldBe(route);
}
}
@@ -0,0 +1,39 @@
using ErsatzTV.Application.Artists;
using ErsatzTV.Core.Api;
using ErsatzTV.Core.Api.Artists;
using ErsatzTV.Core.Domain;
using ErsatzTV.Extensions;
using MediatR;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
namespace ErsatzTV.Controllers.Api;
[ApiController]
public class ArtistsController(IMediator mediator) : ControllerBase
{
[HttpGet("/api/artists/{id:int}", Name = "GetArtistById")]
[Tags("Artists")]
[EndpointSummary("Get an artist by id")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(ArtistDetailResponseModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetById(int id, CancellationToken cancellationToken)
{
Option<ArtistViewModel> result = await mediator.Send(new GetArtistById(id), cancellationToken);
return result.Map(vm => Project(id, vm)).ToGetResult();
}
private static ArtistDetailResponseModel Project(int id, ArtistViewModel vm) =>
new(
id,
vm.Name,
string.IsNullOrWhiteSpace(vm.Disambiguation) ? null : vm.Disambiguation,
string.IsNullOrWhiteSpace(vm.Biography) ? null : vm.Biography,
ApiArtwork.Root(vm.Thumbnail, ArtworkKind.Thumbnail),
ApiArtwork.Root(vm.FanArt, ArtworkKind.FanArt),
vm.Genres,
vm.Styles,
vm.Moods,
vm.Languages.Map(c => c.EnglishName).ToList());
}
@@ -0,0 +1,81 @@
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using ErsatzTV.Application.Images;
using ErsatzTV.Controllers.Api.Requests;
using ErsatzTV.Core;
using ErsatzTV.Core.Api.Images;
using ErsatzTV.Extensions;
using MediatR;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
namespace ErsatzTV.Controllers.Api;
[ApiController]
public class ImagesController(IMediator mediator) : ControllerBase
{
[HttpGet("/api/images/folders", Name = "GetImageFolders")]
[Tags("Images")]
[EndpointSummary("List image library folders")]
[EndpointDescription("Omit parentId for the top-level folders; pass a folder id to list that folder's children.")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(List<ImageFolderResponseModel>), StatusCodes.Status200OK)]
public async Task<List<ImageFolderResponseModel>> GetFolders(
[FromQuery]
[Description("Parent image library-folder id; omit for the top-level folders")]
int? parentId = null,
CancellationToken cancellationToken = default)
{
Option<int> maybeParentId = parentId ?? Option<int>.None;
List<ImageFolderViewModel> folders = await mediator.Send(new GetImageFolders(maybeParentId), cancellationToken);
return folders.Map(Project).ToList();
}
[HttpPut("/api/images/folders/{id:int}/duration", Name = "UpdateImageFolderDuration")]
[Tags("Images")]
[EndpointSummary("Set or clear an image folder's playout duration")]
[EndpointDescription(
"Pass a positive durationSeconds to set the per-image duration for this folder (cascades to descendant " +
"images that don't override it); pass null to clear it and inherit from an ancestor.")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(UpdateImageFolderDurationResponseModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<IActionResult> UpdateDuration(
int id,
[Required] [FromBody] UpdateImageFolderDurationRequest request,
CancellationToken cancellationToken)
{
if (request.DurationSeconds is <= 0)
{
return BadRequest(
new ProblemDetails
{
Status = StatusCodes.Status400BadRequest,
Title = "Validation failed",
Detail = "durationSeconds must be greater than zero, or null to clear"
});
}
bool exists = await mediator.Send(new ImageFolderExists(id), cancellationToken);
if (!exists)
{
return ApiResults.NotFoundProblem("Image folder not found");
}
double? duration = await mediator.Send(
new UpdateImageFolderDuration(id, request.DurationSeconds),
cancellationToken);
return Ok(new UpdateImageFolderDurationResponseModel(duration));
}
private static ImageFolderResponseModel Project(ImageFolderViewModel vm) =>
new(
vm.LibraryFolderId,
vm.Name,
vm.FullPath,
vm.SubfolderCount,
vm.ImageCount,
vm.DurationSeconds.ToNullable());
}
@@ -24,7 +24,7 @@ public class LibraryBrowseController(IMediator mediator) : ControllerBase
[FromQuery] int pageNum = 0,
[FromQuery] int pageSize = 100,
[FromQuery]
[Description("Parent television show id; only used with mediaType=TelevisionSeason (lists that show's seasons), ignored otherwise")]
[Description("Parent id for a drill-in listing; only used with mediaType=TelevisionSeason (that show's seasons), mediaType=Episode (that season's episodes) or mediaType=MusicVideo (that artist's music videos), ignored otherwise")]
int? parentId = null,
CancellationToken cancellationToken = default)
{
@@ -1,6 +1,9 @@
using System.ComponentModel.DataAnnotations;
using ErsatzTV.Application.MediaItems;
using ErsatzTV.Controllers.Api.Requests;
using ErsatzTV.Core;
using ErsatzTV.Core.Api.MediaItems;
using ErsatzTV.Core.Errors;
using ErsatzTV.Extensions;
using MediatR;
using Microsoft.AspNetCore.Http;
@@ -29,4 +32,66 @@ public class MediaItemsController(IMediator mediator) : ControllerBase
Either<BaseError, Unit> result = await mediator.Send(request.ToCommand(), cancellationToken);
return result.ToDeletedResult();
}
[HttpGet("/api/media-items/{id:int}/info", Name = "GetMediaItemInfo")]
[Tags("Media Items")]
[EndpointSummary("Get technical media info for a media item")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(MediaItemInfoResponseModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> GetInfo(int id, CancellationToken cancellationToken)
{
Either<BaseError, MediaItemInfo> result = await mediator.Send(new GetMediaItemInfo(id), cancellationToken);
return result.Match(
Left: error => error is UnableToLocateMediaItem
? ApiResults.NotFoundProblem(error.Value)
: error.ToErrorResult(),
Right: info => (IActionResult)new OkObjectResult(Project(info)));
}
private static MediaItemInfoResponseModel Project(MediaItemInfo info) =>
new(
info.Id,
info.Title,
info.Kind,
info.LibraryKind,
string.IsNullOrWhiteSpace(info.ServerName) ? null : info.ServerName,
info.LibraryName,
info.State,
info.Duration,
string.IsNullOrWhiteSpace(info.SampleAspectRatio) ? null : info.SampleAspectRatio,
string.IsNullOrWhiteSpace(info.DisplayAspectRatio) ? null : info.DisplayAspectRatio,
string.IsNullOrWhiteSpace(info.RFrameRate) ? null : info.RFrameRate,
info.VideoScanKind,
info.InterlacedRatio,
info.Width,
info.Height,
info.Streams.Map(Project).ToList(),
info.Chapters.Map(Project).ToList());
private static MediaItemInfoStreamResponseModel Project(MediaItemInfoStream stream) =>
new(
stream.Index,
stream.Kind,
stream.Title,
stream.Codec,
stream.Profile,
stream.Language,
stream.Channels,
stream.Default,
stream.Forced,
stream.AttachedPic,
stream.PixelFormat,
stream.ColorRange,
stream.ColorSpace,
stream.ColorTransfer,
stream.ColorPrimaries,
stream.BitsPerRawSample,
stream.MimeType,
stream.FileName,
stream.IsExtracted);
private static MediaItemInfoChapterResponseModel Project(MediaItemInfoChapter chapter) =>
new(chapter.Title, chapter.StartTime, chapter.EndTime);
}
@@ -0,0 +1,47 @@
using ErsatzTV.Application.Movies;
using ErsatzTV.Core.Api;
using ErsatzTV.Core.Api.Media;
using ErsatzTV.Core.Api.Movies;
using ErsatzTV.Core.Domain;
using ErsatzTV.Extensions;
using MediatR;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
namespace ErsatzTV.Controllers.Api;
[ApiController]
public class MoviesController(IMediator mediator) : ControllerBase
{
[HttpGet("/api/movies/{id:int}", Name = "GetMovieById")]
[Tags("Movies")]
[EndpointSummary("Get a movie by id")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(MovieDetailResponseModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetById(int id, CancellationToken cancellationToken)
{
Option<MovieViewModel> result = await mediator.Send(new GetMovieById(id), cancellationToken);
return result.Map(vm => Project(id, vm)).ToGetResult();
}
private static MovieDetailResponseModel Project(int id, MovieViewModel vm) =>
new(
id,
vm.Title,
vm.Year,
vm.Plot,
vm.Genres,
vm.Tags,
vm.Studios,
vm.ContentRatings,
vm.Languages,
vm.Actors.Map(a => new ActorResponseModel(a.Id, a.Name, a.Role, ApiArtwork.Root(a.Thumb, ArtworkKind.Thumbnail))).ToList(),
vm.Directors,
vm.Writers,
vm.Path,
vm.LocalPath,
vm.MediaItemState,
ApiArtwork.Root(vm.Poster, ArtworkKind.Poster),
ApiArtwork.Root(vm.FanArt, ArtworkKind.FanArt));
}
@@ -0,0 +1,3 @@
namespace ErsatzTV.Controllers.Api.Requests;
public record UpdateImageFolderDurationRequest(double? DurationSeconds);
@@ -0,0 +1,37 @@
using ErsatzTV.Application.Television;
using ErsatzTV.Core.Api;
using ErsatzTV.Core.Api.Television;
using ErsatzTV.Core.Domain;
using ErsatzTV.Extensions;
using MediatR;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
namespace ErsatzTV.Controllers.Api;
[ApiController]
public class SeasonsController(IMediator mediator) : ControllerBase
{
[HttpGet("/api/seasons/{id:int}", Name = "GetSeasonById")]
[Tags("Television")]
[EndpointSummary("Get a television season by id")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(SeasonDetailResponseModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetById(int id, CancellationToken cancellationToken)
{
Option<TelevisionSeasonViewModel> result =
await mediator.Send(new GetTelevisionSeasonById(id), cancellationToken);
return result.Map(Project).ToGetResult();
}
private static SeasonDetailResponseModel Project(TelevisionSeasonViewModel vm) =>
new(
vm.Id,
vm.ShowId,
vm.Title,
string.IsNullOrWhiteSpace(vm.Year) ? null : vm.Year,
vm.Name,
ApiArtwork.Root(vm.Poster, ArtworkKind.Poster),
ApiArtwork.Root(vm.FanArt, ArtworkKind.FanArt));
}
@@ -0,0 +1,45 @@
using ErsatzTV.Application.Television;
using ErsatzTV.Core.Api;
using ErsatzTV.Core.Api.Media;
using ErsatzTV.Core.Api.Television;
using ErsatzTV.Core.Domain;
using ErsatzTV.Extensions;
using MediatR;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
namespace ErsatzTV.Controllers.Api;
[ApiController]
public class ShowsController(IMediator mediator) : ControllerBase
{
[HttpGet("/api/shows/{id:int}", Name = "GetShowById")]
[Tags("Television")]
[EndpointSummary("Get a television show by id")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(ShowDetailResponseModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetById(int id, CancellationToken cancellationToken)
{
Option<TelevisionShowViewModel> result = await mediator.Send(new GetTelevisionShowById(id), cancellationToken);
return result.Map(Project).ToGetResult();
}
private static ShowDetailResponseModel Project(TelevisionShowViewModel vm) =>
new(
vm.Id,
vm.LibraryId,
vm.MediaSourceKind,
vm.Title,
string.IsNullOrWhiteSpace(vm.Year) ? null : vm.Year,
string.IsNullOrWhiteSpace(vm.Plot) ? null : vm.Plot,
ApiArtwork.Root(vm.Poster, ArtworkKind.Poster),
ApiArtwork.Root(vm.FanArt, ArtworkKind.FanArt),
vm.Genres,
vm.Tags,
vm.Studios,
vm.Networks,
vm.ContentRatings,
vm.Languages.Map(c => c.EnglishName).ToList(),
vm.Actors.Map(a => new ActorResponseModel(a.Id, a.Name, a.Role, ApiArtwork.Root(a.Thumb, ArtworkKind.Thumbnail))).ToList());
}
File diff suppressed because it is too large Load Diff
+47 -2
View File
@@ -69,6 +69,13 @@ import { FFmpegProfilesScreen } from './screens/FFmpegProfilesScreen';
import { FillerPresetsScreen } from './screens/FillerPresetsScreen';
import { LogsScreen } from './screens/LogsScreen';
import { MediaBrowseScreen } from './screens/MediaBrowseScreen';
import {
ArtistDetailScreen,
MovieDetailScreen,
SeasonDetailScreen,
ShowDetailScreen
} from './screens/MediaDetailScreen';
import { ImageBrowserScreen } from './screens/ImageBrowserScreen';
import { SearchScreen } from './screens/SearchScreen';
import { SettingsScreen } from './screens/SettingsScreen';
import { TraktListsScreen } from './screens/TraktListsScreen';
@@ -85,7 +92,7 @@ import {
PlayoutAlternateSchedulesScreen,
PlayoutTemplatesEditorScreen
} from './screens/PlayoutScheduleEditors';
import { navigateToPath, parsePlayoutSubRoute } from './routing';
import { navigateToPath, parseMediaSubRoute, parsePlayoutSubRoute } from './routing';
import {
Badge,
Button,
@@ -3567,6 +3574,44 @@ function PlayoutsRouteScreen() {
return <PlayoutsScreen />;
}
// The Media nav entry owns detail sub-pages (/app/media/{movies|shows|seasons|artists}/{id}) and the
// image folder browser (/app/media/images/browser). Like PlayoutsRouteScreen, this wrapper tracks
// pathname locally + listens for popstate, because routeFromLocation() returns the SAME 'media'
// ScreenRoute object for the base grid and every sub-path (Object.is bails App's setActiveRoute).
function MediaRouteScreen() {
const [pathname, setPathname] = useState(() => window.location.pathname);
const [search, setSearch] = useState(() => window.location.search);
useEffect(() => {
const onPopState = () => {
setPathname(window.location.pathname);
setSearch(window.location.search);
};
window.addEventListener('popstate', onPopState);
return () => window.removeEventListener('popstate', onPopState);
}, []);
const sub = parseMediaSubRoute(pathname);
if (sub?.kind === 'movie') {
return <MovieDetailScreen key={pathname} id={sub.id} />;
}
if (sub?.kind === 'show') {
return <ShowDetailScreen key={pathname} id={sub.id} />;
}
if (sub?.kind === 'season') {
return <SeasonDetailScreen key={pathname} id={sub.id} />;
}
if (sub?.kind === 'artist') {
return <ArtistDetailScreen key={pathname} id={sub.id} />;
}
if (sub?.kind === 'images') {
return <ImageBrowserScreen key={pathname} />;
}
return <MediaBrowseScreen key={search} />;
}
function ScreenContent({
healthState,
route
@@ -3631,7 +3676,7 @@ function ScreenContent({
}
if (route.id === 'media') {
return <MediaBrowseScreen key={window.location.search} />;
return <MediaRouteScreen />;
}
if (route.id === 'search') {
+125
View File
@@ -3,6 +3,12 @@
export interface components {
schemas: {
"ActorResponseModel": {
"id": number;
"name": string;
"role": null | string;
"thumb": string;
};
"AddItemsToCollectionRequest": {
"movieIds": null | Array<number>;
"showIds": null | Array<number>;
@@ -17,6 +23,18 @@ export interface components {
};
"AddTraktListRequest": {
"url": null | string;
};
"ArtistDetailResponseModel": {
"id": number;
"name": string;
"disambiguation": null | string;
"biography": null | string;
"thumbnail": string;
"fanArt": string;
"genres": Array<string>;
"styles": Array<string>;
"moods": Array<string>;
"languages": Array<string>;
};
"ArtworkContentTypeModel": {
"path": null | string;
@@ -687,6 +705,14 @@ export interface components {
"lastAccess": string;
};
"IFormFile": string;
"ImageFolderResponseModel": {
"libraryFolderId": number;
"name": string;
"fullPath": string;
"subfolderCount": number;
"imageCount": number;
"durationSeconds": null | number;
};
"LibraryBrowseItemResponseModel": {
"id": number;
"mediaType": components["schemas"]["LibraryBrowseMediaType"];
@@ -739,8 +765,54 @@ export interface components {
"poster"?: null | string;
"state": components["schemas"]["MediaItemState"];
"hasMediaInfo"?: boolean;
};
"MediaItemInfoChapterResponseModel": {
"title": null | string;
"startTime": string;
"endTime": string;
};
"MediaItemInfoResponseModel": {
"id": number;
"title": string;
"kind": string;
"libraryKind": string;
"serverName": null | string;
"libraryName": string;
"state": components["schemas"]["MediaItemState"];
"duration": string;
"sampleAspectRatio": null | string;
"displayAspectRatio": null | string;
"rFrameRate": null | string;
"videoScanKind": components["schemas"]["VideoScanKind"];
"interlacedRatio": null | number;
"width": number;
"height": number;
"streams": Array<components["schemas"]["MediaItemInfoStreamResponseModel"]>;
"chapters": Array<components["schemas"]["MediaItemInfoChapterResponseModel"]>;
};
"MediaItemInfoStreamResponseModel": {
"index": null | number;
"kind": components["schemas"]["MediaStreamKind"];
"title": null | string;
"codec": null | string;
"profile": null | string;
"language": null | string;
"channels": null | number;
"default": null | boolean;
"forced": null | boolean;
"attachedPic": null | boolean;
"pixelFormat": null | string;
"colorRange": null | string;
"colorSpace": null | string;
"colorTransfer": null | string;
"colorPrimaries": null | string;
"bitsPerRawSample": null | number;
"mimeType": null | string;
"fileName": null | string;
"isExtracted": null | boolean;
};
"MediaItemState": "Normal" | "FileNotFound" | "Unavailable" | "RemoteOnly";
"MediaSourceKind": "Local" | "Plex" | "Jellyfin" | "Emby";
"MediaSourceLibraryResponseModel": {
"id": number;
"name": string;
@@ -754,6 +826,26 @@ export interface components {
"name": string;
"connectionAddress": null | string;
"libraries": Array<components["schemas"]["MediaSourceLibraryResponseModel"]>;
};
"MediaStreamKind": "Video" | "Audio" | "Subtitle" | "Attachment" | "ExternalSubtitle";
"MovieDetailResponseModel": {
"id": number;
"title": string;
"year": null | string;
"plot": null | string;
"genres": Array<string>;
"tags": Array<string>;
"studios": Array<string>;
"contentRatings": Array<string>;
"languages": Array<string>;
"actors": Array<components["schemas"]["ActorResponseModel"]>;
"directors": Array<string>;
"writers": Array<string>;
"path": null | string;
"localPath": null | string;
"state": components["schemas"]["MediaItemState"];
"poster": string;
"fanArt": string;
};
"MultiCollectionItemViewModel": {
"multiCollectionId": number;
@@ -1139,6 +1231,32 @@ export interface components {
"otherVideos": components["schemas"]["SearchResultGroupResponseModel"];
"images": components["schemas"]["SearchResultGroupResponseModel"];
"remoteStreams": components["schemas"]["SearchResultGroupResponseModel"];
};
"SeasonDetailResponseModel": {
"id": number;
"showId": number;
"title": string;
"year": null | string;
"name": string;
"poster": string;
"fanArt": string;
};
"ShowDetailResponseModel": {
"id": number;
"libraryId": number;
"mediaSourceKind": components["schemas"]["MediaSourceKind"];
"title": string;
"year": null | string;
"plot": null | string;
"poster": string;
"fanArt": string;
"genres": Array<string>;
"tags": Array<string>;
"studios": Array<string>;
"networks": Array<string>;
"contentRatings": Array<string>;
"languages": Array<string>;
"actors": Array<components["schemas"]["ActorResponseModel"]>;
};
"SmartCollectionResponseModel": {
"id": number;
@@ -1336,6 +1454,12 @@ export interface components {
};
"UpdateHdhrSettingsRequest": {
"tunerCount": number;
};
"UpdateImageFolderDurationRequest": {
"durationSeconds": null | number;
};
"UpdateImageFolderDurationResponseModel": {
"durationSeconds": null | number;
};
"UpdateLoggingSettingsRequest": {
"defaultMinimumLogLevel": components["schemas"]["LogEventLevel"];
@@ -1410,6 +1534,7 @@ export interface components {
"messages": Array<string>;
"json": string;
};
"VideoScanKind": "Unknown" | "Progressive" | "Interlaced";
"WatermarkFullResponseModel": {
"id": number;
"name": string;
+45
View File
@@ -0,0 +1,45 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { getImageFolders, updateImageFolderDuration } from './imageFolders';
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), { headers: { 'Content-Type': 'application/json' }, status });
}
describe('image folder clients', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('omits parentId for the top-level listing', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse([]));
await getImageFolders();
const url = new URL(String(fetchMock.mock.calls[0][0]), 'http://localhost');
expect(url.pathname).toBe('/api/images/folders');
expect(url.searchParams.has('parentId')).toBe(false);
});
it('passes parentId when listing children', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse([]));
await getImageFolders(42);
const url = new URL(String(fetchMock.mock.calls[0][0]), 'http://localhost');
expect(url.searchParams.get('parentId')).toBe('42');
});
it('PUTs the duration body to the folder route', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({ durationSeconds: 3 }));
const result = await updateImageFolderDuration(7, 3);
const [input, init] = fetchMock.mock.calls[0];
expect(new URL(String(input), 'http://localhost').pathname).toBe('/api/images/folders/7/duration');
expect((init as RequestInit).method).toBe('PUT');
expect(JSON.parse(String((init as RequestInit).body))).toEqual({ durationSeconds: 3 });
expect(result.durationSeconds).toBe(3);
});
it('sends null to clear the duration', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({ durationSeconds: null }));
await updateImageFolderDuration(7, null);
const [, init] = fetchMock.mock.calls[0];
expect(JSON.parse(String((init as RequestInit).body))).toEqual({ durationSeconds: null });
});
});
+32
View File
@@ -0,0 +1,32 @@
import { ApiError, request } from './client';
import type { components } from './generated/v1';
export type ImageFolder = components['schemas']['ImageFolderResponseModel'];
export type UpdateImageFolderDurationResponse = components['schemas']['UpdateImageFolderDurationResponseModel'];
export function getImageFolders(parentId?: number): Promise<ImageFolder[]> {
const queryString = parentId != null ? `?parentId=${parentId}` : '';
return request<ImageFolder[]>(`/api/images/folders${queryString}`);
}
export function updateImageFolderDuration(
id: number,
durationSeconds: number | null
): Promise<UpdateImageFolderDurationResponse> {
return request<UpdateImageFolderDurationResponse>(`/api/images/folders/${id}/duration`, {
body: { durationSeconds },
method: 'PUT'
});
}
export function messageFromImageFolderError(error: unknown, fallback = 'Unable to load image folders'): string {
if (error instanceof ApiError) {
return error.detail ?? error.message;
}
if (error instanceof Error) {
return error.message;
}
return fallback;
}
+2
View File
@@ -11,10 +11,12 @@ export * from './decoTemplates';
export * from './ffmpegProfiles';
export * from './fillerPresets';
export * from './guide';
export * from './imageFolders';
export * from './libraries';
export * from './libraryBrowse';
export * from './logs';
export * from './maintenance';
export * from './mediaDetail';
export * from './mediaItems';
export * from './pickers';
export * from './playlists';
+40
View File
@@ -0,0 +1,40 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { getArtist, getMediaItemInfo, getMovie, getSeason, getShow } from './mediaDetail';
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), { headers: { 'Content-Type': 'application/json' }, status });
}
function calledPath(fetchMock: ReturnType<typeof vi.spyOn>): string {
return new URL(String(fetchMock.mock.calls[0][0]), 'http://localhost').pathname;
}
describe('media detail clients', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('requests the right detail routes', async () => {
const cases: Array<[() => Promise<unknown>, string]> = [
[() => getMovie(5), '/api/movies/5'],
[() => getShow(6), '/api/shows/6'],
[() => getSeason(7), '/api/seasons/7'],
[() => getArtist(8), '/api/artists/8'],
[() => getMediaItemInfo(9), '/api/media-items/9/info']
];
for (const [call, path] of cases) {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({}));
await call();
expect(calledPath(fetchMock)).toBe(path);
vi.restoreAllMocks();
}
});
it('returns the parsed body', async () => {
vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({ id: 5, title: 'The Movie', poster: '/artwork/posters/x.jpg' }));
const movie = await getMovie(5);
expect(movie.title).toBe('The Movie');
expect(movie.poster).toBe('/artwork/posters/x.jpg');
});
});
+43
View File
@@ -0,0 +1,43 @@
import { ApiError, request } from './client';
import type { components } from './generated/v1';
export type MovieDetail = components['schemas']['MovieDetailResponseModel'];
export type ShowDetail = components['schemas']['ShowDetailResponseModel'];
export type SeasonDetail = components['schemas']['SeasonDetailResponseModel'];
export type ArtistDetail = components['schemas']['ArtistDetailResponseModel'];
export type ActorDetail = components['schemas']['ActorResponseModel'];
export type MediaItemInfo = components['schemas']['MediaItemInfoResponseModel'];
export type MediaItemInfoStream = components['schemas']['MediaItemInfoStreamResponseModel'];
export type MediaItemInfoChapter = components['schemas']['MediaItemInfoChapterResponseModel'];
export function getMovie(id: number): Promise<MovieDetail> {
return request<MovieDetail>(`/api/movies/${id}`);
}
export function getShow(id: number): Promise<ShowDetail> {
return request<ShowDetail>(`/api/shows/${id}`);
}
export function getSeason(id: number): Promise<SeasonDetail> {
return request<SeasonDetail>(`/api/seasons/${id}`);
}
export function getArtist(id: number): Promise<ArtistDetail> {
return request<ArtistDetail>(`/api/artists/${id}`);
}
export function getMediaItemInfo(id: number): Promise<MediaItemInfo> {
return request<MediaItemInfo>(`/api/media-items/${id}/info`);
}
export function messageFromMediaDetailError(error: unknown, fallback = 'Unable to load media details'): string {
if (error instanceof ApiError) {
return error.detail ?? error.message;
}
if (error instanceof Error) {
return error.message;
}
return fallback;
}
+18 -6
View File
@@ -9,11 +9,13 @@ export function MediaPosterCard({
item,
selected,
onToggleSelect,
onOpen,
height = 150
}: {
item: LibraryBrowseItem;
selected?: boolean;
onToggleSelect?: (item: LibraryBrowseItem) => void;
onOpen?: (item: LibraryBrowseItem) => void;
height?: number;
}) {
const hue = hueOf(item.title);
@@ -25,20 +27,30 @@ export function MediaPosterCard({
background: `linear-gradient(160deg, hsl(${hue} 24% 17%), hsl(${hue} 26% 12%))`
};
// Selection (channel builder) takes precedence over open (browse drill-in) when both are supplied.
const selectable = typeof onToggleSelect === 'function';
const openable = !selectable && typeof onOpen === 'function';
const interactive = selectable || openable;
const activate = () => {
if (selectable) {
onToggleSelect?.(item);
} else if (openable) {
onOpen?.(item);
}
};
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}
className={`ctv-media-card${selected ? ' ctv-media-card-selected' : ''}${interactive ? ' ctv-press' : ''}`}
onClick={interactive ? activate : undefined}
role={interactive ? 'button' : undefined}
tabIndex={interactive ? 0 : undefined}
onKeyDown={
selectable
interactive
? (event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
onToggleSelect?.(item);
activate();
}
}
: undefined
+17
View File
@@ -49,6 +49,23 @@ export const TYPE_LABEL: Record<LibraryBrowseMediaType, string> = {
RemoteStream: 'Remote Stream'
};
// The SPA detail-page path for a browse item, or null if the kind has no detail page.
// item.id is the domain id the detail endpoints expect (movie/show/season/artist id).
export function mediaDetailPath(item: LibraryBrowseItem): string | null {
switch (item.mediaType) {
case 'Movie':
return `/app/media/movies/${item.id}`;
case 'TelevisionShow':
return `/app/media/shows/${item.id}`;
case 'TelevisionSeason':
return `/app/media/seasons/${item.id}`;
case 'Artist':
return `/app/media/artists/${item.id}`;
default:
return null;
}
}
// Deterministic hue so the same title always gets the same placeholder gradient.
export function hueOf(value: string): number {
let hue = 0;
+28
View File
@@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest';
import { parseMediaSubRoute } from './routing';
describe('parseMediaSubRoute', () => {
it('returns null for the base media path', () => {
expect(parseMediaSubRoute('/app/media')).toBeNull();
expect(parseMediaSubRoute('/app/media/')).toBeNull();
});
it('parses the four detail kinds', () => {
expect(parseMediaSubRoute('/app/media/movies/5')).toEqual({ id: 5, kind: 'movie' });
expect(parseMediaSubRoute('/app/media/shows/6')).toEqual({ id: 6, kind: 'show' });
expect(parseMediaSubRoute('/app/media/seasons/7')).toEqual({ id: 7, kind: 'season' });
expect(parseMediaSubRoute('/app/media/artists/8')).toEqual({ id: 8, kind: 'artist' });
});
it('parses the image browser path', () => {
expect(parseMediaSubRoute('/app/media/images/browser')).toEqual({ kind: 'images' });
});
it('rejects unknown kinds and non-numeric ids', () => {
expect(parseMediaSubRoute('/app/media/widgets/5')).toBeNull();
expect(parseMediaSubRoute('/app/media/movies/abc')).toBeNull();
expect(parseMediaSubRoute('/app/media/movies/0')).toBeNull();
expect(parseMediaSubRoute('/app/media/movies')).toBeNull();
expect(parseMediaSubRoute('/app/media/movies/5/extra')).toBeNull();
});
});
+36
View File
@@ -33,3 +33,39 @@ export function parsePlayoutSubRoute(pathname: string): PlayoutSubRoute | null {
}
return null;
}
// The Media nav entry owns detail sub-pages (/app/media/{movies|shows|seasons|artists}/{id}) and the
// image folder browser (/app/media/images/browser). Parsing lives here so the screen module only
// exports components (react-refresh) while App.tsx's render switch can dispatch. Like the playout
// sub-routes, routeFromLocation() returns the same 'media' route object for the base grid and every
// detail path, so the wrapper in App.tsx tracks pathname locally to re-render the right sub-screen.
export type MediaDetailKind = 'movie' | 'show' | 'season' | 'artist';
export type MediaSubRoute = { kind: MediaDetailKind; id: number } | { kind: 'images' };
const mediaDetailSlugs: Record<string, MediaDetailKind> = {
movies: 'movie',
shows: 'show',
seasons: 'season',
artists: 'artist'
};
export function parseMediaSubRoute(pathname: string): MediaSubRoute | null {
const base = '/app/media';
const normalized = pathname.replace(/\/+$/, '');
if (!normalized.startsWith(`${base}/`)) {
return null;
}
const parts = normalized.slice(base.length + 1).split('/');
if (parts.length === 2 && parts[0] === 'images' && parts[1] === 'browser') {
return { kind: 'images' };
}
if (parts.length === 2) {
const kind = mediaDetailSlugs[parts[0]];
const id = Number(parts[1]);
if (kind && Number.isInteger(id) && id > 0) {
return { id, kind };
}
}
return null;
}
+270
View File
@@ -0,0 +1,270 @@
import { useEffect, useRef, useState } from 'react';
import { ChevronDown, ChevronRight, Clock, FolderTree, Pencil, Search, TriangleAlert } from 'lucide-react';
import { Button, Card, Dialog, IconButton, Input, Spinner } from '../components';
import {
getImageFolders,
messageFromImageFolderError,
updateImageFolderDuration,
type ImageFolder
} from '../api';
import { navigateToPath } from '../routing';
function searchLink(libraryFolderId: number) {
navigateToPath(`/app/search?query=${encodeURIComponent(`library_folder_id:${libraryFolderId}`)}`);
}
function EditDurationDialog({
folder,
open,
onClose,
onSaved
}: {
folder: ImageFolder;
open: boolean;
onClose: () => void;
onSaved: (durationSeconds: number | null) => void;
}) {
// The form is mounted only while open, so its useState initializers reset each time it opens
// (no synchronous setState in an effect).
return (
<Dialog onClose={onClose} open={open} title={`Duration — ${folder.name}`} width={420}>
{open ? <EditDurationForm folder={folder} onClose={onClose} onSaved={onSaved} /> : null}
</Dialog>
);
}
function EditDurationForm({
folder,
onClose,
onSaved
}: {
folder: ImageFolder;
onClose: () => void;
onSaved: (durationSeconds: number | null) => void;
}) {
const [value, setValue] = useState(() => (folder.durationSeconds != null ? String(folder.durationSeconds) : ''));
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const save = (clear: boolean) => {
const parsed = clear ? null : Number(value);
if (!clear && (!Number.isFinite(parsed) || (parsed ?? 0) <= 0)) {
setError('Enter a duration greater than zero, or clear it.');
return;
}
setBusy(true);
setError(null);
updateImageFolderDuration(folder.libraryFolderId, parsed)
.then((result) => {
onSaved(result.durationSeconds ?? null);
onClose();
})
.catch((caught: unknown) => {
setError(messageFromImageFolderError(caught, 'Unable to update duration'));
setBusy(false);
});
};
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-5, 10px)' }}>
<Input
label="Seconds per image"
onChange={(event) => setValue(event.target.value)}
placeholder="Inherited from parent"
type="number"
value={value}
/>
{error ? (
<div className="ctv-channels-error" role="alert">
<TriangleAlert aria-hidden="true" size={15} />
<span>{error}</span>
</div>
) : null}
<div style={{ display: 'flex', gap: 'var(--space-4, 8px)', alignItems: 'center' }}>
<Button disabled={busy} onClick={() => save(true)} size="sm" variant="ghost">
Clear
</Button>
<span className="ctv-channels-spacer" />
<Button disabled={busy} onClick={onClose} size="sm" variant="ghost">
Cancel
</Button>
<Button disabled={busy} onClick={() => save(false)} size="sm" variant="primary">
Save
</Button>
</div>
</div>
);
}
function FolderRow({ folder, depth }: { folder: ImageFolder; depth: number }) {
const [expanded, setExpanded] = useState(false);
const [children, setChildren] = useState<ImageFolder[] | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [duration, setDuration] = useState<number | null>(folder.durationSeconds ?? null);
const [editing, setEditing] = useState(false);
const activeRef = useRef(true);
useEffect(() => {
activeRef.current = true;
return () => {
activeRef.current = false;
};
}, []);
const loadChildren = () => {
setLoading(true);
setError(null);
getImageFolders(folder.libraryFolderId)
.then((result) => {
if (activeRef.current) {
setChildren(result);
setLoading(false);
}
})
.catch((caught: unknown) => {
if (activeRef.current) {
setError(messageFromImageFolderError(caught));
setLoading(false);
}
});
};
const toggle = () => {
if (folder.subfolderCount === 0) {
return;
}
if (!expanded && children === null) {
loadChildren();
}
setExpanded((current) => !current);
};
const hasChildren = folder.subfolderCount > 0;
return (
<div className="ctv-imgfolder">
<div className="ctv-imgfolder-row" style={{ paddingLeft: `${depth * 20}px` }}>
<IconButton
disabled={!hasChildren}
onClick={toggle}
size="sm"
title={hasChildren ? (expanded ? 'Collapse' : 'Expand') : 'No subfolders'}
>
{hasChildren ? (
expanded ? (
<ChevronDown aria-hidden="true" size={15} />
) : (
<ChevronRight aria-hidden="true" size={15} />
)
) : (
<FolderTree aria-hidden="true" size={15} />
)}
</IconButton>
<span className="ctv-imgfolder-name" title={folder.fullPath}>
{folder.name}
</span>
<span className="ctv-imgfolder-meta">
{folder.imageCount} image{folder.imageCount === 1 ? '' : 's'} · {folder.subfolderCount} subfolder
{folder.subfolderCount === 1 ? '' : 's'}
</span>
<span className="ctv-imgfolder-duration">
<Clock aria-hidden="true" size={13} />
{duration != null ? `${duration}s` : 'inherited'}
</span>
<span className="ctv-channels-spacer" />
<IconButton onClick={() => setEditing(true)} size="sm" title="Edit duration">
<Pencil aria-hidden="true" size={14} />
</IconButton>
<IconButton onClick={() => searchLink(folder.libraryFolderId)} size="sm" title="Search this folder">
<Search aria-hidden="true" size={14} />
</IconButton>
</div>
{error ? (
<div className="ctv-channels-error" role="alert" style={{ marginLeft: `${depth * 20 + 20}px` }}>
<TriangleAlert aria-hidden="true" size={15} />
<span>{error}</span>
</div>
) : null}
{expanded && loading ? (
<div className="ctv-collections-loading" role="status" style={{ paddingLeft: `${depth * 20 + 20}px` }}>
<Spinner size={16} />
<span>Loading</span>
</div>
) : null}
{expanded && children
? children.map((child) => (
<FolderRow depth={depth + 1} folder={child} key={child.libraryFolderId} />
))
: null}
<EditDurationDialog
folder={folder}
onClose={() => setEditing(false)}
onSaved={(next) => setDuration(next)}
open={editing}
/>
</div>
);
}
export function ImageBrowserScreen() {
const [folders, setFolders] = useState<ImageFolder[]>([]);
const [status, setStatus] = useState<'loading' | 'error' | 'success'>('loading');
const [error, setError] = useState<string | null>(null);
const activeRef = useRef(true);
useEffect(() => {
activeRef.current = true;
getImageFolders()
.then((result) => {
if (activeRef.current) {
setFolders(result);
setStatus('success');
}
})
.catch((caught: unknown) => {
if (activeRef.current) {
setError(messageFromImageFolderError(caught));
setStatus('error');
}
});
return () => {
activeRef.current = false;
};
}, []);
return (
<div className="ctv-collections">
<div className="ctv-channels-actionbar">
<Button
onClick={() => navigateToPath('/app/media?kind=images')}
size="sm"
variant="ghost"
>
Browse images
</Button>
</div>
{status === 'loading' ? (
<div className="ctv-collections-loading" role="status">
<Spinner size={18} />
<span>Loading image folders</span>
</div>
) : status === 'error' ? (
<div className="ctv-channels-error" role="alert">
<TriangleAlert aria-hidden="true" size={15} />
<span>{error}</span>
</div>
) : folders.length === 0 ? (
<Card>
<div className="ctv-collections-empty">No image libraries found.</div>
</Card>
) : (
<Card>
{folders.map((folder) => (
<FolderRow depth={0} folder={folder} key={folder.libraryFolderId} />
))}
</Card>
)}
</div>
);
}
+23 -4
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { ChevronLeft, ChevronRight, Info, RefreshCw, Search, TriangleAlert } from 'lucide-react';
import { ChevronLeft, ChevronRight, FolderTree, Info, RefreshCw, Search, TriangleAlert } from 'lucide-react';
import { Button, Card, IconButton, Input, Select, Spinner } from '../components';
import {
getLibraryBrowseItems,
@@ -8,6 +8,8 @@ import {
type LibraryBrowseMediaType
} from '../api';
import { MediaPosterCard } from '../media/MediaPosterCard';
import { mediaDetailPath } from '../media/mediaKinds';
import { navigateToPath } from '../routing';
const PAGE_SIZE = 100;
@@ -122,6 +124,16 @@ export function MediaBrowseScreen() {
value={queryInput}
/>
<span className="ctv-channels-spacer" />
{kind.slug === 'images' && (
<Button
onClick={() => navigateToPath('/app/media/images/browser')}
size="sm"
startIcon={<FolderTree aria-hidden="true" size={14} />}
variant="secondary"
>
Folder Browser
</Button>
)}
<Button
onClick={refresh}
size="sm"
@@ -157,9 +169,16 @@ export function MediaBrowseScreen() {
) : (
<>
<div className="ctv-media-grid">
{state.items.map((item) => (
<MediaPosterCard item={item} key={`${item.mediaType}-${item.id}`} />
))}
{state.items.map((item) => {
const detailPath = mediaDetailPath(item);
return (
<MediaPosterCard
item={item}
key={`${item.mediaType}-${item.id}`}
onOpen={detailPath ? () => navigateToPath(detailPath) : undefined}
/>
);
})}
</div>
<div className="ctv-channels-footer" style={{ marginTop: 'var(--space-6, 12px)' }}>
<span>
@@ -0,0 +1,53 @@
import { cleanup, render, screen, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { MovieDetailScreen, ShowDetailScreen } from './MediaDetailScreen';
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), { headers: { 'Content-Type': 'application/json' }, status });
}
const movie = {
id: 5,
title: 'The Movie',
year: '1999',
plot: 'A plot',
genres: ['Drama'],
tags: [],
studios: [],
contentRatings: [],
languages: ['English'],
actors: [{ id: 1, name: 'Actor', role: 'Role', thumb: '' }],
directors: [],
writers: [],
path: '/media/movie.mkv',
localPath: '/media/movie.mkv',
state: 'Normal',
poster: '/artwork/posters/p.jpg',
fanArt: ''
};
describe('media detail screens', () => {
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
it('renders a movie title, plot and cast', async () => {
vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse(movie));
render(<MovieDetailScreen id={5} />);
await waitFor(() => expect(screen.getByText('The Movie')).toBeInTheDocument());
expect(screen.getByText('A plot')).toBeInTheDocument();
expect(screen.getByText('Actor')).toBeInTheDocument();
expect(screen.getByText('Drama')).toBeInTheDocument();
});
it('shows a not-found message on 404', async () => {
vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({ detail: 'nope' }, 404));
render(<ShowDetailScreen id={99} />);
await waitFor(() => expect(screen.getByText('Show not found.')).toBeInTheDocument());
});
});
+665
View File
@@ -0,0 +1,665 @@
import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react';
import { ArrowLeft, ChevronLeft, ChevronRight, Info, TriangleAlert } from 'lucide-react';
import { Button, Card, Dialog, IconButton, Spinner, Tag } from '../components';
import {
ApiError,
getArtist,
getLibraryBrowseItems,
getMediaItemInfo,
getMovie,
getSeason,
getShow,
messageFromLibraryBrowseError,
messageFromMediaDetailError,
type ArtistDetail,
type LibraryBrowseItem,
type LibraryBrowseMediaType,
type MediaItemInfo,
type MovieDetail,
type SeasonDetail,
type ShowDetail
} from '../api';
import { MediaPosterCard } from '../media/MediaPosterCard';
import { mediaDetailPath, parseDurationSeconds } from '../media/mediaKinds';
import { navigateToPath } from '../routing';
const CHILD_PAGE_SIZE = 60;
type DetailState<T> =
| { status: 'loading' }
| { status: 'notfound' }
| { status: 'error'; error: string }
| { status: 'success'; data: T };
function useDetail<T>(load: () => Promise<T>): DetailState<T> {
const [state, setState] = useState<DetailState<T>>({ status: 'loading' });
const activeRef = useRef(true);
useEffect(() => {
activeRef.current = true;
load()
.then((data) => {
if (activeRef.current) {
setState({ data, status: 'success' });
}
})
.catch((error: unknown) => {
if (!activeRef.current) {
return;
}
if (error instanceof ApiError && error.status === 404) {
setState({ status: 'notfound' });
} else {
setState({ error: messageFromMediaDetailError(error), status: 'error' });
}
});
return () => {
activeRef.current = false;
};
}, [load]);
return state;
}
function backToBrowse(kindSlug: string) {
navigateToPath(`/app/media?kind=${kindSlug}`);
}
function ChipRow({ label, values }: { label: string; values: string[] | null | undefined }) {
if (!values || values.length === 0) {
return null;
}
return (
<div className="ctv-detail-chiprow">
<span className="ctv-detail-chiplabel">{label}</span>
<div className="ctv-detail-chips">
{values.map((value) => (
<Tag key={`${label}-${value}`}>{value}</Tag>
))}
</div>
</div>
);
}
function ActorsRow({ actors }: { actors: MovieDetail['actors'] }) {
if (!actors || actors.length === 0) {
return null;
}
return (
<div className="ctv-detail-section">
<h3 className="ctv-detail-heading">Cast</h3>
<div className="ctv-detail-actors">
{actors.map((actor) => (
<div className="ctv-detail-actor" key={actor.id}>
{actor.thumb ? (
<img alt="" className="ctv-detail-actor-thumb" loading="lazy" src={actor.thumb} />
) : (
<div className="ctv-detail-actor-thumb ctv-detail-actor-thumb-empty" />
)}
<div className="ctv-detail-actor-name" title={actor.name}>
{actor.name}
</div>
{actor.role ? (
<div className="ctv-detail-actor-role" title={actor.role}>
{actor.role}
</div>
) : null}
</div>
))}
</div>
</div>
);
}
function DetailShell({
fanart,
poster,
title,
subtitle,
plot,
warning,
actions,
chips,
children
}: {
fanart?: string | null;
poster?: string | null;
title: string;
subtitle?: string | null;
plot?: string | null;
warning?: ReactNode;
actions?: ReactNode;
chips?: ReactNode;
children?: ReactNode;
}) {
return (
<div className="ctv-detail">
{fanart ? (
<div className="ctv-detail-fanart" style={{ backgroundImage: `url(${JSON.stringify(fanart)})` }} />
) : null}
<div className="ctv-detail-head">
{poster ? (
<img alt="" className="ctv-detail-poster" src={poster} />
) : (
<div className="ctv-detail-poster ctv-detail-poster-empty" />
)}
<div className="ctv-detail-headinfo">
<h2 className="ctv-detail-title">{title}</h2>
{subtitle ? <div className="ctv-detail-subtitle">{subtitle}</div> : null}
{plot ? <p className="ctv-detail-plot">{plot}</p> : null}
{warning}
{actions ? <div className="ctv-detail-actions">{actions}</div> : null}
</div>
</div>
{chips ? <Card>{chips}</Card> : null}
{children}
</div>
);
}
function StateWarning({ state, path }: { state: string | null | undefined; path?: string | null }) {
if (state === 'FileNotFound') {
return (
<div className="ctv-channels-error" role="alert">
<TriangleAlert aria-hidden="true" size={15} />
<span>File not found{path ? `: ${path}` : ''}</span>
</div>
);
}
if (state === 'Unavailable') {
return (
<div className="ctv-channels-error" role="alert">
<TriangleAlert aria-hidden="true" size={15} />
<span>Unavailable{path ? `: ${path}` : ''}</span>
</div>
);
}
return null;
}
// A paged grid of a parent's children (seasons of a show, episodes of a season, an artist's music
// videos) fetched via the browse parentId drill-in.
function ChildGrid({
heading,
mediaType,
parentId
}: {
heading: string;
mediaType: LibraryBrowseMediaType;
parentId: number;
}) {
const [items, setItems] = useState<LibraryBrowseItem[]>([]);
const [totalCount, setTotalCount] = useState(0);
const [pageNum, setPageNum] = useState(0);
const [status, setStatus] = useState<'loading' | 'error' | 'success'>('loading');
const [error, setError] = useState<string | null>(null);
const activeRef = useRef(true);
const seqRef = useRef(0);
useEffect(() => {
activeRef.current = true;
return () => {
activeRef.current = false;
};
}, []);
const load = useCallback(() => {
const id = ++seqRef.current;
getLibraryBrowseItems({ mediaType, pageNum, pageSize: CHILD_PAGE_SIZE, parentId })
.then((paged) => {
if (activeRef.current && id === seqRef.current) {
setItems(paged.page ?? []);
setTotalCount(paged.totalCount ?? 0);
setStatus('success');
}
})
.catch((caught: unknown) => {
if (activeRef.current && id === seqRef.current) {
setError(messageFromLibraryBrowseError(caught));
setStatus('error');
}
});
}, [mediaType, pageNum, parentId]);
useEffect(() => {
load();
}, [load]);
const totalPages = Math.max(1, Math.ceil(totalCount / CHILD_PAGE_SIZE));
return (
<div className="ctv-detail-section">
<h3 className="ctv-detail-heading">{heading}</h3>
{status === 'loading' ? (
<div className="ctv-collections-loading" role="status">
<Spinner size={18} />
<span>Loading</span>
</div>
) : status === 'error' ? (
<div className="ctv-channels-error" role="alert">
<TriangleAlert aria-hidden="true" size={15} />
<span>{error}</span>
<span className="ctv-channels-spacer" />
<Button
onClick={() => {
setStatus('loading');
load();
}}
size="sm"
variant="secondary"
>
Retry
</Button>
</div>
) : items.length === 0 ? (
<Card>
<div className="ctv-collections-empty">Nothing here yet.</div>
</Card>
) : (
<>
<div className="ctv-media-grid">
{items.map((item) => {
const detailPath = mediaDetailPath(item);
return (
<MediaPosterCard
item={item}
key={`${item.mediaType}-${item.id}`}
onOpen={detailPath ? () => navigateToPath(detailPath) : undefined}
/>
);
})}
</div>
{totalPages > 1 ? (
<div className="ctv-channels-footer" style={{ marginTop: 'var(--space-6, 12px)' }}>
<span>{totalCount}</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>
) : null}
</>
)}
</div>
);
}
function formatTicks(value: string | null | undefined): string {
const seconds = parseDurationSeconds(value);
if (seconds == null) {
return '—';
}
const whole = Math.floor(seconds);
const hours = Math.floor(whole / 3600);
const minutes = Math.floor((whole % 3600) / 60);
const secs = whole % 60;
const pad = (n: number) => String(n).padStart(2, '0');
return hours > 0 ? `${hours}:${pad(minutes)}:${pad(secs)}` : `${minutes}:${pad(secs)}`;
}
// The technical media-info dialog (streams + chapters), backed by GET /api/media-items/{id}/info.
// The fetching body is a separate component mounted only while the dialog is open, so its effect
// only ever calls setState asynchronously (no synchronous setState in an effect body).
export function MediaInfoDialog({
mediaItemId,
open,
onClose
}: {
mediaItemId: number;
open: boolean;
onClose: () => void;
}) {
return (
<Dialog onClose={onClose} open={open} title="Media Info" width={720}>
{open ? <MediaInfoBody mediaItemId={mediaItemId} /> : null}
</Dialog>
);
}
function MediaInfoBody({ mediaItemId }: { mediaItemId: number }) {
const [info, setInfo] = useState<MediaItemInfo | null>(null);
const [status, setStatus] = useState<'loading' | 'error' | 'success'>('loading');
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let active = true;
getMediaItemInfo(mediaItemId)
.then((result) => {
if (active) {
setInfo(result);
setStatus('success');
}
})
.catch((caught: unknown) => {
if (active) {
setError(messageFromMediaDetailError(caught, 'Unable to load media info'));
setStatus('error');
}
});
return () => {
active = false;
};
}, [mediaItemId]);
return (
<>
{status === 'loading' ? (
<div className="ctv-collections-loading" role="status">
<Spinner size={18} />
<span>Loading media info</span>
</div>
) : status === 'error' ? (
<div className="ctv-channels-error" role="alert">
<TriangleAlert aria-hidden="true" size={15} />
<span>{error}</span>
</div>
) : info ? (
<div className="ctv-detail-info">
<dl className="ctv-detail-infogrid">
<dt>Kind</dt>
<dd>{info.kind}</dd>
<dt>Library</dt>
<dd>{info.libraryName}</dd>
<dt>Duration</dt>
<dd>{formatTicks(info.duration)}</dd>
<dt>Resolution</dt>
<dd>
{info.width}×{info.height}
</dd>
{info.displayAspectRatio ? (
<>
<dt>Aspect Ratio</dt>
<dd>{info.displayAspectRatio}</dd>
</>
) : null}
</dl>
{info.streams && info.streams.length > 0 ? (
<div className="ctv-detail-info-block">
<h4 className="ctv-detail-heading">Streams</h4>
<div className="ctv-channels-table-scroll">
<table className="ctv-channels-table">
<thead>
<tr>
<th>#</th>
<th>Kind</th>
<th>Codec</th>
<th>Language</th>
<th>Title</th>
</tr>
</thead>
<tbody>
{info.streams.map((stream, index) => (
<tr key={`${stream.kind}-${stream.index ?? index}`}>
<td>{stream.index ?? '—'}</td>
<td>{stream.kind}</td>
<td>{stream.codec ?? '—'}</td>
<td>{stream.language ?? '—'}</td>
<td>{stream.title ?? '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
) : null}
{info.chapters && info.chapters.length > 0 ? (
<div className="ctv-detail-info-block">
<h4 className="ctv-detail-heading">Chapters</h4>
<div className="ctv-channels-table-scroll">
<table className="ctv-channels-table">
<thead>
<tr>
<th>Title</th>
<th>Start</th>
<th>End</th>
</tr>
</thead>
<tbody>
{info.chapters.map((chapter, index) => (
<tr key={`chapter-${index}`}>
<td>{chapter.title || `Chapter ${index + 1}`}</td>
<td>{formatTicks(chapter.startTime)}</td>
<td>{formatTicks(chapter.endTime)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
) : null}
</div>
) : null}
</>
);
}
function NotFound({ kindSlug, label }: { kindSlug: string; label: string }) {
return (
<div className="ctv-detail">
<Button onClick={() => backToBrowse(kindSlug)} size="sm" startIcon={<ArrowLeft size={14} />} variant="ghost">
Back
</Button>
<Card>
<div className="ctv-collections-empty">{label} not found.</div>
</Card>
</div>
);
}
function ErrorState({ message, kindSlug }: { message: string; kindSlug: string }) {
return (
<div className="ctv-detail">
<Button onClick={() => backToBrowse(kindSlug)} size="sm" startIcon={<ArrowLeft size={14} />} variant="ghost">
Back
</Button>
<div className="ctv-channels-error" role="alert">
<TriangleAlert aria-hidden="true" size={15} />
<span>{message}</span>
</div>
</div>
);
}
function Loading() {
return (
<div className="ctv-collections-loading" role="status">
<Spinner size={18} />
<span>Loading</span>
</div>
);
}
export function MovieDetailScreen({ id }: { id: number }) {
const state = useDetail<MovieDetail>(useCallback(() => getMovie(id), [id]));
const [infoOpen, setInfoOpen] = useState(false);
if (state.status === 'loading') {
return <Loading />;
}
if (state.status === 'notfound') {
return <NotFound kindSlug="movies" label="Movie" />;
}
if (state.status === 'error') {
return <ErrorState kindSlug="movies" message={state.error} />;
}
const movie = state.data;
const displayPath = movie.state === 'FileNotFound' ? movie.path : movie.localPath || movie.path;
return (
<>
<Button onClick={() => backToBrowse('movies')} size="sm" startIcon={<ArrowLeft size={14} />} variant="ghost">
Back
</Button>
<DetailShell
actions={
<>
<Button onClick={() => setInfoOpen(true)} size="sm" startIcon={<Info size={14} />} variant="secondary">
Media Info
</Button>
{/* Add-to-collection / add-to-playlist mutations are out of scope here; tracked by #153 / #155. */}
</>
}
chips={
<div className="ctv-detail-chipstack">
<ChipRow label="Genres" values={movie.genres} />
<ChipRow label="Tags" values={movie.tags} />
<ChipRow label="Studios" values={movie.studios} />
<ChipRow label="Content Ratings" values={movie.contentRatings} />
<ChipRow label="Languages" values={movie.languages} />
<ChipRow label="Directors" values={movie.directors} />
<ChipRow label="Writers" values={movie.writers} />
</div>
}
fanart={movie.fanArt}
plot={movie.plot}
poster={movie.poster}
subtitle={movie.year}
title={movie.title}
warning={<StateWarning path={displayPath} state={movie.state} />}
>
<ActorsRow actors={movie.actors} />
{displayPath ? <div className="ctv-detail-path">{displayPath}</div> : null}
</DetailShell>
<MediaInfoDialog mediaItemId={id} onClose={() => setInfoOpen(false)} open={infoOpen} />
</>
);
}
export function ShowDetailScreen({ id }: { id: number }) {
const state = useDetail<ShowDetail>(useCallback(() => getShow(id), [id]));
if (state.status === 'loading') {
return <Loading />;
}
if (state.status === 'notfound') {
return <NotFound kindSlug="shows" label="Show" />;
}
if (state.status === 'error') {
return <ErrorState kindSlug="shows" message={state.error} />;
}
const show = state.data;
return (
<>
<Button onClick={() => backToBrowse('shows')} size="sm" startIcon={<ArrowLeft size={14} />} variant="ghost">
Back
</Button>
<DetailShell
chips={
<div className="ctv-detail-chipstack">
<ChipRow label="Genres" values={show.genres} />
<ChipRow label="Tags" values={show.tags} />
<ChipRow label="Studios" values={show.studios} />
<ChipRow label="Networks" values={show.networks} />
<ChipRow label="Content Ratings" values={show.contentRatings} />
<ChipRow label="Languages" values={show.languages} />
</div>
}
fanart={show.fanArt}
plot={show.plot}
poster={show.poster}
subtitle={show.year}
title={show.title}
>
<ActorsRow actors={show.actors} />
<ChildGrid heading="Seasons" mediaType="TelevisionSeason" parentId={id} />
</DetailShell>
</>
);
}
export function SeasonDetailScreen({ id }: { id: number }) {
const state = useDetail<SeasonDetail>(useCallback(() => getSeason(id), [id]));
if (state.status === 'loading') {
return <Loading />;
}
if (state.status === 'notfound') {
return <NotFound kindSlug="shows" label="Season" />;
}
if (state.status === 'error') {
return <ErrorState kindSlug="shows" message={state.error} />;
}
const season = state.data;
return (
<>
<Button
onClick={() => navigateToPath(`/app/media/shows/${season.showId}`)}
size="sm"
startIcon={<ArrowLeft size={14} />}
variant="ghost"
>
Back to show
</Button>
<DetailShell
fanart={season.fanArt}
poster={season.poster}
subtitle={season.year}
title={`${season.title}${season.name}`}
>
<ChildGrid heading="Episodes" mediaType="Episode" parentId={id} />
</DetailShell>
</>
);
}
export function ArtistDetailScreen({ id }: { id: number }) {
const state = useDetail<ArtistDetail>(useCallback(() => getArtist(id), [id]));
if (state.status === 'loading') {
return <Loading />;
}
if (state.status === 'notfound') {
return <NotFound kindSlug="artists" label="Artist" />;
}
if (state.status === 'error') {
return <ErrorState kindSlug="artists" message={state.error} />;
}
const artist = state.data;
return (
<>
<Button onClick={() => backToBrowse('artists')} size="sm" startIcon={<ArrowLeft size={14} />} variant="ghost">
Back
</Button>
<DetailShell
chips={
<div className="ctv-detail-chipstack">
<ChipRow label="Genres" values={artist.genres} />
<ChipRow label="Styles" values={artist.styles} />
<ChipRow label="Moods" values={artist.moods} />
<ChipRow label="Languages" values={artist.languages} />
</div>
}
fanart={artist.fanArt}
plot={artist.biography}
poster={artist.thumbnail}
subtitle={artist.disambiguation}
title={artist.name}
>
<ChildGrid heading="Music Videos" mediaType="MusicVideo" parentId={id} />
</DetailShell>
</>
);
}
+228
View File
@@ -2580,6 +2580,234 @@
white-space: nowrap;
}
/* Media detail pages (#141/#161) */
.ctv-detail {
position: relative;
display: flex;
flex-direction: column;
gap: var(--space-6, 12px);
}
.ctv-detail-fanart {
position: absolute;
inset: -8px -8px auto -8px;
height: 260px;
background-size: cover;
background-position: center 20%;
opacity: 0.12;
filter: saturate(0.9);
border-radius: var(--radius-md, 8px);
pointer-events: none;
z-index: 0;
-webkit-mask-image: linear-gradient(to bottom, rgb(0 0 0 / 100%), transparent);
mask-image: linear-gradient(to bottom, rgb(0 0 0 / 100%), transparent);
}
.ctv-detail-head {
position: relative;
z-index: 1;
display: flex;
gap: var(--space-7, 16px);
flex-wrap: wrap;
}
.ctv-detail-poster {
width: 200px;
max-width: 40vw;
aspect-ratio: 2 / 3;
object-fit: cover;
border-radius: var(--radius-sm);
border: 1px solid var(--border-hairline);
background: var(--surface-card);
flex-shrink: 0;
}
.ctv-detail-poster-empty {
background: repeating-linear-gradient(135deg, var(--surface-card) 0 8px, var(--surface-app) 8px 16px);
}
.ctv-detail-headinfo {
display: flex;
flex-direction: column;
gap: var(--space-4, 8px);
min-width: 260px;
flex: 1;
}
.ctv-detail-title {
margin: 0;
font: var(--weight-semibold) var(--text-xl, 20px) / 1.2 var(--font-sans);
color: var(--text-primary);
}
.ctv-detail-subtitle {
color: var(--text-secondary);
font-size: var(--text-sm, 13px);
}
.ctv-detail-plot {
margin: 0;
color: var(--text-primary);
line-height: 1.5;
max-width: 70ch;
}
.ctv-detail-actions {
display: flex;
gap: var(--space-5, 10px);
flex-wrap: wrap;
margin-top: var(--space-4, 8px);
}
.ctv-detail-path {
font-family: var(--font-mono, monospace);
font-size: var(--text-xs, 12px);
color: var(--text-secondary);
word-break: break-all;
}
.ctv-detail-chipstack {
display: flex;
flex-direction: column;
gap: var(--space-5, 10px);
}
.ctv-detail-chiprow {
display: flex;
gap: var(--space-5, 10px);
align-items: baseline;
flex-wrap: wrap;
}
.ctv-detail-chiplabel {
min-width: 130px;
color: var(--text-disabled);
font-size: var(--text-2xs, 11px);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.ctv-detail-chips {
display: flex;
gap: var(--space-3, 6px);
flex-wrap: wrap;
flex: 1;
}
.ctv-detail-section {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
gap: var(--space-5, 10px);
}
.ctv-detail-heading {
margin: 0;
font: var(--weight-semibold) var(--text-md, 15px) / 1.2 var(--font-sans);
color: var(--text-primary);
}
.ctv-detail-actors {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
gap: var(--space-6, 12px);
}
.ctv-detail-actor {
display: flex;
flex-direction: column;
gap: 4px;
text-align: center;
}
.ctv-detail-actor-thumb {
width: 100%;
aspect-ratio: 1 / 1;
object-fit: cover;
border-radius: 50%;
border: 1px solid var(--border-hairline);
}
.ctv-detail-actor-thumb-empty {
background: var(--surface-card);
}
.ctv-detail-actor-name {
font-size: var(--text-xs, 12px);
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ctv-detail-actor-role {
font-size: var(--text-2xs, 11px);
color: var(--text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ctv-detail-info {
display: flex;
flex-direction: column;
gap: var(--space-7, 16px);
}
.ctv-detail-infogrid {
display: grid;
grid-template-columns: max-content 1fr;
gap: var(--space-3, 6px) var(--space-6, 12px);
margin: 0;
}
.ctv-detail-infogrid dt {
color: var(--text-disabled);
font-size: var(--text-2xs, 11px);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.ctv-detail-infogrid dd {
margin: 0;
color: var(--text-primary);
font-size: var(--text-sm, 13px);
}
.ctv-detail-info-block {
display: flex;
flex-direction: column;
gap: var(--space-4, 8px);
}
.ctv-imgfolder-row {
display: flex;
align-items: center;
gap: var(--space-4, 8px);
padding: var(--space-3, 6px) 0;
border-bottom: 1px solid var(--border-hairline);
}
.ctv-imgfolder-name {
font-size: var(--text-sm, 13px);
color: var(--text-primary);
font-weight: var(--weight-medium, 500);
}
.ctv-imgfolder-meta {
font-size: var(--text-2xs, 11px);
color: var(--text-secondary);
}
.ctv-imgfolder-duration {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: var(--text-2xs, 11px);
color: var(--text-disabled);
}
.ctv-media-section-header {
display: flex;
align-items: baseline;