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
This commit was merged in pull request #183.
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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><img src></c> URL for the React SPA. The Blazor pages prefix "artwork/{folder}/" themselves and
|
||||
/// resolve relative to <c><base href="/"></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
@@ -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') {
|
||||
|
||||
Vendored
+125
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user