diff --git a/ErsatzTV.Application/Images/Queries/ImageFolderExists.cs b/ErsatzTV.Application/Images/Queries/ImageFolderExists.cs new file mode 100644 index 000000000..81d1b32a6 --- /dev/null +++ b/ErsatzTV.Application/Images/Queries/ImageFolderExists.cs @@ -0,0 +1,3 @@ +namespace ErsatzTV.Application.Images; + +public record ImageFolderExists(int LibraryFolderId) : IRequest; diff --git a/ErsatzTV.Application/Images/Queries/ImageFolderExistsHandler.cs b/ErsatzTV.Application/Images/Queries/ImageFolderExistsHandler.cs new file mode 100644 index 000000000..e44976b20 --- /dev/null +++ b/ErsatzTV.Application/Images/Queries/ImageFolderExistsHandler.cs @@ -0,0 +1,21 @@ +using ErsatzTV.Core.Domain; +using ErsatzTV.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; + +namespace ErsatzTV.Application.Images; + +public class ImageFolderExistsHandler(IDbContextFactory dbContextFactory) + : IRequestHandler +{ + public async Task 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); + } +} diff --git a/ErsatzTV.Application/LibraryBrowse/Queries/GetLibraryBrowseItemsHandler.cs b/ErsatzTV.Application/LibraryBrowse/Queries/GetLibraryBrowseItemsHandler.cs index 9c12f2eb7..52753b80c 100644 --- a/ErsatzTV.Application/LibraryBrowse/Queries/GetLibraryBrowseItemsHandler.cs +++ b/ErsatzTV.Application/LibraryBrowse/Queries/GetLibraryBrowseItemsHandler.cs @@ -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 BrowseEpisodesForSeason( + TvContext dbContext, + GetLibraryBrowseItems request, + CancellationToken cancellationToken) + { + List 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 pageIds = allEpisodeIds + .Skip(request.PageNum * request.PageSize) + .Take(request.PageSize) + .ToList(); + + List episodes = await GetEpisodes(dbContext, pageIds, cancellationToken); + + Dictionary byId = episodes.ToDictionary(e => e.Id); + List 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 BrowseMusicVideosForArtist( + TvContext dbContext, + GetLibraryBrowseItems request, + CancellationToken cancellationToken) + { + List 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 pageIds = allMusicVideoIds + .Skip(request.PageNum * request.PageSize) + .Take(request.PageSize) + .ToList(); + + List musicVideos = await GetMusicVideos(dbContext, pageIds, cancellationToken); + + Dictionary byId = musicVideos.ToDictionary(mv => mv.Id); + List ordered = pageIds + .Where(byId.ContainsKey) + .Select(id => byId[id]) + .ToList(); + + return new PagedLibraryBrowseItemsResponseModel(total, ordered); + } + private static async Task> GetSeasons( TvContext dbContext, List ids, diff --git a/ErsatzTV.Core/Api/ApiArtwork.cs b/ErsatzTV.Core/Api/ApiArtwork.cs new file mode 100644 index 000000000..4f3819d06 --- /dev/null +++ b/ErsatzTV.Core/Api/ApiArtwork.cs @@ -0,0 +1,66 @@ +#nullable enable +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Emby; +using ErsatzTV.Core.Jellyfin; +using Flurl; + +namespace ErsatzTV.Core.Api; + +/// +/// Roots an artwork value (as produced by the Application view-model mappers) into a directly-usable +/// <img src> URL for the React SPA. The Blazor pages prefix "artwork/{folder}/" themselves and +/// resolve relative to <base href="/">, but the SPA renders the value raw from under /app/, +/// so the API must root the URL itself (issue #180/#181). Mirrors the projection helper in +/// GetLibraryBrowseItemsHandler so detail endpoints stay consistent with the browse grid. +/// +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}"; + } +} diff --git a/ErsatzTV.Core/Api/Artists/ArtistDetailResponseModel.cs b/ErsatzTV.Core/Api/Artists/ArtistDetailResponseModel.cs new file mode 100644 index 000000000..7fc44c911 --- /dev/null +++ b/ErsatzTV.Core/Api/Artists/ArtistDetailResponseModel.cs @@ -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 Genres, + List Styles, + List Moods, + List Languages); diff --git a/ErsatzTV.Core/Api/Images/ImageFolderResponseModel.cs b/ErsatzTV.Core/Api/Images/ImageFolderResponseModel.cs new file mode 100644 index 000000000..740427aec --- /dev/null +++ b/ErsatzTV.Core/Api/Images/ImageFolderResponseModel.cs @@ -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); diff --git a/ErsatzTV.Core/Api/Images/UpdateImageFolderDurationResponseModel.cs b/ErsatzTV.Core/Api/Images/UpdateImageFolderDurationResponseModel.cs new file mode 100644 index 000000000..5e7c91961 --- /dev/null +++ b/ErsatzTV.Core/Api/Images/UpdateImageFolderDurationResponseModel.cs @@ -0,0 +1,4 @@ +#nullable enable +namespace ErsatzTV.Core.Api.Images; + +public record UpdateImageFolderDurationResponseModel(double? DurationSeconds); diff --git a/ErsatzTV.Core/Api/Media/ActorResponseModel.cs b/ErsatzTV.Core/Api/Media/ActorResponseModel.cs new file mode 100644 index 000000000..baece2e0d --- /dev/null +++ b/ErsatzTV.Core/Api/Media/ActorResponseModel.cs @@ -0,0 +1,8 @@ +#nullable enable +namespace ErsatzTV.Core.Api.Media; + +public record ActorResponseModel( + int Id, + string Name, + string? Role, + string Thumb); diff --git a/ErsatzTV.Core/Api/MediaItems/MediaItemInfoResponseModel.cs b/ErsatzTV.Core/Api/MediaItems/MediaItemInfoResponseModel.cs new file mode 100644 index 000000000..58557e48d --- /dev/null +++ b/ErsatzTV.Core/Api/MediaItems/MediaItemInfoResponseModel.cs @@ -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 Streams, + List 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); diff --git a/ErsatzTV.Core/Api/Movies/MovieDetailResponseModel.cs b/ErsatzTV.Core/Api/Movies/MovieDetailResponseModel.cs new file mode 100644 index 000000000..2c9fdec38 --- /dev/null +++ b/ErsatzTV.Core/Api/Movies/MovieDetailResponseModel.cs @@ -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 Genres, + List Tags, + List Studios, + List ContentRatings, + List Languages, + List Actors, + List Directors, + List Writers, + string? Path, + string? LocalPath, + MediaItemState State, + string Poster, + string FanArt); diff --git a/ErsatzTV.Core/Api/Television/SeasonDetailResponseModel.cs b/ErsatzTV.Core/Api/Television/SeasonDetailResponseModel.cs new file mode 100644 index 000000000..9faaf9e51 --- /dev/null +++ b/ErsatzTV.Core/Api/Television/SeasonDetailResponseModel.cs @@ -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); diff --git a/ErsatzTV.Core/Api/Television/ShowDetailResponseModel.cs b/ErsatzTV.Core/Api/Television/ShowDetailResponseModel.cs new file mode 100644 index 000000000..9fe57469b --- /dev/null +++ b/ErsatzTV.Core/Api/Television/ShowDetailResponseModel.cs @@ -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 Genres, + List Tags, + List Studios, + List Networks, + List ContentRatings, + List Languages, + List Actors); diff --git a/ErsatzTV.Tests/Application/LibraryBrowse/GetLibraryBrowseItemsHandlerTests.cs b/ErsatzTV.Tests/Application/LibraryBrowse/GetLibraryBrowseItemsHandlerTests.cs index 01868c795..b04b5c3b8 100644 --- a/ErsatzTV.Tests/Application/LibraryBrowse/GetLibraryBrowseItemsHandlerTests.cs +++ b/ErsatzTV.Tests/Application/LibraryBrowse/GetLibraryBrowseItemsHandlerTests.cs @@ -272,6 +272,55 @@ public class GetLibraryBrowseItemsHandlerTests Arg.Any()); } + [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(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any>(), + Arg.Any()); + } + + [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(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any>(), + Arg.Any()); + } + [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(); diff --git a/ErsatzTV.Tests/Controllers/ApiControllerSecurityTests.cs b/ErsatzTV.Tests/Controllers/ApiControllerSecurityTests.cs index f5c1cfa03..5b0331282 100644 --- a/ErsatzTV.Tests/Controllers/ApiControllerSecurityTests.cs +++ b/ErsatzTV.Tests/Controllers/ApiControllerSecurityTests.cs @@ -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), diff --git a/ErsatzTV.Tests/Controllers/ImagesControllerTests.cs b/ErsatzTV.Tests/Controllers/ImagesControllerTests.cs new file mode 100644 index 000000000..5ed875e5f --- /dev/null +++ b/ErsatzTV.Tests/Controllers/ImagesControllerTests.cs @@ -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(); + _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(), Arg.Any()) + .Returns( + [ + new ImageFolderViewModel(1, "Root", "/images", 2, 5, Option.None), + new ImageFolderViewModel(2, "Child", "/images/child", 0, 3, Option.Some(4.5)) + ]); + + List 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(), Arg.Any()).Returns([]); + + await _controller.GetFolders(null, CancellationToken.None); + + await _mediator.Received().Send( + Arg.Is(q => q.LibraryFolderId.IsNone), + Arg.Any()); + } + + [Test] + public async Task GetFolders_Should_Pass_Some_When_ParentId_Given() + { + _mediator.Send(Arg.Any(), Arg.Any()).Returns([]); + + await _controller.GetFolders(42, CancellationToken.None); + + await _mediator.Received().Send( + Arg.Is(q => q.LibraryFolderId == Option.Some(42)), + Arg.Any()); + } + + [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().StatusCode.ShouldBe(400); + await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); + } + + [Test] + public async Task UpdateDuration_Should_Return_404_When_Folder_Missing() + { + _mediator.Send(Arg.Any(), Arg.Any()).Returns(false); + + IActionResult result = await _controller.UpdateDuration( + 1, + new UpdateImageFolderDurationRequest(3.0), + CancellationToken.None); + + result.ShouldBeOfType().StatusCode.ShouldBe(404); + await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); + } + + [Test] + public async Task UpdateDuration_Should_Return_200_And_Update_When_Valid() + { + _mediator.Send(Arg.Any(), Arg.Any()).Returns(true); + _mediator.Send(Arg.Any(), Arg.Any()).Returns(3.0); + + IActionResult result = await _controller.UpdateDuration( + 1, + new UpdateImageFolderDurationRequest(3.0), + CancellationToken.None); + + var ok = result.ShouldBeOfType(); + ok.Value.ShouldBeOfType().DurationSeconds.ShouldBe(3.0); + } + + [Test] + public async Task UpdateDuration_Should_Allow_Null_To_Clear() + { + _mediator.Send(Arg.Any(), Arg.Any()).Returns(true); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns((double?)null); + + IActionResult result = await _controller.UpdateDuration( + 1, + new UpdateImageFolderDurationRequest(null), + CancellationToken.None); + + result.ShouldBeOfType() + .Value.ShouldBeOfType().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(inherit: true).Single(); + attribute.HttpMethods.ShouldContain(httpMethod); + attribute.Template.ShouldBe(route); + } +} diff --git a/ErsatzTV.Tests/Controllers/MediaDetailControllerTests.cs b/ErsatzTV.Tests/Controllers/MediaDetailControllerTests.cs new file mode 100644 index 000000000..22e0b0def --- /dev/null +++ b/ErsatzTV.Tests/Controllers/MediaDetailControllerTests.cs @@ -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(); + + [Test] + public void Controllers_Should_Expose_Idiomatic_Rest_Routes() + { + ShouldHaveActionRoute(nameof(MoviesController.GetById), "GET", "/api/movies/{id:int}"); + ShouldHaveActionRoute(nameof(ShowsController.GetById), "GET", "/api/shows/{id:int}"); + ShouldHaveActionRoute(nameof(SeasonsController.GetById), "GET", "/api/seasons/{id:int}"); + ShouldHaveActionRoute(nameof(ArtistsController.GetById), "GET", "/api/artists/{id:int}"); + ShouldHaveActionRoute( + 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(), Arg.Any()) + .Returns(Option.Some(vm)); + + var controller = new MoviesController(_mediator); + IActionResult result = await controller.GetById(5, CancellationToken.None); + + var ok = result.ShouldBeOfType(); + var body = ok.Value.ShouldBeOfType(); + 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(), Arg.Any()) + .Returns(Option.None); + + var controller = new MoviesController(_mediator); + IActionResult result = await controller.GetById(5, CancellationToken.None); + + result.ShouldBeOfType().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(), Arg.Any()) + .Returns(Option.Some(vm)); + + var controller = new ShowsController(_mediator); + IActionResult result = await controller.GetById(3, CancellationToken.None); + + var body = result.ShouldBeOfType().Value.ShouldBeOfType(); + 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(), Arg.Any()) + .Returns(Option.None); + + var controller = new ShowsController(_mediator); + (await controller.GetById(3, CancellationToken.None)).ShouldBeOfType(); + } + + [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(), Arg.Any()) + .Returns(Option.Some(vm)); + + var controller = new SeasonsController(_mediator); + var body = (await controller.GetById(4, CancellationToken.None)) + .ShouldBeOfType().Value.ShouldBeOfType(); + 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(), Arg.Any()) + .Returns(Option.None); + + var controller = new SeasonsController(_mediator); + (await controller.GetById(4, CancellationToken.None)).ShouldBeOfType(); + } + + [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(), Arg.Any()) + .Returns(Option.Some(vm)); + + var controller = new ArtistsController(_mediator); + var body = (await controller.GetById(6, CancellationToken.None)) + .ShouldBeOfType().Value.ShouldBeOfType(); + 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(), Arg.Any()) + .Returns(Option.None); + + var controller = new ArtistsController(_mediator); + (await controller.GetById(6, CancellationToken.None)).ShouldBeOfType(); + } + + [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(), Arg.Any()) + .Returns(Either.Right(info)); + + var controller = new MediaItemsController(_mediator); + var body = (await controller.GetInfo(9, CancellationToken.None)) + .ShouldBeOfType().Value.ShouldBeOfType(); + 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(), Arg.Any()) + .Returns(Either.Left(new UnableToLocateMediaItem())); + + var controller = new MediaItemsController(_mediator); + (await controller.GetInfo(9, CancellationToken.None)) + .ShouldBeOfType().StatusCode.ShouldBe(404); + } + + [Test] + public async Task MediaItemInfo_Should_Return_422_On_Other_Error() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Either.Left(BaseError.New("boom"))); + + var controller = new MediaItemsController(_mediator); + (await controller.GetInfo(9, CancellationToken.None)) + .ShouldBeOfType().StatusCode.ShouldBe(422); + } + + private static void ShouldHaveActionRoute(string actionName, string httpMethod, string route) + { + MethodInfo action = typeof(TController).GetMethod(actionName) + ?? throw new AssertionException($"Missing action {actionName}"); + + HttpMethodAttribute attribute = action.GetCustomAttributes(inherit: true).Single(); + attribute.HttpMethods.ShouldContain(httpMethod); + attribute.Template.ShouldBe(route); + } +} diff --git a/ErsatzTV/Controllers/Api/ArtistsController.cs b/ErsatzTV/Controllers/Api/ArtistsController.cs new file mode 100644 index 000000000..06fef7283 --- /dev/null +++ b/ErsatzTV/Controllers/Api/ArtistsController.cs @@ -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 GetById(int id, CancellationToken cancellationToken) + { + Option 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()); +} diff --git a/ErsatzTV/Controllers/Api/ImagesController.cs b/ErsatzTV/Controllers/Api/ImagesController.cs new file mode 100644 index 000000000..ac687eab8 --- /dev/null +++ b/ErsatzTV/Controllers/Api/ImagesController.cs @@ -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), StatusCodes.Status200OK)] + public async Task> GetFolders( + [FromQuery] + [Description("Parent image library-folder id; omit for the top-level folders")] + int? parentId = null, + CancellationToken cancellationToken = default) + { + Option maybeParentId = parentId ?? Option.None; + List 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 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()); +} diff --git a/ErsatzTV/Controllers/Api/LibraryBrowseController.cs b/ErsatzTV/Controllers/Api/LibraryBrowseController.cs index 3dcb7f1d3..cba075e64 100644 --- a/ErsatzTV/Controllers/Api/LibraryBrowseController.cs +++ b/ErsatzTV/Controllers/Api/LibraryBrowseController.cs @@ -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) { diff --git a/ErsatzTV/Controllers/Api/MediaItemsController.cs b/ErsatzTV/Controllers/Api/MediaItemsController.cs index 8b08f9b47..aecec794b 100644 --- a/ErsatzTV/Controllers/Api/MediaItemsController.cs +++ b/ErsatzTV/Controllers/Api/MediaItemsController.cs @@ -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 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 GetInfo(int id, CancellationToken cancellationToken) + { + Either 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); } diff --git a/ErsatzTV/Controllers/Api/MoviesController.cs b/ErsatzTV/Controllers/Api/MoviesController.cs new file mode 100644 index 000000000..e1feee55f --- /dev/null +++ b/ErsatzTV/Controllers/Api/MoviesController.cs @@ -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 GetById(int id, CancellationToken cancellationToken) + { + Option 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)); +} diff --git a/ErsatzTV/Controllers/Api/Requests/UpdateImageFolderDurationRequest.cs b/ErsatzTV/Controllers/Api/Requests/UpdateImageFolderDurationRequest.cs new file mode 100644 index 000000000..b13a75c82 --- /dev/null +++ b/ErsatzTV/Controllers/Api/Requests/UpdateImageFolderDurationRequest.cs @@ -0,0 +1,3 @@ +namespace ErsatzTV.Controllers.Api.Requests; + +public record UpdateImageFolderDurationRequest(double? DurationSeconds); diff --git a/ErsatzTV/Controllers/Api/SeasonsController.cs b/ErsatzTV/Controllers/Api/SeasonsController.cs new file mode 100644 index 000000000..495cc3977 --- /dev/null +++ b/ErsatzTV/Controllers/Api/SeasonsController.cs @@ -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 GetById(int id, CancellationToken cancellationToken) + { + Option 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)); +} diff --git a/ErsatzTV/Controllers/Api/ShowsController.cs b/ErsatzTV/Controllers/Api/ShowsController.cs new file mode 100644 index 000000000..2966f2698 --- /dev/null +++ b/ErsatzTV/Controllers/Api/ShowsController.cs @@ -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 GetById(int id, CancellationToken cancellationToken) + { + Option 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()); +} diff --git a/ErsatzTV/wwwroot/openapi/v1.json b/ErsatzTV/wwwroot/openapi/v1.json index 894ac4dce..f70274272 100644 --- a/ErsatzTV/wwwroot/openapi/v1.json +++ b/ErsatzTV/wwwroot/openapi/v1.json @@ -5,6 +5,68 @@ "version": "1.0.0" }, "paths": { + "/api/artists/{id}": { + "get": { + "tags": [ + "Artists" + ], + "summary": "Get an artist by id", + "operationId": "GetArtistById", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ArtistDetailResponseModel" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ArtistDetailResponseModel" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ArtistDetailResponseModel" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, "/api/artwork/uploads": { "post": { "tags": [ @@ -4807,6 +4869,166 @@ } } }, + "/api/images/folders": { + "get": { + "tags": [ + "Images" + ], + "summary": "List image library folders", + "description": "Omit parentId for the top-level folders; pass a folder id to list that folder's children.", + "operationId": "GetImageFolders", + "parameters": [ + { + "name": "parentId", + "in": "query", + "description": "Parent image library-folder id; omit for the top-level folders", + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ImageFolderResponseModel" + } + } + }, + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ImageFolderResponseModel" + } + } + }, + "text/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ImageFolderResponseModel" + } + } + } + } + } + } + } + }, + "/api/images/folders/{id}/duration": { + "put": { + "tags": [ + "Images" + ], + "summary": "Set or clear an image folder's playout duration", + "description": "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.", + "operationId": "UpdateImageFolderDuration", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "requestBody": { + "content": { + "application/json-patch+json": { + "schema": { + "$ref": "#/components/schemas/UpdateImageFolderDurationRequest" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateImageFolderDurationRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/UpdateImageFolderDurationRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/UpdateImageFolderDurationRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/UpdateImageFolderDurationResponseModel" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateImageFolderDurationResponseModel" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/UpdateImageFolderDurationResponseModel" + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, "/api/libraries/scan-status": { "get": { "tags": [ @@ -4972,7 +5194,7 @@ { "name": "parentId", "in": "query", - "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", "schema": { "type": "integer", "format": "int32" @@ -5170,6 +5392,88 @@ } } }, + "/api/media-items/{id}/info": { + "get": { + "tags": [ + "Media Items" + ], + "summary": "Get technical media info for a media item", + "operationId": "GetMediaItemInfo", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/MediaItemInfoResponseModel" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/MediaItemInfoResponseModel" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/MediaItemInfoResponseModel" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "422": { + "description": "Unprocessable Entity", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, "/api/media-sources": { "get": { "tags": [ @@ -5210,6 +5514,68 @@ } } }, + "/api/movies/{id}": { + "get": { + "tags": [ + "Movies" + ], + "summary": "Get a movie by id", + "operationId": "GetMovieById", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/MovieDetailResponseModel" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/MovieDetailResponseModel" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/MovieDetailResponseModel" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, "/api/playlists/groups": { "get": { "tags": [ @@ -7930,6 +8296,68 @@ } } }, + "/api/seasons/{id}": { + "get": { + "tags": [ + "Television" + ], + "summary": "Get a television season by id", + "operationId": "GetSeasonById", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/SeasonDetailResponseModel" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/SeasonDetailResponseModel" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/SeasonDetailResponseModel" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, "/api/sessions": { "get": { "tags": [ @@ -8867,6 +9295,68 @@ } } }, + "/api/shows/{id}": { + "get": { + "tags": [ + "Television" + ], + "summary": "Get a television show by id", + "operationId": "GetShowById", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ShowDetailResponseModel" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ShowDetailResponseModel" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ShowDetailResponseModel" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, "/api/smart-collections": { "get": { "tags": [ @@ -11219,6 +11709,33 @@ }, "components": { "schemas": { + "ActorResponseModel": { + "required": [ + "id", + "name", + "role", + "thumb" + ], + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "name": { + "type": "string" + }, + "role": { + "type": [ + "null", + "string" + ] + }, + "thumb": { + "type": "string" + } + } + }, "AddItemsToCollectionRequest": { "required": [ "movieIds", @@ -11350,6 +11867,72 @@ } } }, + "ArtistDetailResponseModel": { + "required": [ + "id", + "name", + "disambiguation", + "biography", + "thumbnail", + "fanArt", + "genres", + "styles", + "moods", + "languages" + ], + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "name": { + "type": "string" + }, + "disambiguation": { + "type": [ + "null", + "string" + ] + }, + "biography": { + "type": [ + "null", + "string" + ] + }, + "thumbnail": { + "type": "string" + }, + "fanArt": { + "type": "string" + }, + "genres": { + "type": "array", + "items": { + "type": "string" + } + }, + "styles": { + "type": "array", + "items": { + "type": "string" + } + }, + "moods": { + "type": "array", + "items": { + "type": "string" + } + }, + "languages": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, "ArtworkContentTypeModel": { "required": [ "path", @@ -14928,6 +15511,44 @@ "type": "string", "format": "binary" }, + "ImageFolderResponseModel": { + "required": [ + "libraryFolderId", + "name", + "fullPath", + "subfolderCount", + "imageCount", + "durationSeconds" + ], + "type": "object", + "properties": { + "libraryFolderId": { + "type": "integer", + "format": "int32" + }, + "name": { + "type": "string" + }, + "fullPath": { + "type": "string" + }, + "subfolderCount": { + "type": "integer", + "format": "int32" + }, + "imageCount": { + "type": "integer", + "format": "int32" + }, + "durationSeconds": { + "type": [ + "null", + "number" + ], + "format": "double" + } + } + }, "LibraryBrowseItemResponseModel": { "required": [ "id", @@ -15232,6 +15853,271 @@ } } }, + "MediaItemInfoChapterResponseModel": { + "required": [ + "title", + "startTime", + "endTime" + ], + "type": "object", + "properties": { + "title": { + "type": [ + "null", + "string" + ] + }, + "startTime": { + "pattern": "^-?(\\d+\\.)?\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,7})?$", + "type": "string" + }, + "endTime": { + "pattern": "^-?(\\d+\\.)?\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,7})?$", + "type": "string" + } + } + }, + "MediaItemInfoResponseModel": { + "required": [ + "id", + "title", + "kind", + "libraryKind", + "serverName", + "libraryName", + "state", + "duration", + "sampleAspectRatio", + "displayAspectRatio", + "rFrameRate", + "videoScanKind", + "interlacedRatio", + "width", + "height", + "streams", + "chapters" + ], + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "title": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "libraryKind": { + "type": "string" + }, + "serverName": { + "type": [ + "null", + "string" + ] + }, + "libraryName": { + "type": "string" + }, + "state": { + "$ref": "#/components/schemas/MediaItemState" + }, + "duration": { + "pattern": "^-?(\\d+\\.)?\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,7})?$", + "type": "string" + }, + "sampleAspectRatio": { + "type": [ + "null", + "string" + ] + }, + "displayAspectRatio": { + "type": [ + "null", + "string" + ] + }, + "rFrameRate": { + "type": [ + "null", + "string" + ] + }, + "videoScanKind": { + "$ref": "#/components/schemas/VideoScanKind" + }, + "interlacedRatio": { + "type": [ + "null", + "number" + ], + "format": "double" + }, + "width": { + "type": "integer", + "format": "int32" + }, + "height": { + "type": "integer", + "format": "int32" + }, + "streams": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MediaItemInfoStreamResponseModel" + } + }, + "chapters": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MediaItemInfoChapterResponseModel" + } + } + } + }, + "MediaItemInfoStreamResponseModel": { + "required": [ + "index", + "kind", + "title", + "codec", + "profile", + "language", + "channels", + "default", + "forced", + "attachedPic", + "pixelFormat", + "colorRange", + "colorSpace", + "colorTransfer", + "colorPrimaries", + "bitsPerRawSample", + "mimeType", + "fileName", + "isExtracted" + ], + "type": "object", + "properties": { + "index": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "kind": { + "$ref": "#/components/schemas/MediaStreamKind" + }, + "title": { + "type": [ + "null", + "string" + ] + }, + "codec": { + "type": [ + "null", + "string" + ] + }, + "profile": { + "type": [ + "null", + "string" + ] + }, + "language": { + "type": [ + "null", + "string" + ] + }, + "channels": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "default": { + "type": [ + "null", + "boolean" + ] + }, + "forced": { + "type": [ + "null", + "boolean" + ] + }, + "attachedPic": { + "type": [ + "null", + "boolean" + ] + }, + "pixelFormat": { + "type": [ + "null", + "string" + ] + }, + "colorRange": { + "type": [ + "null", + "string" + ] + }, + "colorSpace": { + "type": [ + "null", + "string" + ] + }, + "colorTransfer": { + "type": [ + "null", + "string" + ] + }, + "colorPrimaries": { + "type": [ + "null", + "string" + ] + }, + "bitsPerRawSample": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "mimeType": { + "type": [ + "null", + "string" + ] + }, + "fileName": { + "type": [ + "null", + "string" + ] + }, + "isExtracted": { + "type": [ + "null", + "boolean" + ] + } + } + }, "MediaItemState": { "enum": [ "Normal", @@ -15241,6 +16127,15 @@ ], "type": "string" }, + "MediaSourceKind": { + "enum": [ + "Local", + "Plex", + "Jellyfin", + "Emby" + ], + "type": "string" + }, "MediaSourceLibraryResponseModel": { "required": [ "id", @@ -15308,6 +16203,128 @@ } } }, + "MediaStreamKind": { + "enum": [ + "Video", + "Audio", + "Subtitle", + "Attachment", + "ExternalSubtitle" + ], + "type": "string" + }, + "MovieDetailResponseModel": { + "required": [ + "id", + "title", + "year", + "plot", + "genres", + "tags", + "studios", + "contentRatings", + "languages", + "actors", + "directors", + "writers", + "path", + "localPath", + "state", + "poster", + "fanArt" + ], + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "title": { + "type": "string" + }, + "year": { + "type": [ + "null", + "string" + ] + }, + "plot": { + "type": [ + "null", + "string" + ] + }, + "genres": { + "type": "array", + "items": { + "type": "string" + } + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "studios": { + "type": "array", + "items": { + "type": "string" + } + }, + "contentRatings": { + "type": "array", + "items": { + "type": "string" + } + }, + "languages": { + "type": "array", + "items": { + "type": "string" + } + }, + "actors": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ActorResponseModel" + } + }, + "directors": { + "type": "array", + "items": { + "type": "string" + } + }, + "writers": { + "type": "array", + "items": { + "type": "string" + } + }, + "path": { + "type": [ + "null", + "string" + ] + }, + "localPath": { + "type": [ + "null", + "string" + ] + }, + "state": { + "$ref": "#/components/schemas/MediaItemState" + }, + "poster": { + "type": "string" + }, + "fanArt": { + "type": "string" + } + } + }, "MultiCollectionItemViewModel": { "required": [ "multiCollectionId", @@ -17381,6 +18398,142 @@ } } }, + "SeasonDetailResponseModel": { + "required": [ + "id", + "showId", + "title", + "year", + "name", + "poster", + "fanArt" + ], + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "showId": { + "type": "integer", + "format": "int32" + }, + "title": { + "type": "string" + }, + "year": { + "type": [ + "null", + "string" + ] + }, + "name": { + "type": "string" + }, + "poster": { + "type": "string" + }, + "fanArt": { + "type": "string" + } + } + }, + "ShowDetailResponseModel": { + "required": [ + "id", + "libraryId", + "mediaSourceKind", + "title", + "year", + "plot", + "poster", + "fanArt", + "genres", + "tags", + "studios", + "networks", + "contentRatings", + "languages", + "actors" + ], + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "libraryId": { + "type": "integer", + "format": "int32" + }, + "mediaSourceKind": { + "$ref": "#/components/schemas/MediaSourceKind" + }, + "title": { + "type": "string" + }, + "year": { + "type": [ + "null", + "string" + ] + }, + "plot": { + "type": [ + "null", + "string" + ] + }, + "poster": { + "type": "string" + }, + "fanArt": { + "type": "string" + }, + "genres": { + "type": "array", + "items": { + "type": "string" + } + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "studios": { + "type": "array", + "items": { + "type": "string" + } + }, + "networks": { + "type": "array", + "items": { + "type": "string" + } + }, + "contentRatings": { + "type": "array", + "items": { + "type": "string" + } + }, + "languages": { + "type": "array", + "items": { + "type": "string" + } + }, + "actors": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ActorResponseModel" + } + } + } + }, "SmartCollectionResponseModel": { "required": [ "id", @@ -18392,6 +19545,36 @@ } } }, + "UpdateImageFolderDurationRequest": { + "required": [ + "durationSeconds" + ], + "type": "object", + "properties": { + "durationSeconds": { + "type": [ + "null", + "number" + ], + "format": "double" + } + } + }, + "UpdateImageFolderDurationResponseModel": { + "required": [ + "durationSeconds" + ], + "type": "object", + "properties": { + "durationSeconds": { + "type": [ + "null", + "number" + ], + "format": "double" + } + } + }, "UpdateLoggingSettingsRequest": { "required": [ "defaultMinimumLogLevel", @@ -18723,6 +19906,14 @@ } } }, + "VideoScanKind": { + "enum": [ + "Unknown", + "Progressive", + "Interlaced" + ], + "type": "string" + }, "WatermarkFullResponseModel": { "required": [ "id", @@ -18977,6 +20168,9 @@ } }, "tags": [ + { + "name": "Artists" + }, { "name": "Artwork" }, @@ -19013,6 +20207,9 @@ { "name": "Health" }, + { + "name": "Images" + }, { "name": "Libraries" }, @@ -19028,6 +20225,9 @@ { "name": "Media Sources" }, + { + "name": "Movies" + }, { "name": "Playlists" }, @@ -19046,6 +20246,9 @@ { "name": "Search" }, + { + "name": "Television" + }, { "name": "Sessions" }, diff --git a/web/src/App.tsx b/web/src/App.tsx index 75866c97f..e9f66078b 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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 ; } +// 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 ; + } + if (sub?.kind === 'show') { + return ; + } + if (sub?.kind === 'season') { + return ; + } + if (sub?.kind === 'artist') { + return ; + } + if (sub?.kind === 'images') { + return ; + } + + return ; +} + function ScreenContent({ healthState, route @@ -3631,7 +3676,7 @@ function ScreenContent({ } if (route.id === 'media') { - return ; + return ; } if (route.id === 'search') { diff --git a/web/src/api/generated/v1.d.ts b/web/src/api/generated/v1.d.ts index 1f10be5e4..3e4c9f59d 100644 --- a/web/src/api/generated/v1.d.ts +++ b/web/src/api/generated/v1.d.ts @@ -3,6 +3,12 @@ export interface components { schemas: { + "ActorResponseModel": { + "id": number; + "name": string; + "role": null | string; + "thumb": string; + }; "AddItemsToCollectionRequest": { "movieIds": null | Array; "showIds": null | Array; @@ -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; + "styles": Array; + "moods": Array; + "languages": Array; }; "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; + "chapters": Array; + }; + "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; + }; + "MediaStreamKind": "Video" | "Audio" | "Subtitle" | "Attachment" | "ExternalSubtitle"; + "MovieDetailResponseModel": { + "id": number; + "title": string; + "year": null | string; + "plot": null | string; + "genres": Array; + "tags": Array; + "studios": Array; + "contentRatings": Array; + "languages": Array; + "actors": Array; + "directors": Array; + "writers": Array; + "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; + "tags": Array; + "studios": Array; + "networks": Array; + "contentRatings": Array; + "languages": Array; + "actors": Array; }; "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; "json": string; }; + "VideoScanKind": "Unknown" | "Progressive" | "Interlaced"; "WatermarkFullResponseModel": { "id": number; "name": string; diff --git a/web/src/api/imageFolders.test.ts b/web/src/api/imageFolders.test.ts new file mode 100644 index 000000000..fc4ad2660 --- /dev/null +++ b/web/src/api/imageFolders.test.ts @@ -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 }); + }); +}); diff --git a/web/src/api/imageFolders.ts b/web/src/api/imageFolders.ts new file mode 100644 index 000000000..8b8e5aad8 --- /dev/null +++ b/web/src/api/imageFolders.ts @@ -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 { + const queryString = parentId != null ? `?parentId=${parentId}` : ''; + return request(`/api/images/folders${queryString}`); +} + +export function updateImageFolderDuration( + id: number, + durationSeconds: number | null +): Promise { + return request(`/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; +} diff --git a/web/src/api/index.ts b/web/src/api/index.ts index 20916109a..416e74b0f 100644 --- a/web/src/api/index.ts +++ b/web/src/api/index.ts @@ -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'; diff --git a/web/src/api/mediaDetail.test.ts b/web/src/api/mediaDetail.test.ts new file mode 100644 index 000000000..7b2580a1b --- /dev/null +++ b/web/src/api/mediaDetail.test.ts @@ -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): 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, 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'); + }); +}); diff --git a/web/src/api/mediaDetail.ts b/web/src/api/mediaDetail.ts new file mode 100644 index 000000000..1d9937924 --- /dev/null +++ b/web/src/api/mediaDetail.ts @@ -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 { + return request(`/api/movies/${id}`); +} + +export function getShow(id: number): Promise { + return request(`/api/shows/${id}`); +} + +export function getSeason(id: number): Promise { + return request(`/api/seasons/${id}`); +} + +export function getArtist(id: number): Promise { + return request(`/api/artists/${id}`); +} + +export function getMediaItemInfo(id: number): Promise { + return request(`/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; +} diff --git a/web/src/media/MediaPosterCard.tsx b/web/src/media/MediaPosterCard.tsx index 38d8d0d44..1327f7877 100644 --- a/web/src/media/MediaPosterCard.tsx +++ b/web/src/media/MediaPosterCard.tsx @@ -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 (
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 diff --git a/web/src/media/mediaKinds.ts b/web/src/media/mediaKinds.ts index e7687b06a..763dfeb05 100644 --- a/web/src/media/mediaKinds.ts +++ b/web/src/media/mediaKinds.ts @@ -49,6 +49,23 @@ export const TYPE_LABEL: Record = { 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; diff --git a/web/src/routing.test.ts b/web/src/routing.test.ts new file mode 100644 index 000000000..7fcfe7b0d --- /dev/null +++ b/web/src/routing.test.ts @@ -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(); + }); +}); diff --git a/web/src/routing.ts b/web/src/routing.ts index 7673ffe9c..6ddf8ffc9 100644 --- a/web/src/routing.ts +++ b/web/src/routing.ts @@ -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 = { + 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; +} diff --git a/web/src/screens/ImageBrowserScreen.tsx b/web/src/screens/ImageBrowserScreen.tsx new file mode 100644 index 000000000..2dbc83c98 --- /dev/null +++ b/web/src/screens/ImageBrowserScreen.tsx @@ -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 ( + + {open ? : null} + + ); +} + +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(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 ( +
+ setValue(event.target.value)} + placeholder="Inherited from parent" + type="number" + value={value} + /> + {error ? ( +
+
+ ) : null} +
+ + + + +
+
+ ); +} + +function FolderRow({ folder, depth }: { folder: ImageFolder; depth: number }) { + const [expanded, setExpanded] = useState(false); + const [children, setChildren] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [duration, setDuration] = useState(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 ( +
+
+ + {hasChildren ? ( + expanded ? ( + + + {folder.name} + + + {folder.imageCount} image{folder.imageCount === 1 ? '' : 's'} · {folder.subfolderCount} subfolder + {folder.subfolderCount === 1 ? '' : 's'} + + + + + setEditing(true)} size="sm" title="Edit duration"> + + searchLink(folder.libraryFolderId)} size="sm" title="Search this folder"> + +
+ {error ? ( +
+
+ ) : null} + {expanded && loading ? ( +
+ + Loading… +
+ ) : null} + {expanded && children + ? children.map((child) => ( + + )) + : null} + setEditing(false)} + onSaved={(next) => setDuration(next)} + open={editing} + /> +
+ ); +} + +export function ImageBrowserScreen() { + const [folders, setFolders] = useState([]); + const [status, setStatus] = useState<'loading' | 'error' | 'success'>('loading'); + const [error, setError] = useState(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 ( +
+
+ +
+ {status === 'loading' ? ( +
+ + Loading image folders… +
+ ) : status === 'error' ? ( +
+
+ ) : folders.length === 0 ? ( + +
No image libraries found.
+
+ ) : ( + + {folders.map((folder) => ( + + ))} + + )} +
+ ); +} diff --git a/web/src/screens/MediaBrowseScreen.tsx b/web/src/screens/MediaBrowseScreen.tsx index 0e1f7150b..5606437c1 100644 --- a/web/src/screens/MediaBrowseScreen.tsx +++ b/web/src/screens/MediaBrowseScreen.tsx @@ -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} /> + {kind.slug === 'images' && ( + + )} +
+ ) : items.length === 0 ? ( + +
Nothing here yet.
+
+ ) : ( + <> +
+ {items.map((item) => { + const detailPath = mediaDetailPath(item); + return ( + navigateToPath(detailPath) : undefined} + /> + ); + })} +
+ {totalPages > 1 ? ( +
+ {totalCount} + + setPageNum((current) => Math.max(0, current - 1))} + size="sm" + title="Previous page" + > + + + Page {pageNum + 1} of {totalPages} + + = totalPages} + onClick={() => setPageNum((current) => current + 1)} + size="sm" + title="Next page" + > + +
+ ) : null} + + )} + + ); +} + +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 ( + + {open ? : null} + + ); +} + +function MediaInfoBody({ mediaItemId }: { mediaItemId: number }) { + const [info, setInfo] = useState(null); + const [status, setStatus] = useState<'loading' | 'error' | 'success'>('loading'); + const [error, setError] = useState(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' ? ( +
+ + Loading media info… +
+ ) : status === 'error' ? ( +
+
+ ) : info ? ( +
+
+
Kind
+
{info.kind}
+
Library
+
{info.libraryName}
+
Duration
+
{formatTicks(info.duration)}
+
Resolution
+
+ {info.width}×{info.height} +
+ {info.displayAspectRatio ? ( + <> +
Aspect Ratio
+
{info.displayAspectRatio}
+ + ) : null} +
+ {info.streams && info.streams.length > 0 ? ( +
+

Streams

+
+ + + + + + + + + + + + {info.streams.map((stream, index) => ( + + + + + + + + ))} + +
#KindCodecLanguageTitle
{stream.index ?? '—'}{stream.kind}{stream.codec ?? '—'}{stream.language ?? '—'}{stream.title ?? '—'}
+
+
+ ) : null} + {info.chapters && info.chapters.length > 0 ? ( +
+

Chapters

+
+ + + + + + + + + + {info.chapters.map((chapter, index) => ( + + + + + + ))} + +
TitleStartEnd
{chapter.title || `Chapter ${index + 1}`}{formatTicks(chapter.startTime)}{formatTicks(chapter.endTime)}
+
+
+ ) : null} +
+ ) : null} + + ); +} + +function NotFound({ kindSlug, label }: { kindSlug: string; label: string }) { + return ( +
+ + +
{label} not found.
+
+
+ ); +} + +function ErrorState({ message, kindSlug }: { message: string; kindSlug: string }) { + return ( +
+ +
+
+
+ ); +} + +function Loading() { + return ( +
+ + Loading… +
+ ); +} + +export function MovieDetailScreen({ id }: { id: number }) { + const state = useDetail(useCallback(() => getMovie(id), [id])); + const [infoOpen, setInfoOpen] = useState(false); + + if (state.status === 'loading') { + return ; + } + if (state.status === 'notfound') { + return ; + } + if (state.status === 'error') { + return ; + } + + const movie = state.data; + const displayPath = movie.state === 'FileNotFound' ? movie.path : movie.localPath || movie.path; + + return ( + <> + + + + {/* Add-to-collection / add-to-playlist mutations are out of scope here; tracked by #153 / #155. */} + + } + chips={ +
+ + + + + + + +
+ } + fanart={movie.fanArt} + plot={movie.plot} + poster={movie.poster} + subtitle={movie.year} + title={movie.title} + warning={} + > + + {displayPath ?
{displayPath}
: null} +
+ setInfoOpen(false)} open={infoOpen} /> + + ); +} + +export function ShowDetailScreen({ id }: { id: number }) { + const state = useDetail(useCallback(() => getShow(id), [id])); + + if (state.status === 'loading') { + return ; + } + if (state.status === 'notfound') { + return ; + } + if (state.status === 'error') { + return ; + } + + const show = state.data; + + return ( + <> + + + + + + + + + + } + fanart={show.fanArt} + plot={show.plot} + poster={show.poster} + subtitle={show.year} + title={show.title} + > + + + + + ); +} + +export function SeasonDetailScreen({ id }: { id: number }) { + const state = useDetail(useCallback(() => getSeason(id), [id])); + + if (state.status === 'loading') { + return ; + } + if (state.status === 'notfound') { + return ; + } + if (state.status === 'error') { + return ; + } + + const season = state.data; + + return ( + <> + + + + + + ); +} + +export function ArtistDetailScreen({ id }: { id: number }) { + const state = useDetail(useCallback(() => getArtist(id), [id])); + + if (state.status === 'loading') { + return ; + } + if (state.status === 'notfound') { + return ; + } + if (state.status === 'error') { + return ; + } + + const artist = state.data; + + return ( + <> + + + + + + + + } + fanart={artist.fanArt} + plot={artist.biography} + poster={artist.thumbnail} + subtitle={artist.disambiguation} + title={artist.name} + > + + + + ); +} diff --git a/web/src/shell.css b/web/src/shell.css index a02a48279..625556a2d 100644 --- a/web/src/shell.css +++ b/web/src/shell.css @@ -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;