feat(api): media detail + info + image-folder endpoints (#141/#161)

Add REST endpoints backing the SPA media detail pages and image browser:

- GET /api/movies/{id}, /api/shows/{id}, /api/seasons/{id}, /api/artists/{id}
  wrapping the existing detail queries; 404 on None.
- GET /api/media-items/{id}/info wrapping GetMediaItemInfo; UnableToLocateMediaItem
  -> 404, other errors -> 422.
- GET /api/images/folders?parentId= and PUT /api/images/folders/{id}/duration
  (validates null-or-positive -> 400; existence guard via new ImageFolderExists
  query -> 404).
- Extend GetLibraryBrowseItems parentId drill-in to Episode (episodes of a season,
  episode-number order) and MusicVideo (an artist's music videos, album/track order),
  alongside the existing TelevisionSeason branch.

Response DTOs live in ErsatzTV.Core/Api/* and never expose Application VMs. Artwork
values are rooted for the SPA via a shared ErsatzTV.Core/Api/ApiArtwork helper
(mirrors the #180/#181 browse-handler logic; handles jellyfin/emby proxy prefixes,
http passthrough, empty). Regenerated OpenAPI v1.json + web v1.d.ts. New controllers
registered in ApiControllerSecurityTests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-07 22:43:47 +02:00
co-authored by Claude Fable 5
parent 3ec3ab5fe0
commit cd31c755bb
26 changed files with 2546 additions and 7 deletions
@@ -0,0 +1,3 @@
namespace ErsatzTV.Application.Images;
public record ImageFolderExists(int LibraryFolderId) : IRequest<bool>;
@@ -0,0 +1,21 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Images;
public class ImageFolderExistsHandler(IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<ImageFolderExists, bool>
{
public async Task<bool> Handle(ImageFolderExists request, CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
return await dbContext.LibraryFolders
.AsNoTracking()
.AnyAsync(
lf => lf.Id == request.LibraryFolderId
&& lf.LibraryPath.Library.MediaKind == LibraryMediaKind.Images,
cancellationToken);
}
}
@@ -22,12 +22,32 @@ public class GetLibraryBrowseItemsHandler(
GetLibraryBrowseItems request,
CancellationToken cancellationToken)
{
// Drill-in: seasons of a specific show. Bypasses Lucene and reads the show's seasons directly
// (in season-number order) so the SPA can expand a show tile into its seasons (issue #180).
if (request.ParentId.HasValue && request.MediaType == LibraryBrowseMediaType.TelevisionSeason)
// Drill-in for detail pages: read a parent's children directly (bypassing Lucene) so the SPA can
// expand a show into its seasons (#180), a season into its episodes, or an artist into its music
// videos (#141/#161). Each reads in the natural display order for that kind.
if (request.ParentId.HasValue)
{
await using TvContext seasonContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
return await BrowseSeasonsForShow(seasonContext, request, cancellationToken);
switch (request.MediaType)
{
case LibraryBrowseMediaType.TelevisionSeason:
{
await using TvContext seasonContext =
await dbContextFactory.CreateDbContextAsync(cancellationToken);
return await BrowseSeasonsForShow(seasonContext, request, cancellationToken);
}
case LibraryBrowseMediaType.Episode:
{
await using TvContext episodeContext =
await dbContextFactory.CreateDbContextAsync(cancellationToken);
return await BrowseEpisodesForSeason(episodeContext, request, cancellationToken);
}
case LibraryBrowseMediaType.MusicVideo:
{
await using TvContext musicVideoContext =
await dbContextFactory.CreateDbContextAsync(cancellationToken);
return await BrowseMusicVideosForArtist(musicVideoContext, request, cancellationToken);
}
}
}
int offset = request.PageNum * request.PageSize;
@@ -323,6 +343,75 @@ public class GetLibraryBrowseItemsHandler(
return new PagedLibraryBrowseItemsResponseModel(total, ordered);
}
// Drill-in: episodes of a specific season, in episode-number order (#141/#161).
private static async Task<PagedLibraryBrowseItemsResponseModel> BrowseEpisodesForSeason(
TvContext dbContext,
GetLibraryBrowseItems request,
CancellationToken cancellationToken)
{
List<int> allEpisodeIds = await dbContext.EpisodeMetadata
.AsNoTracking()
.Where(em => em.Episode.SeasonId == request.ParentId.Value)
.OrderBy(em => em.EpisodeNumber)
.ThenBy(em => em.EpisodeId)
.Select(em => em.EpisodeId)
.ToListAsync(cancellationToken);
// Distinct preserves order (LINQ-to-Objects) for episodes with multiple metadata rows.
allEpisodeIds = allEpisodeIds.Distinct().ToList();
int total = allEpisodeIds.Count;
List<int> pageIds = allEpisodeIds
.Skip(request.PageNum * request.PageSize)
.Take(request.PageSize)
.ToList();
List<LibraryBrowseItemResponseModel> episodes = await GetEpisodes(dbContext, pageIds, cancellationToken);
Dictionary<int, LibraryBrowseItemResponseModel> byId = episodes.ToDictionary(e => e.Id);
List<LibraryBrowseItemResponseModel> ordered = pageIds
.Where(byId.ContainsKey)
.Select(id => byId[id])
.ToList();
return new PagedLibraryBrowseItemsResponseModel(total, ordered);
}
// Drill-in: music videos of a specific artist, in album/track/title order (#141/#161).
private static async Task<PagedLibraryBrowseItemsResponseModel> BrowseMusicVideosForArtist(
TvContext dbContext,
GetLibraryBrowseItems request,
CancellationToken cancellationToken)
{
List<int> allMusicVideoIds = await dbContext.MusicVideoMetadata
.AsNoTracking()
.Where(mvm => mvm.MusicVideo.ArtistId == request.ParentId.Value)
.OrderBy(mvm => mvm.Album)
.ThenBy(mvm => mvm.Track)
.ThenBy(mvm => mvm.Title)
.ThenBy(mvm => mvm.MusicVideoId)
.Select(mvm => mvm.MusicVideoId)
.ToListAsync(cancellationToken);
allMusicVideoIds = allMusicVideoIds.Distinct().ToList();
int total = allMusicVideoIds.Count;
List<int> pageIds = allMusicVideoIds
.Skip(request.PageNum * request.PageSize)
.Take(request.PageSize)
.ToList();
List<LibraryBrowseItemResponseModel> musicVideos = await GetMusicVideos(dbContext, pageIds, cancellationToken);
Dictionary<int, LibraryBrowseItemResponseModel> byId = musicVideos.ToDictionary(mv => mv.Id);
List<LibraryBrowseItemResponseModel> ordered = pageIds
.Where(byId.ContainsKey)
.Select(id => byId[id])
.ToList();
return new PagedLibraryBrowseItemsResponseModel(total, ordered);
}
private static async Task<List<LibraryBrowseItemResponseModel>> GetSeasons(
TvContext dbContext,
List<int> ids,
+66
View File
@@ -0,0 +1,66 @@
#nullable enable
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Emby;
using ErsatzTV.Core.Jellyfin;
using Flurl;
namespace ErsatzTV.Core.Api;
/// <summary>
/// Roots an artwork value (as produced by the Application view-model mappers) into a directly-usable
/// <c>&lt;img src&gt;</c> URL for the React SPA. The Blazor pages prefix "artwork/{folder}/" themselves and
/// resolve relative to <c>&lt;base href="/"&gt;</c>, but the SPA renders the value raw from under <c>/app/</c>,
/// so the API must root the URL itself (issue #180/#181). Mirrors the projection helper in
/// GetLibraryBrowseItemsHandler so detail endpoints stay consistent with the browse grid.
/// </summary>
public static class ApiArtwork
{
public static string Root(string? artwork, ArtworkKind artworkKind)
{
if (string.IsNullOrWhiteSpace(artwork))
{
return string.Empty;
}
// Absolute URLs are already usable as-is (matches Blazor's GetPosterUrl guard).
if (artwork.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
artwork.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
{
return artwork;
}
string folder = artworkKind switch
{
ArtworkKind.Thumbnail => "thumbnails",
ArtworkKind.FanArt => "fanart",
_ => "posters"
};
// Some mappers (e.g. Artists) leave the raw jellyfin://emby:// scheme in the value; convert it here so
// the SPA gets a working proxy URL even when the source mapper didn't pre-convert. Movie/TV mappers
// already produce a relative "jellyfin/{id}?..." path, which falls through to the plain prefix below.
if (artwork.StartsWith("jellyfin://", StringComparison.OrdinalIgnoreCase))
{
Url url = JellyfinUrl.RelativeProxyForArtwork(artwork);
if (artworkKind is ArtworkKind.Poster or ArtworkKind.Thumbnail)
{
url.SetQueryParam("fillHeight", 440);
}
return $"/artwork/{folder}/{url}";
}
if (artwork.StartsWith("emby://", StringComparison.OrdinalIgnoreCase))
{
Url url = EmbyUrl.RelativeProxyForArtwork(artwork);
if (artworkKind is ArtworkKind.Poster or ArtworkKind.Thumbnail)
{
url.SetQueryParam("maxHeight", 440);
}
return $"/artwork/{folder}/{url}";
}
return $"/artwork/{folder}/{artwork}";
}
}
@@ -0,0 +1,14 @@
#nullable enable
namespace ErsatzTV.Core.Api.Artists;
public record ArtistDetailResponseModel(
int Id,
string Name,
string? Disambiguation,
string? Biography,
string Thumbnail,
string FanArt,
List<string> Genres,
List<string> Styles,
List<string> Moods,
List<string> Languages);
@@ -0,0 +1,10 @@
#nullable enable
namespace ErsatzTV.Core.Api.Images;
public record ImageFolderResponseModel(
int LibraryFolderId,
string Name,
string FullPath,
int SubfolderCount,
int ImageCount,
double? DurationSeconds);
@@ -0,0 +1,4 @@
#nullable enable
namespace ErsatzTV.Core.Api.Images;
public record UpdateImageFolderDurationResponseModel(double? DurationSeconds);
@@ -0,0 +1,8 @@
#nullable enable
namespace ErsatzTV.Core.Api.Media;
public record ActorResponseModel(
int Id,
string Name,
string? Role,
string Thumb);
@@ -0,0 +1,49 @@
#nullable enable
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Core.Api.MediaItems;
public record MediaItemInfoResponseModel(
int Id,
string Title,
string Kind,
string LibraryKind,
string? ServerName,
string LibraryName,
MediaItemState State,
TimeSpan Duration,
string? SampleAspectRatio,
string? DisplayAspectRatio,
string? RFrameRate,
VideoScanKind VideoScanKind,
double? InterlacedRatio,
int Width,
int Height,
List<MediaItemInfoStreamResponseModel> Streams,
List<MediaItemInfoChapterResponseModel> Chapters);
public record MediaItemInfoStreamResponseModel(
int? Index,
MediaStreamKind Kind,
string? Title,
string? Codec,
string? Profile,
string? Language,
int? Channels,
bool? Default,
bool? Forced,
bool? AttachedPic,
string? PixelFormat,
string? ColorRange,
string? ColorSpace,
string? ColorTransfer,
string? ColorPrimaries,
int? BitsPerRawSample,
string? MimeType,
string? FileName,
bool? IsExtracted);
public record MediaItemInfoChapterResponseModel(
string? Title,
TimeSpan StartTime,
TimeSpan EndTime);
@@ -0,0 +1,24 @@
#nullable enable
using ErsatzTV.Core.Api.Media;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Core.Api.Movies;
public record MovieDetailResponseModel(
int Id,
string Title,
string? Year,
string? Plot,
List<string> Genres,
List<string> Tags,
List<string> Studios,
List<string> ContentRatings,
List<string> Languages,
List<ActorResponseModel> Actors,
List<string> Directors,
List<string> Writers,
string? Path,
string? LocalPath,
MediaItemState State,
string Poster,
string FanArt);
@@ -0,0 +1,11 @@
#nullable enable
namespace ErsatzTV.Core.Api.Television;
public record SeasonDetailResponseModel(
int Id,
int ShowId,
string Title,
string? Year,
string Name,
string Poster,
string FanArt);
@@ -0,0 +1,22 @@
#nullable enable
using ErsatzTV.Core.Api.Media;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Core.Api.Television;
public record ShowDetailResponseModel(
int Id,
int LibraryId,
MediaSourceKind MediaSourceKind,
string Title,
string? Year,
string? Plot,
string Poster,
string FanArt,
List<string> Genres,
List<string> Tags,
List<string> Studios,
List<string> Networks,
List<string> ContentRatings,
List<string> Languages,
List<ActorResponseModel> Actors);
@@ -272,6 +272,55 @@ public class GetLibraryBrowseItemsHandlerTests
Arg.Any<CancellationToken>());
}
[Test]
public async Task Handle_Should_Browse_Episodes_For_A_Specific_Season_By_ParentId()
{
await SeedEpisodeDrillInGraph();
var handler = new GetLibraryBrowseItemsHandler(_searchIndex, _db.Factory);
PagedLibraryBrowseItemsResponseModel result = await handler.Handle(
new GetLibraryBrowseItems("", null, LibraryBrowseMediaType.Episode, 0, 10, 701),
CancellationToken.None);
result.TotalCount.ShouldBe(3);
// Ordered by episode number regardless of insertion order.
result.Page[0].MediaItemId.ShouldBe(712);
result.Page[1].MediaItemId.ShouldBe(711);
result.Page[2].MediaItemId.ShouldBe(713);
await _searchIndex.DidNotReceive().Search(
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Any<int>(),
Arg.Any<int>(),
Arg.Any<List<string>>(),
Arg.Any<CancellationToken>());
}
[Test]
public async Task Handle_Should_Browse_Music_Videos_For_A_Specific_Artist_By_ParentId()
{
await SeedMusicVideoDrillInGraph();
var handler = new GetLibraryBrowseItemsHandler(_searchIndex, _db.Factory);
PagedLibraryBrowseItemsResponseModel result = await handler.Handle(
new GetLibraryBrowseItems("", null, LibraryBrowseMediaType.MusicVideo, 0, 10, 801),
CancellationToken.None);
result.TotalCount.ShouldBe(2);
// Ordered by album then track: "Album A"/track 1 before "Album B"/track 1.
result.Page[0].MediaItemId.ShouldBe(812);
result.Page[1].MediaItemId.ShouldBe(811);
await _searchIndex.DidNotReceive().Search(
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Any<int>(),
Arg.Any<int>(),
Arg.Any<List<string>>(),
Arg.Any<CancellationToken>());
}
[Test]
public async Task Handle_Should_Root_Jellyfin_Artwork_Urls_With_FillHeight()
{
@@ -525,6 +574,116 @@ public class GetLibraryBrowseItemsHandlerTests
await context.SaveChangesAsync();
}
private async Task SeedEpisodeDrillInGraph()
{
await using TvContext context = _db.CreateContext();
(LocalLibrary library, LibraryPath path) = MakeLibrary(700, "Episode Library");
var show = new Show
{
Id = 700,
LibraryPath = path,
Collections = [],
CollectionItems = [],
TraktListItems = [],
Seasons = [],
ShowMetadata = [MakeShowMetadata("Drill Show", "show.jpg")]
};
var season = MakeSeason(701, path, show, 1, "season.jpg");
// Inserted out of episode-number order to prove the handler sorts by number.
Episode e2 = MakeNumberedEpisode(711, path, season, 2);
Episode e1 = MakeNumberedEpisode(712, path, season, 1);
Episode e3 = MakeNumberedEpisode(713, path, season, 3);
season.Episodes.AddRange([e2, e1, e3]);
show.Seasons.Add(season);
path.MediaItems.AddRange([show, season, e2, e1, e3]);
context.LocalLibraries.Add(library);
context.Shows.Add(show);
context.Seasons.Add(season);
context.Episodes.AddRange(e2, e1, e3);
await context.SaveChangesAsync();
}
private async Task SeedMusicVideoDrillInGraph()
{
await using TvContext context = _db.CreateContext();
(LocalLibrary library, LibraryPath path) = MakeLibrary(800, "Music Video Library");
var artist = new Artist
{
Id = 801,
LibraryPath = path,
Collections = [],
CollectionItems = [],
TraktListItems = [],
MusicVideos = [],
ArtistMetadata = [MakeArtistMetadata("Drill Artist", "artist.jpg")]
};
MusicVideo mvB = MakeTitledMusicVideo(811, path, artist, "Track On B", "Album B", 1);
MusicVideo mvA = MakeTitledMusicVideo(812, path, artist, "Track On A", "Album A", 1);
artist.MusicVideos.AddRange([mvB, mvA]);
path.MediaItems.AddRange([artist, mvB, mvA]);
context.LocalLibraries.Add(library);
context.Artists.Add(artist);
context.MusicVideos.AddRange(mvB, mvA);
await context.SaveChangesAsync();
}
private static Episode MakeNumberedEpisode(int id, LibraryPath path, Season season, int episodeNumber)
{
Episode episode = MakeEpisode(id, path, season);
episode.EpisodeMetadata =
[
new EpisodeMetadata
{
Title = $"Episode {episodeNumber}",
SortTitle = $"Episode {episodeNumber}",
EpisodeNumber = episodeNumber,
Artwork = [],
Genres = [],
Tags = [],
Studios = [],
Actors = [],
Guids = [],
Subtitles = [],
Directors = [],
Writers = []
}
];
return episode;
}
private static MusicVideo MakeTitledMusicVideo(
int id,
LibraryPath path,
Artist artist,
string title,
string album,
int track)
{
MusicVideo musicVideo = MakeMusicVideo(id, path, artist);
musicVideo.MusicVideoMetadata =
[
new MusicVideoMetadata
{
Title = title,
SortTitle = title,
Album = album,
Track = track,
Artwork = [],
Genres = [],
Tags = [],
Studios = [],
Actors = [],
Guids = [],
Subtitles = []
}
];
return musicVideo;
}
private async Task SeedArtistGraph()
{
await using TvContext context = _db.CreateContext();
@@ -25,6 +25,7 @@ public class ApiControllerSecurityTests
{
Type[] apiControllers =
[
typeof(ArtistsController),
typeof(BlockController),
typeof(ChannelController),
typeof(CollectionController),
@@ -32,16 +33,20 @@ public class ApiControllerSecurityTests
typeof(DecoTemplateController),
typeof(FFmpegProfileController),
typeof(FillerPresetController),
typeof(ImagesController),
typeof(LibrariesController),
typeof(LogsController),
typeof(MaintenanceController),
typeof(MediaItemsController),
typeof(MoviesController),
typeof(PlayoutController),
typeof(ResolutionController),
typeof(ScannerController),
typeof(ScheduleController),
typeof(ScriptedScheduleController),
typeof(SeasonsController),
typeof(SessionController),
typeof(ShowsController),
typeof(SettingsController),
typeof(SmartCollectionController),
typeof(TemplateController),
@@ -0,0 +1,147 @@
using System.Reflection;
using ErsatzTV.Application.Images;
using ErsatzTV.Controllers.Api;
using ErsatzTV.Controllers.Api.Requests;
using ErsatzTV.Core.Api.Images;
using LanguageExt;
using MediatR;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Routing;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Controllers;
[TestFixture]
public class ImagesControllerTests
{
private ImagesController _controller = null!;
private IMediator _mediator = null!;
[SetUp]
public void SetUp()
{
_mediator = Substitute.For<IMediator>();
_controller = new ImagesController(_mediator);
}
[Test]
public void Controller_Should_Expose_Idiomatic_Rest_Routes()
{
ShouldHaveActionRoute(nameof(ImagesController.GetFolders), "GET", "/api/images/folders");
ShouldHaveActionRoute(
nameof(ImagesController.UpdateDuration),
"PUT",
"/api/images/folders/{id:int}/duration");
}
[Test]
public async Task GetFolders_Should_Map_Duration_Option_To_Nullable()
{
_mediator.Send(Arg.Any<GetImageFolders>(), Arg.Any<CancellationToken>())
.Returns(
[
new ImageFolderViewModel(1, "Root", "/images", 2, 5, Option<double>.None),
new ImageFolderViewModel(2, "Child", "/images/child", 0, 3, Option<double>.Some(4.5))
]);
List<ImageFolderResponseModel> result = await _controller.GetFolders(null, CancellationToken.None);
result.Count.ShouldBe(2);
result[0].DurationSeconds.ShouldBeNull();
result[1].DurationSeconds.ShouldBe(4.5);
result[1].Name.ShouldBe("Child");
}
[Test]
public async Task GetFolders_Should_Pass_None_When_ParentId_Omitted()
{
_mediator.Send(Arg.Any<GetImageFolders>(), Arg.Any<CancellationToken>()).Returns([]);
await _controller.GetFolders(null, CancellationToken.None);
await _mediator.Received().Send(
Arg.Is<GetImageFolders>(q => q.LibraryFolderId.IsNone),
Arg.Any<CancellationToken>());
}
[Test]
public async Task GetFolders_Should_Pass_Some_When_ParentId_Given()
{
_mediator.Send(Arg.Any<GetImageFolders>(), Arg.Any<CancellationToken>()).Returns([]);
await _controller.GetFolders(42, CancellationToken.None);
await _mediator.Received().Send(
Arg.Is<GetImageFolders>(q => q.LibraryFolderId == Option<int>.Some(42)),
Arg.Any<CancellationToken>());
}
[Test]
public async Task UpdateDuration_Should_Return_400_For_Non_Positive_Duration()
{
IActionResult result = await _controller.UpdateDuration(
1,
new UpdateImageFolderDurationRequest(0),
CancellationToken.None);
result.ShouldBeOfType<BadRequestObjectResult>().StatusCode.ShouldBe(400);
await _mediator.DidNotReceive().Send(Arg.Any<ImageFolderExists>(), Arg.Any<CancellationToken>());
}
[Test]
public async Task UpdateDuration_Should_Return_404_When_Folder_Missing()
{
_mediator.Send(Arg.Any<ImageFolderExists>(), Arg.Any<CancellationToken>()).Returns(false);
IActionResult result = await _controller.UpdateDuration(
1,
new UpdateImageFolderDurationRequest(3.0),
CancellationToken.None);
result.ShouldBeOfType<NotFoundObjectResult>().StatusCode.ShouldBe(404);
await _mediator.DidNotReceive().Send(Arg.Any<UpdateImageFolderDuration>(), Arg.Any<CancellationToken>());
}
[Test]
public async Task UpdateDuration_Should_Return_200_And_Update_When_Valid()
{
_mediator.Send(Arg.Any<ImageFolderExists>(), Arg.Any<CancellationToken>()).Returns(true);
_mediator.Send(Arg.Any<UpdateImageFolderDuration>(), Arg.Any<CancellationToken>()).Returns(3.0);
IActionResult result = await _controller.UpdateDuration(
1,
new UpdateImageFolderDurationRequest(3.0),
CancellationToken.None);
var ok = result.ShouldBeOfType<OkObjectResult>();
ok.Value.ShouldBeOfType<UpdateImageFolderDurationResponseModel>().DurationSeconds.ShouldBe(3.0);
}
[Test]
public async Task UpdateDuration_Should_Allow_Null_To_Clear()
{
_mediator.Send(Arg.Any<ImageFolderExists>(), Arg.Any<CancellationToken>()).Returns(true);
_mediator.Send(Arg.Any<UpdateImageFolderDuration>(), Arg.Any<CancellationToken>())
.Returns((double?)null);
IActionResult result = await _controller.UpdateDuration(
1,
new UpdateImageFolderDurationRequest(null),
CancellationToken.None);
result.ShouldBeOfType<OkObjectResult>()
.Value.ShouldBeOfType<UpdateImageFolderDurationResponseModel>().DurationSeconds.ShouldBeNull();
}
private static void ShouldHaveActionRoute(string actionName, string httpMethod, string route)
{
MethodInfo action = typeof(ImagesController).GetMethod(actionName)
?? throw new AssertionException($"Missing action {actionName}");
HttpMethodAttribute attribute = action.GetCustomAttributes<HttpMethodAttribute>(inherit: true).Single();
attribute.HttpMethods.ShouldContain(httpMethod);
attribute.Template.ShouldBe(route);
}
}
@@ -0,0 +1,262 @@
using System.Globalization;
using System.Reflection;
using ErsatzTV.Application.Artists;
using ErsatzTV.Application.MediaCards;
using ErsatzTV.Application.MediaItems;
using ErsatzTV.Application.Movies;
using ErsatzTV.Application.Television;
using ErsatzTV.Controllers.Api;
using ErsatzTV.Core;
using ErsatzTV.Core.Api.Artists;
using ErsatzTV.Core.Api.MediaItems;
using ErsatzTV.Core.Api.Movies;
using ErsatzTV.Core.Api.Television;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using LanguageExt;
using MediatR;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Routing;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
using static LanguageExt.Prelude;
namespace ErsatzTV.Tests.Controllers;
[TestFixture]
public class MediaDetailControllerTests
{
private IMediator _mediator = null!;
[SetUp]
public void SetUp() => _mediator = Substitute.For<IMediator>();
[Test]
public void Controllers_Should_Expose_Idiomatic_Rest_Routes()
{
ShouldHaveActionRoute<MoviesController>(nameof(MoviesController.GetById), "GET", "/api/movies/{id:int}");
ShouldHaveActionRoute<ShowsController>(nameof(ShowsController.GetById), "GET", "/api/shows/{id:int}");
ShouldHaveActionRoute<SeasonsController>(nameof(SeasonsController.GetById), "GET", "/api/seasons/{id:int}");
ShouldHaveActionRoute<ArtistsController>(nameof(ArtistsController.GetById), "GET", "/api/artists/{id:int}");
ShouldHaveActionRoute<MediaItemsController>(
nameof(MediaItemsController.GetInfo),
"GET",
"/api/media-items/{id:int}/info");
}
[Test]
public async Task Movie_Should_Return_200_With_Rooted_Artwork()
{
var vm = new MovieViewModel(
"The Movie",
"1999",
"A plot",
["Drama"],
["tag"],
["Studio"],
["PG"],
["English"],
[new ActorCardViewModel(7, "Actor", "Role", "actor.jpg", MediaItemState.Normal)],
["Director"],
["Writer"],
"/media/movie.mkv",
"/local/movie.mkv",
MediaItemState.Normal)
{
Poster = "poster.jpg",
FanArt = "https://example.com/fan.jpg"
};
_mediator.Send(Arg.Any<GetMovieById>(), Arg.Any<CancellationToken>())
.Returns(Option<MovieViewModel>.Some(vm));
var controller = new MoviesController(_mediator);
IActionResult result = await controller.GetById(5, CancellationToken.None);
var ok = result.ShouldBeOfType<OkObjectResult>();
var body = ok.Value.ShouldBeOfType<MovieDetailResponseModel>();
body.Id.ShouldBe(5);
body.Title.ShouldBe("The Movie");
body.Poster.ShouldBe("/artwork/posters/poster.jpg");
body.FanArt.ShouldBe("https://example.com/fan.jpg");
body.Actors.Single().Thumb.ShouldBe("/artwork/thumbnails/actor.jpg");
body.State.ShouldBe(MediaItemState.Normal);
}
[Test]
public async Task Movie_Should_Return_404_When_Missing()
{
_mediator.Send(Arg.Any<GetMovieById>(), Arg.Any<CancellationToken>())
.Returns(Option<MovieViewModel>.None);
var controller = new MoviesController(_mediator);
IActionResult result = await controller.GetById(5, CancellationToken.None);
result.ShouldBeOfType<NotFoundObjectResult>().StatusCode.ShouldBe(404);
}
[Test]
public async Task Show_Should_Map_Languages_To_English_Names()
{
var vm = new TelevisionShowViewModel(
3,
2,
MediaSourceKind.Local,
"The Show",
"2010",
"Plot",
"poster.jpg",
"fan.jpg",
["Comedy"],
["tag"],
["Studio"],
["Network"],
["TV-14"],
[new CultureInfo("en")],
[]);
_mediator.Send(Arg.Any<GetTelevisionShowById>(), Arg.Any<CancellationToken>())
.Returns(Option<TelevisionShowViewModel>.Some(vm));
var controller = new ShowsController(_mediator);
IActionResult result = await controller.GetById(3, CancellationToken.None);
var body = result.ShouldBeOfType<OkObjectResult>().Value.ShouldBeOfType<ShowDetailResponseModel>();
body.Id.ShouldBe(3);
body.Poster.ShouldBe("/artwork/posters/poster.jpg");
body.FanArt.ShouldBe("/artwork/fanart/fan.jpg");
body.Languages.ShouldContain(new CultureInfo("en").EnglishName);
}
[Test]
public async Task Show_Should_Return_404_When_Missing()
{
_mediator.Send(Arg.Any<GetTelevisionShowById>(), Arg.Any<CancellationToken>())
.Returns(Option<TelevisionShowViewModel>.None);
var controller = new ShowsController(_mediator);
(await controller.GetById(3, CancellationToken.None)).ShouldBeOfType<NotFoundObjectResult>();
}
[Test]
public async Task Season_Should_Return_200()
{
var vm = new TelevisionSeasonViewModel(4, 3, "Show", "2010", "Season 1", "s.jpg", "f.jpg");
_mediator.Send(Arg.Any<GetTelevisionSeasonById>(), Arg.Any<CancellationToken>())
.Returns(Option<TelevisionSeasonViewModel>.Some(vm));
var controller = new SeasonsController(_mediator);
var body = (await controller.GetById(4, CancellationToken.None))
.ShouldBeOfType<OkObjectResult>().Value.ShouldBeOfType<SeasonDetailResponseModel>();
body.ShowId.ShouldBe(3);
body.Poster.ShouldBe("/artwork/posters/s.jpg");
}
[Test]
public async Task Season_Should_Return_404_When_Missing()
{
_mediator.Send(Arg.Any<GetTelevisionSeasonById>(), Arg.Any<CancellationToken>())
.Returns(Option<TelevisionSeasonViewModel>.None);
var controller = new SeasonsController(_mediator);
(await controller.GetById(4, CancellationToken.None)).ShouldBeOfType<NotFoundObjectResult>();
}
[Test]
public async Task Artist_Should_Root_Thumbnail_And_FanArt()
{
var vm = new ArtistViewModel(
"Artist",
"Disambig",
"Bio",
"thumb.jpg",
"fan.jpg",
["Rock"],
["Style"],
["Mood"],
[new CultureInfo("en")]);
_mediator.Send(Arg.Any<GetArtistById>(), Arg.Any<CancellationToken>())
.Returns(Option<ArtistViewModel>.Some(vm));
var controller = new ArtistsController(_mediator);
var body = (await controller.GetById(6, CancellationToken.None))
.ShouldBeOfType<OkObjectResult>().Value.ShouldBeOfType<ArtistDetailResponseModel>();
body.Id.ShouldBe(6);
body.Thumbnail.ShouldBe("/artwork/thumbnails/thumb.jpg");
body.FanArt.ShouldBe("/artwork/fanart/fan.jpg");
body.Languages.ShouldContain(new CultureInfo("en").EnglishName);
}
[Test]
public async Task Artist_Should_Return_404_When_Missing()
{
_mediator.Send(Arg.Any<GetArtistById>(), Arg.Any<CancellationToken>())
.Returns(Option<ArtistViewModel>.None);
var controller = new ArtistsController(_mediator);
(await controller.GetById(6, CancellationToken.None)).ShouldBeOfType<NotFoundObjectResult>();
}
[Test]
public async Task MediaItemInfo_Should_Return_200_With_Mapped_Streams()
{
var info = new MediaItemInfo(
9,
"Title",
"Movie",
"LocalLibrary",
null,
"Movies",
MediaItemState.Normal,
TimeSpan.FromMinutes(90),
"1:1",
"16:9",
"24/1",
VideoScanKind.Progressive,
null,
1920,
1080,
[new MediaItemInfoStream(0, MediaStreamKind.Video, "v", "h264", "high", "eng", null, true, null, null, "yuv420p", null, null, null, null, 8, null)],
[new MediaItemInfoChapter("Chapter 1", TimeSpan.Zero, TimeSpan.FromMinutes(10))]);
_mediator.Send(Arg.Any<GetMediaItemInfo>(), Arg.Any<CancellationToken>())
.Returns(Either<BaseError, MediaItemInfo>.Right(info));
var controller = new MediaItemsController(_mediator);
var body = (await controller.GetInfo(9, CancellationToken.None))
.ShouldBeOfType<OkObjectResult>().Value.ShouldBeOfType<MediaItemInfoResponseModel>();
body.Id.ShouldBe(9);
body.Streams.Single().Codec.ShouldBe("h264");
body.Chapters.Single().Title.ShouldBe("Chapter 1");
}
[Test]
public async Task MediaItemInfo_Should_Return_404_When_Not_Located()
{
_mediator.Send(Arg.Any<GetMediaItemInfo>(), Arg.Any<CancellationToken>())
.Returns(Either<BaseError, MediaItemInfo>.Left(new UnableToLocateMediaItem()));
var controller = new MediaItemsController(_mediator);
(await controller.GetInfo(9, CancellationToken.None))
.ShouldBeOfType<NotFoundObjectResult>().StatusCode.ShouldBe(404);
}
[Test]
public async Task MediaItemInfo_Should_Return_422_On_Other_Error()
{
_mediator.Send(Arg.Any<GetMediaItemInfo>(), Arg.Any<CancellationToken>())
.Returns(Either<BaseError, MediaItemInfo>.Left(BaseError.New("boom")));
var controller = new MediaItemsController(_mediator);
(await controller.GetInfo(9, CancellationToken.None))
.ShouldBeOfType<UnprocessableEntityObjectResult>().StatusCode.ShouldBe(422);
}
private static void ShouldHaveActionRoute<TController>(string actionName, string httpMethod, string route)
{
MethodInfo action = typeof(TController).GetMethod(actionName)
?? throw new AssertionException($"Missing action {actionName}");
HttpMethodAttribute attribute = action.GetCustomAttributes<HttpMethodAttribute>(inherit: true).Single();
attribute.HttpMethods.ShouldContain(httpMethod);
attribute.Template.ShouldBe(route);
}
}
@@ -0,0 +1,39 @@
using ErsatzTV.Application.Artists;
using ErsatzTV.Core.Api;
using ErsatzTV.Core.Api.Artists;
using ErsatzTV.Core.Domain;
using ErsatzTV.Extensions;
using MediatR;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
namespace ErsatzTV.Controllers.Api;
[ApiController]
public class ArtistsController(IMediator mediator) : ControllerBase
{
[HttpGet("/api/artists/{id:int}", Name = "GetArtistById")]
[Tags("Artists")]
[EndpointSummary("Get an artist by id")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(ArtistDetailResponseModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetById(int id, CancellationToken cancellationToken)
{
Option<ArtistViewModel> result = await mediator.Send(new GetArtistById(id), cancellationToken);
return result.Map(vm => Project(id, vm)).ToGetResult();
}
private static ArtistDetailResponseModel Project(int id, ArtistViewModel vm) =>
new(
id,
vm.Name,
string.IsNullOrWhiteSpace(vm.Disambiguation) ? null : vm.Disambiguation,
string.IsNullOrWhiteSpace(vm.Biography) ? null : vm.Biography,
ApiArtwork.Root(vm.Thumbnail, ArtworkKind.Thumbnail),
ApiArtwork.Root(vm.FanArt, ArtworkKind.FanArt),
vm.Genres,
vm.Styles,
vm.Moods,
vm.Languages.Map(c => c.EnglishName).ToList());
}
@@ -0,0 +1,81 @@
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using ErsatzTV.Application.Images;
using ErsatzTV.Controllers.Api.Requests;
using ErsatzTV.Core;
using ErsatzTV.Core.Api.Images;
using ErsatzTV.Extensions;
using MediatR;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
namespace ErsatzTV.Controllers.Api;
[ApiController]
public class ImagesController(IMediator mediator) : ControllerBase
{
[HttpGet("/api/images/folders", Name = "GetImageFolders")]
[Tags("Images")]
[EndpointSummary("List image library folders")]
[EndpointDescription("Omit parentId for the top-level folders; pass a folder id to list that folder's children.")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(List<ImageFolderResponseModel>), StatusCodes.Status200OK)]
public async Task<List<ImageFolderResponseModel>> GetFolders(
[FromQuery]
[Description("Parent image library-folder id; omit for the top-level folders")]
int? parentId = null,
CancellationToken cancellationToken = default)
{
Option<int> maybeParentId = parentId ?? Option<int>.None;
List<ImageFolderViewModel> folders = await mediator.Send(new GetImageFolders(maybeParentId), cancellationToken);
return folders.Map(Project).ToList();
}
[HttpPut("/api/images/folders/{id:int}/duration", Name = "UpdateImageFolderDuration")]
[Tags("Images")]
[EndpointSummary("Set or clear an image folder's playout duration")]
[EndpointDescription(
"Pass a positive durationSeconds to set the per-image duration for this folder (cascades to descendant " +
"images that don't override it); pass null to clear it and inherit from an ancestor.")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(UpdateImageFolderDurationResponseModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<IActionResult> UpdateDuration(
int id,
[Required] [FromBody] UpdateImageFolderDurationRequest request,
CancellationToken cancellationToken)
{
if (request.DurationSeconds is <= 0)
{
return BadRequest(
new ProblemDetails
{
Status = StatusCodes.Status400BadRequest,
Title = "Validation failed",
Detail = "durationSeconds must be greater than zero, or null to clear"
});
}
bool exists = await mediator.Send(new ImageFolderExists(id), cancellationToken);
if (!exists)
{
return ApiResults.NotFoundProblem("Image folder not found");
}
double? duration = await mediator.Send(
new UpdateImageFolderDuration(id, request.DurationSeconds),
cancellationToken);
return Ok(new UpdateImageFolderDurationResponseModel(duration));
}
private static ImageFolderResponseModel Project(ImageFolderViewModel vm) =>
new(
vm.LibraryFolderId,
vm.Name,
vm.FullPath,
vm.SubfolderCount,
vm.ImageCount,
vm.DurationSeconds.ToNullable());
}
@@ -24,7 +24,7 @@ public class LibraryBrowseController(IMediator mediator) : ControllerBase
[FromQuery] int pageNum = 0,
[FromQuery] int pageSize = 100,
[FromQuery]
[Description("Parent television show id; only used with mediaType=TelevisionSeason (lists that show's seasons), ignored otherwise")]
[Description("Parent id for a drill-in listing; only used with mediaType=TelevisionSeason (that show's seasons), mediaType=Episode (that season's episodes) or mediaType=MusicVideo (that artist's music videos), ignored otherwise")]
int? parentId = null,
CancellationToken cancellationToken = default)
{
@@ -1,6 +1,9 @@
using System.ComponentModel.DataAnnotations;
using ErsatzTV.Application.MediaItems;
using ErsatzTV.Controllers.Api.Requests;
using ErsatzTV.Core;
using ErsatzTV.Core.Api.MediaItems;
using ErsatzTV.Core.Errors;
using ErsatzTV.Extensions;
using MediatR;
using Microsoft.AspNetCore.Http;
@@ -29,4 +32,66 @@ public class MediaItemsController(IMediator mediator) : ControllerBase
Either<BaseError, Unit> result = await mediator.Send(request.ToCommand(), cancellationToken);
return result.ToDeletedResult();
}
[HttpGet("/api/media-items/{id:int}/info", Name = "GetMediaItemInfo")]
[Tags("Media Items")]
[EndpointSummary("Get technical media info for a media item")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(MediaItemInfoResponseModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> GetInfo(int id, CancellationToken cancellationToken)
{
Either<BaseError, MediaItemInfo> result = await mediator.Send(new GetMediaItemInfo(id), cancellationToken);
return result.Match(
Left: error => error is UnableToLocateMediaItem
? ApiResults.NotFoundProblem(error.Value)
: error.ToErrorResult(),
Right: info => (IActionResult)new OkObjectResult(Project(info)));
}
private static MediaItemInfoResponseModel Project(MediaItemInfo info) =>
new(
info.Id,
info.Title,
info.Kind,
info.LibraryKind,
string.IsNullOrWhiteSpace(info.ServerName) ? null : info.ServerName,
info.LibraryName,
info.State,
info.Duration,
string.IsNullOrWhiteSpace(info.SampleAspectRatio) ? null : info.SampleAspectRatio,
string.IsNullOrWhiteSpace(info.DisplayAspectRatio) ? null : info.DisplayAspectRatio,
string.IsNullOrWhiteSpace(info.RFrameRate) ? null : info.RFrameRate,
info.VideoScanKind,
info.InterlacedRatio,
info.Width,
info.Height,
info.Streams.Map(Project).ToList(),
info.Chapters.Map(Project).ToList());
private static MediaItemInfoStreamResponseModel Project(MediaItemInfoStream stream) =>
new(
stream.Index,
stream.Kind,
stream.Title,
stream.Codec,
stream.Profile,
stream.Language,
stream.Channels,
stream.Default,
stream.Forced,
stream.AttachedPic,
stream.PixelFormat,
stream.ColorRange,
stream.ColorSpace,
stream.ColorTransfer,
stream.ColorPrimaries,
stream.BitsPerRawSample,
stream.MimeType,
stream.FileName,
stream.IsExtracted);
private static MediaItemInfoChapterResponseModel Project(MediaItemInfoChapter chapter) =>
new(chapter.Title, chapter.StartTime, chapter.EndTime);
}
@@ -0,0 +1,47 @@
using ErsatzTV.Application.Movies;
using ErsatzTV.Core.Api;
using ErsatzTV.Core.Api.Media;
using ErsatzTV.Core.Api.Movies;
using ErsatzTV.Core.Domain;
using ErsatzTV.Extensions;
using MediatR;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
namespace ErsatzTV.Controllers.Api;
[ApiController]
public class MoviesController(IMediator mediator) : ControllerBase
{
[HttpGet("/api/movies/{id:int}", Name = "GetMovieById")]
[Tags("Movies")]
[EndpointSummary("Get a movie by id")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(MovieDetailResponseModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetById(int id, CancellationToken cancellationToken)
{
Option<MovieViewModel> result = await mediator.Send(new GetMovieById(id), cancellationToken);
return result.Map(vm => Project(id, vm)).ToGetResult();
}
private static MovieDetailResponseModel Project(int id, MovieViewModel vm) =>
new(
id,
vm.Title,
vm.Year,
vm.Plot,
vm.Genres,
vm.Tags,
vm.Studios,
vm.ContentRatings,
vm.Languages,
vm.Actors.Map(a => new ActorResponseModel(a.Id, a.Name, a.Role, ApiArtwork.Root(a.Thumb, ArtworkKind.Thumbnail))).ToList(),
vm.Directors,
vm.Writers,
vm.Path,
vm.LocalPath,
vm.MediaItemState,
ApiArtwork.Root(vm.Poster, ArtworkKind.Poster),
ApiArtwork.Root(vm.FanArt, ArtworkKind.FanArt));
}
@@ -0,0 +1,3 @@
namespace ErsatzTV.Controllers.Api.Requests;
public record UpdateImageFolderDurationRequest(double? DurationSeconds);
@@ -0,0 +1,37 @@
using ErsatzTV.Application.Television;
using ErsatzTV.Core.Api;
using ErsatzTV.Core.Api.Television;
using ErsatzTV.Core.Domain;
using ErsatzTV.Extensions;
using MediatR;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
namespace ErsatzTV.Controllers.Api;
[ApiController]
public class SeasonsController(IMediator mediator) : ControllerBase
{
[HttpGet("/api/seasons/{id:int}", Name = "GetSeasonById")]
[Tags("Television")]
[EndpointSummary("Get a television season by id")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(SeasonDetailResponseModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetById(int id, CancellationToken cancellationToken)
{
Option<TelevisionSeasonViewModel> result =
await mediator.Send(new GetTelevisionSeasonById(id), cancellationToken);
return result.Map(Project).ToGetResult();
}
private static SeasonDetailResponseModel Project(TelevisionSeasonViewModel vm) =>
new(
vm.Id,
vm.ShowId,
vm.Title,
string.IsNullOrWhiteSpace(vm.Year) ? null : vm.Year,
vm.Name,
ApiArtwork.Root(vm.Poster, ArtworkKind.Poster),
ApiArtwork.Root(vm.FanArt, ArtworkKind.FanArt));
}
@@ -0,0 +1,45 @@
using ErsatzTV.Application.Television;
using ErsatzTV.Core.Api;
using ErsatzTV.Core.Api.Media;
using ErsatzTV.Core.Api.Television;
using ErsatzTV.Core.Domain;
using ErsatzTV.Extensions;
using MediatR;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
namespace ErsatzTV.Controllers.Api;
[ApiController]
public class ShowsController(IMediator mediator) : ControllerBase
{
[HttpGet("/api/shows/{id:int}", Name = "GetShowById")]
[Tags("Television")]
[EndpointSummary("Get a television show by id")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(ShowDetailResponseModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetById(int id, CancellationToken cancellationToken)
{
Option<TelevisionShowViewModel> result = await mediator.Send(new GetTelevisionShowById(id), cancellationToken);
return result.Map(Project).ToGetResult();
}
private static ShowDetailResponseModel Project(TelevisionShowViewModel vm) =>
new(
vm.Id,
vm.LibraryId,
vm.MediaSourceKind,
vm.Title,
string.IsNullOrWhiteSpace(vm.Year) ? null : vm.Year,
string.IsNullOrWhiteSpace(vm.Plot) ? null : vm.Plot,
ApiArtwork.Root(vm.Poster, ArtworkKind.Poster),
ApiArtwork.Root(vm.FanArt, ArtworkKind.FanArt),
vm.Genres,
vm.Tags,
vm.Studios,
vm.Networks,
vm.ContentRatings,
vm.Languages.Map(c => c.EnglishName).ToList(),
vm.Actors.Map(a => new ActorResponseModel(a.Id, a.Name, a.Role, ApiArtwork.Root(a.Thumb, ArtworkKind.Thumbnail))).ToList());
}
File diff suppressed because it is too large Load Diff
+125
View File
@@ -3,6 +3,12 @@
export interface components {
schemas: {
"ActorResponseModel": {
"id": number;
"name": string;
"role": null | string;
"thumb": string;
};
"AddItemsToCollectionRequest": {
"movieIds": null | Array<number>;
"showIds": null | Array<number>;
@@ -17,6 +23,18 @@ export interface components {
};
"AddTraktListRequest": {
"url": null | string;
};
"ArtistDetailResponseModel": {
"id": number;
"name": string;
"disambiguation": null | string;
"biography": null | string;
"thumbnail": string;
"fanArt": string;
"genres": Array<string>;
"styles": Array<string>;
"moods": Array<string>;
"languages": Array<string>;
};
"ArtworkContentTypeModel": {
"path": null | string;
@@ -687,6 +705,14 @@ export interface components {
"lastAccess": string;
};
"IFormFile": string;
"ImageFolderResponseModel": {
"libraryFolderId": number;
"name": string;
"fullPath": string;
"subfolderCount": number;
"imageCount": number;
"durationSeconds": null | number;
};
"LibraryBrowseItemResponseModel": {
"id": number;
"mediaType": components["schemas"]["LibraryBrowseMediaType"];
@@ -739,8 +765,54 @@ export interface components {
"poster"?: null | string;
"state": components["schemas"]["MediaItemState"];
"hasMediaInfo"?: boolean;
};
"MediaItemInfoChapterResponseModel": {
"title": null | string;
"startTime": string;
"endTime": string;
};
"MediaItemInfoResponseModel": {
"id": number;
"title": string;
"kind": string;
"libraryKind": string;
"serverName": null | string;
"libraryName": string;
"state": components["schemas"]["MediaItemState"];
"duration": string;
"sampleAspectRatio": null | string;
"displayAspectRatio": null | string;
"rFrameRate": null | string;
"videoScanKind": components["schemas"]["VideoScanKind"];
"interlacedRatio": null | number;
"width": number;
"height": number;
"streams": Array<components["schemas"]["MediaItemInfoStreamResponseModel"]>;
"chapters": Array<components["schemas"]["MediaItemInfoChapterResponseModel"]>;
};
"MediaItemInfoStreamResponseModel": {
"index": null | number;
"kind": components["schemas"]["MediaStreamKind"];
"title": null | string;
"codec": null | string;
"profile": null | string;
"language": null | string;
"channels": null | number;
"default": null | boolean;
"forced": null | boolean;
"attachedPic": null | boolean;
"pixelFormat": null | string;
"colorRange": null | string;
"colorSpace": null | string;
"colorTransfer": null | string;
"colorPrimaries": null | string;
"bitsPerRawSample": null | number;
"mimeType": null | string;
"fileName": null | string;
"isExtracted": null | boolean;
};
"MediaItemState": "Normal" | "FileNotFound" | "Unavailable" | "RemoteOnly";
"MediaSourceKind": "Local" | "Plex" | "Jellyfin" | "Emby";
"MediaSourceLibraryResponseModel": {
"id": number;
"name": string;
@@ -754,6 +826,26 @@ export interface components {
"name": string;
"connectionAddress": null | string;
"libraries": Array<components["schemas"]["MediaSourceLibraryResponseModel"]>;
};
"MediaStreamKind": "Video" | "Audio" | "Subtitle" | "Attachment" | "ExternalSubtitle";
"MovieDetailResponseModel": {
"id": number;
"title": string;
"year": null | string;
"plot": null | string;
"genres": Array<string>;
"tags": Array<string>;
"studios": Array<string>;
"contentRatings": Array<string>;
"languages": Array<string>;
"actors": Array<components["schemas"]["ActorResponseModel"]>;
"directors": Array<string>;
"writers": Array<string>;
"path": null | string;
"localPath": null | string;
"state": components["schemas"]["MediaItemState"];
"poster": string;
"fanArt": string;
};
"MultiCollectionItemViewModel": {
"multiCollectionId": number;
@@ -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<string>;
"tags": Array<string>;
"studios": Array<string>;
"networks": Array<string>;
"contentRatings": Array<string>;
"languages": Array<string>;
"actors": Array<components["schemas"]["ActorResponseModel"]>;
};
"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;