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 bbcaabb77..02cdea87b 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": [ @@ -7685,6 +8051,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": [ @@ -8622,6 +9050,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": [ @@ -10897,6 +11387,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", @@ -11028,6 +11545,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", @@ -14606,6 +15189,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", @@ -14910,6 +15531,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", @@ -14919,6 +15805,15 @@ ], "type": "string" }, + "MediaSourceKind": { + "enum": [ + "Local", + "Plex", + "Jellyfin", + "Emby" + ], + "type": "string" + }, "MediaSourceLibraryResponseModel": { "required": [ "id", @@ -14986,6 +15881,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", @@ -16983,6 +18000,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", @@ -17994,6 +19147,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", @@ -18285,6 +19468,14 @@ ], "type": "string" }, + "VideoScanKind": { + "enum": [ + "Unknown", + "Progressive", + "Interlaced" + ], + "type": "string" + }, "WatermarkFullResponseModel": { "required": [ "id", @@ -18539,6 +19730,9 @@ } }, "tags": [ + { + "name": "Artists" + }, { "name": "Artwork" }, @@ -18575,6 +19769,9 @@ { "name": "Health" }, + { + "name": "Images" + }, { "name": "Libraries" }, @@ -18590,6 +19787,9 @@ { "name": "Media Sources" }, + { + "name": "Movies" + }, { "name": "Playlists" }, @@ -18608,6 +19808,9 @@ { "name": "Search" }, + { + "name": "Television" + }, { "name": "Sessions" }, diff --git a/web/src/api/generated/v1.d.ts b/web/src/api/generated/v1.d.ts index b3dfa5e5c..6a39978ac 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; @@ -1121,6 +1213,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; @@ -1318,6 +1436,12 @@ export interface components { }; "UpdateHdhrSettingsRequest": { "tunerCount": number; + }; + "UpdateImageFolderDurationRequest": { + "durationSeconds": null | number; + }; + "UpdateImageFolderDurationResponseModel": { + "durationSeconds": null | number; }; "UpdateLoggingSettingsRequest": { "defaultMinimumLogLevel": components["schemas"]["LogEventLevel"]; @@ -1383,6 +1507,7 @@ export interface components { "blockBehavior": components["schemas"]["XmltvBlockBehavior"]; }; "VaapiDriver": "Default" | "iHD" | "i965" | "RadeonSI" | "Nouveau"; + "VideoScanKind": "Unknown" | "Progressive" | "Interlaced"; "WatermarkFullResponseModel": { "id": number; "name": string;