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