- Extend /api/library/browse to episodes, music videos, songs, other videos,
images and remote streams (new LibraryBrowseMediaType values + hydrators);
add optional Subtitle to LibraryBrowseItemResponseModel for leaf-item context
- Add GET /api/search: grouped per-kind results reusing the browse query/shape;
empty query -> 422
- Add DELETE /api/media-items: body { ids }, empty -> 422, success -> 204
- Tests: SearchController, MediaItemsController, security + OpenAPI contract entries
- Regenerate openapi v1.json + web v1.d.ts
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -91,12 +91,24 @@ public class GetLibraryBrowseItemsHandler(
|
|||||||
LibraryBrowseMediaType.TelevisionShow => [LuceneSearchIndex.ShowType],
|
LibraryBrowseMediaType.TelevisionShow => [LuceneSearchIndex.ShowType],
|
||||||
LibraryBrowseMediaType.TelevisionSeason => [LuceneSearchIndex.SeasonType],
|
LibraryBrowseMediaType.TelevisionSeason => [LuceneSearchIndex.SeasonType],
|
||||||
LibraryBrowseMediaType.Artist => [LuceneSearchIndex.ArtistType],
|
LibraryBrowseMediaType.Artist => [LuceneSearchIndex.ArtistType],
|
||||||
|
LibraryBrowseMediaType.Episode => [LuceneSearchIndex.EpisodeType],
|
||||||
|
LibraryBrowseMediaType.MusicVideo => [LuceneSearchIndex.MusicVideoType],
|
||||||
|
LibraryBrowseMediaType.Song => [LuceneSearchIndex.SongType],
|
||||||
|
LibraryBrowseMediaType.OtherVideo => [LuceneSearchIndex.OtherVideoType],
|
||||||
|
LibraryBrowseMediaType.Image => [LuceneSearchIndex.ImageType],
|
||||||
|
LibraryBrowseMediaType.RemoteStream => [LuceneSearchIndex.RemoteStreamType],
|
||||||
null =>
|
null =>
|
||||||
[
|
[
|
||||||
LuceneSearchIndex.MovieType,
|
LuceneSearchIndex.MovieType,
|
||||||
LuceneSearchIndex.ShowType,
|
LuceneSearchIndex.ShowType,
|
||||||
LuceneSearchIndex.SeasonType,
|
LuceneSearchIndex.SeasonType,
|
||||||
LuceneSearchIndex.ArtistType
|
LuceneSearchIndex.ArtistType,
|
||||||
|
LuceneSearchIndex.EpisodeType,
|
||||||
|
LuceneSearchIndex.MusicVideoType,
|
||||||
|
LuceneSearchIndex.SongType,
|
||||||
|
LuceneSearchIndex.OtherVideoType,
|
||||||
|
LuceneSearchIndex.ImageType,
|
||||||
|
LuceneSearchIndex.RemoteStreamType
|
||||||
],
|
],
|
||||||
_ => []
|
_ => []
|
||||||
};
|
};
|
||||||
@@ -115,6 +127,15 @@ public class GetLibraryBrowseItemsHandler(
|
|||||||
List<int> showIds = searchItems.Where(i => i.Type == LuceneSearchIndex.ShowType).Select(i => i.Id).ToList();
|
List<int> showIds = searchItems.Where(i => i.Type == LuceneSearchIndex.ShowType).Select(i => i.Id).ToList();
|
||||||
List<int> seasonIds = searchItems.Where(i => i.Type == LuceneSearchIndex.SeasonType).Select(i => i.Id).ToList();
|
List<int> seasonIds = searchItems.Where(i => i.Type == LuceneSearchIndex.SeasonType).Select(i => i.Id).ToList();
|
||||||
List<int> artistIds = searchItems.Where(i => i.Type == LuceneSearchIndex.ArtistType).Select(i => i.Id).ToList();
|
List<int> artistIds = searchItems.Where(i => i.Type == LuceneSearchIndex.ArtistType).Select(i => i.Id).ToList();
|
||||||
|
List<int> episodeIds = searchItems.Where(i => i.Type == LuceneSearchIndex.EpisodeType).Select(i => i.Id).ToList();
|
||||||
|
List<int> musicVideoIds =
|
||||||
|
searchItems.Where(i => i.Type == LuceneSearchIndex.MusicVideoType).Select(i => i.Id).ToList();
|
||||||
|
List<int> songIds = searchItems.Where(i => i.Type == LuceneSearchIndex.SongType).Select(i => i.Id).ToList();
|
||||||
|
List<int> otherVideoIds =
|
||||||
|
searchItems.Where(i => i.Type == LuceneSearchIndex.OtherVideoType).Select(i => i.Id).ToList();
|
||||||
|
List<int> imageIds = searchItems.Where(i => i.Type == LuceneSearchIndex.ImageType).Select(i => i.Id).ToList();
|
||||||
|
List<int> remoteStreamIds =
|
||||||
|
searchItems.Where(i => i.Type == LuceneSearchIndex.RemoteStreamType).Select(i => i.Id).ToList();
|
||||||
|
|
||||||
Dictionary<(string Type, int Id), LibraryBrowseItemResponseModel> hydrated = [];
|
Dictionary<(string Type, int Id), LibraryBrowseItemResponseModel> hydrated = [];
|
||||||
|
|
||||||
@@ -138,6 +159,37 @@ public class GetLibraryBrowseItemsHandler(
|
|||||||
hydrated[(LuceneSearchIndex.ArtistType, item.Id)] = item;
|
hydrated[(LuceneSearchIndex.ArtistType, item.Id)] = item;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
foreach (LibraryBrowseItemResponseModel item in await GetEpisodes(dbContext, episodeIds, cancellationToken))
|
||||||
|
{
|
||||||
|
hydrated[(LuceneSearchIndex.EpisodeType, item.Id)] = item;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (LibraryBrowseItemResponseModel item in await GetMusicVideos(dbContext, musicVideoIds, cancellationToken))
|
||||||
|
{
|
||||||
|
hydrated[(LuceneSearchIndex.MusicVideoType, item.Id)] = item;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (LibraryBrowseItemResponseModel item in await GetSongs(dbContext, songIds, cancellationToken))
|
||||||
|
{
|
||||||
|
hydrated[(LuceneSearchIndex.SongType, item.Id)] = item;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (LibraryBrowseItemResponseModel item in await GetOtherVideos(dbContext, otherVideoIds, cancellationToken))
|
||||||
|
{
|
||||||
|
hydrated[(LuceneSearchIndex.OtherVideoType, item.Id)] = item;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (LibraryBrowseItemResponseModel item in await GetImages(dbContext, imageIds, cancellationToken))
|
||||||
|
{
|
||||||
|
hydrated[(LuceneSearchIndex.ImageType, item.Id)] = item;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (LibraryBrowseItemResponseModel item in
|
||||||
|
await GetRemoteStreams(dbContext, remoteStreamIds, cancellationToken))
|
||||||
|
{
|
||||||
|
hydrated[(LuceneSearchIndex.RemoteStreamType, item.Id)] = item;
|
||||||
|
}
|
||||||
|
|
||||||
return searchItems
|
return searchItems
|
||||||
.Where(i => hydrated.ContainsKey((i.Type, i.Id)))
|
.Where(i => hydrated.ContainsKey((i.Type, i.Id)))
|
||||||
.Select(i => hydrated[(i.Type, i.Id)])
|
.Select(i => hydrated[(i.Type, i.Id)])
|
||||||
@@ -330,6 +382,307 @@ public class GetLibraryBrowseItemsHandler(
|
|||||||
null)).ToList());
|
null)).ToList());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static async Task<List<LibraryBrowseItemResponseModel>> GetEpisodes(
|
||||||
|
TvContext dbContext,
|
||||||
|
List<int> ids,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (ids.Count == 0)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return await dbContext.EpisodeMetadata
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(em => ids.Contains(em.EpisodeId))
|
||||||
|
.Include(em => em.Artwork)
|
||||||
|
.Include(em => em.Episode)
|
||||||
|
.ThenInclude(e => e.LibraryPath)
|
||||||
|
.ThenInclude(lp => lp.Library)
|
||||||
|
.Include(em => em.Episode)
|
||||||
|
.ThenInclude(e => e.MediaVersions)
|
||||||
|
.Include(em => em.Episode)
|
||||||
|
.ThenInclude(e => e.Season)
|
||||||
|
.ThenInclude(s => s.Show)
|
||||||
|
.ThenInclude(sh => sh.ShowMetadata)
|
||||||
|
.ToListAsync(cancellationToken)
|
||||||
|
.Map(list => list
|
||||||
|
.GroupBy(em => em.EpisodeId)
|
||||||
|
.Select(g => g.OrderBy(em => em.Id).First())
|
||||||
|
.Map(em => new LibraryBrowseItemResponseModel(
|
||||||
|
em.EpisodeId,
|
||||||
|
LibraryBrowseMediaType.Episode,
|
||||||
|
em.Title ?? string.Empty,
|
||||||
|
em.Episode.LibraryPath.LibraryId,
|
||||||
|
em.Episode.LibraryPath.Library.Name,
|
||||||
|
ArtworkWithFallback(em, ArtworkKind.Thumbnail, ArtworkKind.Poster),
|
||||||
|
BestDuration(em.Episode.MediaVersions),
|
||||||
|
1,
|
||||||
|
null,
|
||||||
|
CollectionType.Episode,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
em.EpisodeId,
|
||||||
|
null,
|
||||||
|
EpisodeSubtitle(em))).ToList());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<List<LibraryBrowseItemResponseModel>> GetMusicVideos(
|
||||||
|
TvContext dbContext,
|
||||||
|
List<int> ids,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (ids.Count == 0)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return await dbContext.MusicVideoMetadata
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(mvm => ids.Contains(mvm.MusicVideoId))
|
||||||
|
.Include(mvm => mvm.Artwork)
|
||||||
|
.Include(mvm => mvm.MusicVideo)
|
||||||
|
.ThenInclude(mv => mv.LibraryPath)
|
||||||
|
.ThenInclude(lp => lp.Library)
|
||||||
|
.Include(mvm => mvm.MusicVideo)
|
||||||
|
.ThenInclude(mv => mv.MediaVersions)
|
||||||
|
.Include(mvm => mvm.MusicVideo)
|
||||||
|
.ThenInclude(mv => mv.Artist)
|
||||||
|
.ThenInclude(a => a.ArtistMetadata)
|
||||||
|
.ToListAsync(cancellationToken)
|
||||||
|
.Map(list => list
|
||||||
|
.GroupBy(mvm => mvm.MusicVideoId)
|
||||||
|
.Select(g => g.OrderBy(mvm => mvm.Id).First())
|
||||||
|
.Map(mvm => new LibraryBrowseItemResponseModel(
|
||||||
|
mvm.MusicVideoId,
|
||||||
|
LibraryBrowseMediaType.MusicVideo,
|
||||||
|
mvm.Title ?? string.Empty,
|
||||||
|
mvm.MusicVideo.LibraryPath.LibraryId,
|
||||||
|
mvm.MusicVideo.LibraryPath.Library.Name,
|
||||||
|
ArtworkWithFallback(mvm, ArtworkKind.Thumbnail, ArtworkKind.Poster),
|
||||||
|
BestDuration(mvm.MusicVideo.MediaVersions),
|
||||||
|
1,
|
||||||
|
null,
|
||||||
|
CollectionType.MusicVideo,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
mvm.MusicVideoId,
|
||||||
|
null,
|
||||||
|
MusicVideoSubtitle(mvm))).ToList());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<List<LibraryBrowseItemResponseModel>> GetSongs(
|
||||||
|
TvContext dbContext,
|
||||||
|
List<int> ids,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (ids.Count == 0)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return await dbContext.SongMetadata
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(sm => ids.Contains(sm.SongId))
|
||||||
|
.Include(sm => sm.Artwork)
|
||||||
|
.Include(sm => sm.Song)
|
||||||
|
.ThenInclude(s => s.LibraryPath)
|
||||||
|
.ThenInclude(lp => lp.Library)
|
||||||
|
.Include(sm => sm.Song)
|
||||||
|
.ThenInclude(s => s.MediaVersions)
|
||||||
|
.ToListAsync(cancellationToken)
|
||||||
|
.Map(list => list
|
||||||
|
.GroupBy(sm => sm.SongId)
|
||||||
|
.Select(g => g.OrderBy(sm => sm.Id).First())
|
||||||
|
.Map(sm => new LibraryBrowseItemResponseModel(
|
||||||
|
sm.SongId,
|
||||||
|
LibraryBrowseMediaType.Song,
|
||||||
|
sm.Title ?? string.Empty,
|
||||||
|
sm.Song.LibraryPath.LibraryId,
|
||||||
|
sm.Song.LibraryPath.Library.Name,
|
||||||
|
ArtworkWithFallback(sm, ArtworkKind.Thumbnail, ArtworkKind.Poster),
|
||||||
|
BestDuration(sm.Song.MediaVersions),
|
||||||
|
1,
|
||||||
|
null,
|
||||||
|
CollectionType.Song,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
sm.SongId,
|
||||||
|
null,
|
||||||
|
SongSubtitle(sm))).ToList());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<List<LibraryBrowseItemResponseModel>> GetOtherVideos(
|
||||||
|
TvContext dbContext,
|
||||||
|
List<int> ids,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (ids.Count == 0)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return await dbContext.OtherVideoMetadata
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(ovm => ids.Contains(ovm.OtherVideoId))
|
||||||
|
.Include(ovm => ovm.Artwork)
|
||||||
|
.Include(ovm => ovm.OtherVideo)
|
||||||
|
.ThenInclude(ov => ov.LibraryPath)
|
||||||
|
.ThenInclude(lp => lp.Library)
|
||||||
|
.Include(ovm => ovm.OtherVideo)
|
||||||
|
.ThenInclude(ov => ov.MediaVersions)
|
||||||
|
.ToListAsync(cancellationToken)
|
||||||
|
.Map(list => list
|
||||||
|
.GroupBy(ovm => ovm.OtherVideoId)
|
||||||
|
.Select(g => g.OrderBy(ovm => ovm.Id).First())
|
||||||
|
.Map(ovm => new LibraryBrowseItemResponseModel(
|
||||||
|
ovm.OtherVideoId,
|
||||||
|
LibraryBrowseMediaType.OtherVideo,
|
||||||
|
ovm.Title ?? string.Empty,
|
||||||
|
ovm.OtherVideo.LibraryPath.LibraryId,
|
||||||
|
ovm.OtherVideo.LibraryPath.Library.Name,
|
||||||
|
ArtworkWithFallback(ovm, ArtworkKind.Thumbnail, ArtworkKind.Poster),
|
||||||
|
BestDuration(ovm.OtherVideo.MediaVersions),
|
||||||
|
1,
|
||||||
|
null,
|
||||||
|
CollectionType.OtherVideo,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
ovm.OtherVideoId,
|
||||||
|
null,
|
||||||
|
string.IsNullOrWhiteSpace(ovm.OriginalTitle) ? null : ovm.OriginalTitle)).ToList());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<List<LibraryBrowseItemResponseModel>> GetImages(
|
||||||
|
TvContext dbContext,
|
||||||
|
List<int> ids,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (ids.Count == 0)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return await dbContext.ImageMetadata
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(im => ids.Contains(im.ImageId))
|
||||||
|
.Include(im => im.Artwork)
|
||||||
|
.Include(im => im.Image)
|
||||||
|
.ThenInclude(i => i.LibraryPath)
|
||||||
|
.ThenInclude(lp => lp.Library)
|
||||||
|
.Include(im => im.Image)
|
||||||
|
.ThenInclude(i => i.MediaVersions)
|
||||||
|
.ToListAsync(cancellationToken)
|
||||||
|
.Map(list => list
|
||||||
|
.GroupBy(im => im.ImageId)
|
||||||
|
.Select(g => g.OrderBy(im => im.Id).First())
|
||||||
|
.Map(im => new LibraryBrowseItemResponseModel(
|
||||||
|
im.ImageId,
|
||||||
|
LibraryBrowseMediaType.Image,
|
||||||
|
im.Title ?? string.Empty,
|
||||||
|
im.Image.LibraryPath.LibraryId,
|
||||||
|
im.Image.LibraryPath.Library.Name,
|
||||||
|
ArtworkWithFallback(im, ArtworkKind.Poster, ArtworkKind.Thumbnail),
|
||||||
|
BestDuration(im.Image.MediaVersions),
|
||||||
|
1,
|
||||||
|
null,
|
||||||
|
CollectionType.Image,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
im.ImageId,
|
||||||
|
null,
|
||||||
|
string.IsNullOrWhiteSpace(im.OriginalTitle) ? null : im.OriginalTitle)).ToList());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<List<LibraryBrowseItemResponseModel>> GetRemoteStreams(
|
||||||
|
TvContext dbContext,
|
||||||
|
List<int> ids,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (ids.Count == 0)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return await dbContext.RemoteStreamMetadata
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(rsm => ids.Contains(rsm.RemoteStreamId))
|
||||||
|
.Include(rsm => rsm.Artwork)
|
||||||
|
.Include(rsm => rsm.RemoteStream)
|
||||||
|
.ThenInclude(rs => rs.LibraryPath)
|
||||||
|
.ThenInclude(lp => lp.Library)
|
||||||
|
.Include(rsm => rsm.RemoteStream)
|
||||||
|
.ThenInclude(rs => rs.MediaVersions)
|
||||||
|
.ToListAsync(cancellationToken)
|
||||||
|
.Map(list => list
|
||||||
|
.GroupBy(rsm => rsm.RemoteStreamId)
|
||||||
|
.Select(g => g.OrderBy(rsm => rsm.Id).First())
|
||||||
|
.Map(rsm => new LibraryBrowseItemResponseModel(
|
||||||
|
rsm.RemoteStreamId,
|
||||||
|
LibraryBrowseMediaType.RemoteStream,
|
||||||
|
rsm.Title ?? string.Empty,
|
||||||
|
rsm.RemoteStream.LibraryPath.LibraryId,
|
||||||
|
rsm.RemoteStream.LibraryPath.Library.Name,
|
||||||
|
ArtworkWithFallback(rsm, ArtworkKind.Thumbnail, ArtworkKind.Poster),
|
||||||
|
BestDuration(rsm.RemoteStream.MediaVersions),
|
||||||
|
1,
|
||||||
|
null,
|
||||||
|
CollectionType.RemoteStream,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
rsm.RemoteStreamId,
|
||||||
|
null,
|
||||||
|
string.IsNullOrWhiteSpace(rsm.OriginalTitle) ? null : rsm.OriginalTitle)).ToList());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string EpisodeSubtitle(EpisodeMetadata metadata)
|
||||||
|
{
|
||||||
|
string showTitle = metadata.Episode.Season.Show.ShowMetadata.HeadOrNone()
|
||||||
|
.Map(sm => sm.Title ?? string.Empty)
|
||||||
|
.IfNone(string.Empty);
|
||||||
|
int seasonNumber = metadata.Episode.Season.SeasonNumber;
|
||||||
|
string suffix = $"S{seasonNumber}E{metadata.EpisodeNumber}";
|
||||||
|
return string.IsNullOrWhiteSpace(showTitle) ? suffix : $"{showTitle} - {suffix}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string MusicVideoSubtitle(MusicVideoMetadata metadata)
|
||||||
|
{
|
||||||
|
string artist = metadata.MusicVideo.Artist.ArtistMetadata.HeadOrNone()
|
||||||
|
.Map(am => am.Title ?? string.Empty)
|
||||||
|
.IfNone(string.Empty);
|
||||||
|
string album = string.IsNullOrWhiteSpace(metadata.Album) ? string.Empty : metadata.Album;
|
||||||
|
if (!string.IsNullOrWhiteSpace(artist) && !string.IsNullOrWhiteSpace(album))
|
||||||
|
{
|
||||||
|
return $"{artist} - {album}";
|
||||||
|
}
|
||||||
|
|
||||||
|
return string.IsNullOrWhiteSpace(artist) ? album : artist;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string SongSubtitle(SongMetadata metadata)
|
||||||
|
{
|
||||||
|
string artists = string.Join(", ", metadata.Artists ?? []);
|
||||||
|
string album = string.IsNullOrWhiteSpace(metadata.Album) ? string.Empty : metadata.Album;
|
||||||
|
if (!string.IsNullOrWhiteSpace(artists) && !string.IsNullOrWhiteSpace(album))
|
||||||
|
{
|
||||||
|
return $"{artists} - {album}";
|
||||||
|
}
|
||||||
|
|
||||||
|
return string.IsNullOrWhiteSpace(artists) ? album : artists;
|
||||||
|
}
|
||||||
|
|
||||||
private static async Task<int> CountCollections(
|
private static async Task<int> CountCollections(
|
||||||
TvContext dbContext,
|
TvContext dbContext,
|
||||||
GetLibraryBrowseItems request,
|
GetLibraryBrowseItems request,
|
||||||
@@ -913,6 +1266,12 @@ public class GetLibraryBrowseItemsHandler(
|
|||||||
return string.IsNullOrWhiteSpace(showTitle) ? seasonTitle : $"{showTitle} - {seasonTitle}";
|
return string.IsNullOrWhiteSpace(showTitle) ? seasonTitle : $"{showTitle} - {seasonTitle}";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string ArtworkWithFallback(Metadata metadata, ArtworkKind primary, ArtworkKind fallback)
|
||||||
|
{
|
||||||
|
string artwork = Artwork(metadata, primary);
|
||||||
|
return string.IsNullOrWhiteSpace(artwork) ? Artwork(metadata, fallback) : artwork;
|
||||||
|
}
|
||||||
|
|
||||||
private static string Artwork(Metadata metadata, ArtworkKind artworkKind)
|
private static string Artwork(Metadata metadata, ArtworkKind artworkKind)
|
||||||
{
|
{
|
||||||
string artwork = Optional(metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == artworkKind))
|
string artwork = Optional(metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == artworkKind))
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
using ErsatzTV.Core.Api.Search;
|
||||||
|
|
||||||
|
namespace ErsatzTV.Application.Search;
|
||||||
|
|
||||||
|
public record GetSearchResults(string Query, int PageSize) : IRequest<SearchResultsResponseModel>;
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
using ErsatzTV.Application.LibraryBrowse;
|
||||||
|
using ErsatzTV.Core.Api.LibraryBrowse;
|
||||||
|
using ErsatzTV.Core.Api.Search;
|
||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace ErsatzTV.Application.Search;
|
||||||
|
|
||||||
|
// Fans out to the shared library-browse query once per media kind (mirroring the legacy Search.razor page,
|
||||||
|
// which sends one `type:{kind} AND ({query})` query per kind). Reusing GetLibraryBrowseItems keeps hydration,
|
||||||
|
// artwork resolution and the response shape identical to /api/library/browse. Raw Lucene queries pass through
|
||||||
|
// unchanged, so state filters such as `state:FileNotFound` (the Trash screen) work here too.
|
||||||
|
public class GetSearchResultsHandler(IMediator mediator)
|
||||||
|
: IRequestHandler<GetSearchResults, SearchResultsResponseModel>
|
||||||
|
{
|
||||||
|
public async Task<SearchResultsResponseModel> Handle(
|
||||||
|
GetSearchResults request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
async Task<SearchResultGroupResponseModel> ForKind(LibraryBrowseMediaType kind)
|
||||||
|
{
|
||||||
|
PagedLibraryBrowseItemsResponseModel paged = await mediator.Send(
|
||||||
|
new GetLibraryBrowseItems(request.Query, null, kind, 0, request.PageSize),
|
||||||
|
cancellationToken);
|
||||||
|
return new SearchResultGroupResponseModel(paged.TotalCount, paged.Page);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new SearchResultsResponseModel(
|
||||||
|
await ForKind(LibraryBrowseMediaType.Movie),
|
||||||
|
await ForKind(LibraryBrowseMediaType.TelevisionShow),
|
||||||
|
await ForKind(LibraryBrowseMediaType.TelevisionSeason),
|
||||||
|
await ForKind(LibraryBrowseMediaType.Artist),
|
||||||
|
await ForKind(LibraryBrowseMediaType.Episode),
|
||||||
|
await ForKind(LibraryBrowseMediaType.MusicVideo),
|
||||||
|
await ForKind(LibraryBrowseMediaType.Song),
|
||||||
|
await ForKind(LibraryBrowseMediaType.OtherVideo),
|
||||||
|
await ForKind(LibraryBrowseMediaType.Image),
|
||||||
|
await ForKind(LibraryBrowseMediaType.RemoteStream));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,4 +19,5 @@ public record LibraryBrowseItemResponseModel(
|
|||||||
int? SmartCollectionId,
|
int? SmartCollectionId,
|
||||||
int? RerunCollectionId,
|
int? RerunCollectionId,
|
||||||
int? MediaItemId,
|
int? MediaItemId,
|
||||||
int? PlaylistId);
|
int? PlaylistId,
|
||||||
|
string? Subtitle = null);
|
||||||
|
|||||||
@@ -11,5 +11,11 @@ public enum LibraryBrowseMediaType
|
|||||||
SmartCollection = 6,
|
SmartCollection = 6,
|
||||||
MultiCollection = 7,
|
MultiCollection = 7,
|
||||||
RerunCollection = 8,
|
RerunCollection = 8,
|
||||||
Playlist = 9
|
Playlist = 9,
|
||||||
|
Episode = 10,
|
||||||
|
MusicVideo = 11,
|
||||||
|
Song = 12,
|
||||||
|
OtherVideo = 13,
|
||||||
|
Image = 14,
|
||||||
|
RemoteStream = 15
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
#nullable enable
|
||||||
|
using ErsatzTV.Core.Api.LibraryBrowse;
|
||||||
|
|
||||||
|
namespace ErsatzTV.Core.Api.Search;
|
||||||
|
|
||||||
|
public record SearchResultGroupResponseModel(
|
||||||
|
int TotalCount,
|
||||||
|
List<LibraryBrowseItemResponseModel> Items);
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
#nullable enable
|
||||||
|
namespace ErsatzTV.Core.Api.Search;
|
||||||
|
|
||||||
|
public record SearchResultsResponseModel(
|
||||||
|
SearchResultGroupResponseModel Movies,
|
||||||
|
SearchResultGroupResponseModel Shows,
|
||||||
|
SearchResultGroupResponseModel Seasons,
|
||||||
|
SearchResultGroupResponseModel Artists,
|
||||||
|
SearchResultGroupResponseModel Episodes,
|
||||||
|
SearchResultGroupResponseModel MusicVideos,
|
||||||
|
SearchResultGroupResponseModel Songs,
|
||||||
|
SearchResultGroupResponseModel OtherVideos,
|
||||||
|
SearchResultGroupResponseModel Images,
|
||||||
|
SearchResultGroupResponseModel RemoteStreams);
|
||||||
@@ -32,6 +32,7 @@ public class ApiControllerSecurityTests
|
|||||||
typeof(LibrariesController),
|
typeof(LibrariesController),
|
||||||
typeof(LogsController),
|
typeof(LogsController),
|
||||||
typeof(MaintenanceController),
|
typeof(MaintenanceController),
|
||||||
|
typeof(MediaItemsController),
|
||||||
typeof(PlayoutController),
|
typeof(PlayoutController),
|
||||||
typeof(ResolutionController),
|
typeof(ResolutionController),
|
||||||
typeof(ScannerController),
|
typeof(ScannerController),
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
using System.Reflection;
|
||||||
|
using ErsatzTV.Controllers.Api;
|
||||||
|
using ErsatzTV.Controllers.Api.Requests;
|
||||||
|
using ErsatzTV.Core;
|
||||||
|
using ErsatzTV.Application.Maintenance;
|
||||||
|
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 MediaItemsControllerTests
|
||||||
|
{
|
||||||
|
private MediaItemsController _controller = null!;
|
||||||
|
private IMediator _mediator = null!;
|
||||||
|
|
||||||
|
[SetUp]
|
||||||
|
public void SetUp()
|
||||||
|
{
|
||||||
|
_mediator = Substitute.For<IMediator>();
|
||||||
|
_controller = new MediaItemsController(_mediator);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Controller_Should_Expose_Delete_Route_With_Stable_Operation_Name()
|
||||||
|
{
|
||||||
|
MethodInfo action = typeof(MediaItemsController).GetMethod(nameof(MediaItemsController.Delete))
|
||||||
|
?? throw new AssertionException($"Missing action {nameof(MediaItemsController.Delete)}");
|
||||||
|
|
||||||
|
var attribute = action.GetCustomAttributes<HttpDeleteAttribute>().Single();
|
||||||
|
attribute.Template.ShouldBe("/api/media-items");
|
||||||
|
attribute.Name.ShouldBe("DeleteMediaItems");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Delete_Should_Return_422_For_Empty_Ids()
|
||||||
|
{
|
||||||
|
IActionResult result = await _controller.Delete(new DeleteMediaItemsRequest([]), CancellationToken.None);
|
||||||
|
|
||||||
|
var unprocessable = result.ShouldBeOfType<UnprocessableEntityObjectResult>();
|
||||||
|
unprocessable.StatusCode.ShouldBe(422);
|
||||||
|
await _mediator.DidNotReceive().Send(Arg.Any<DeleteItemsFromDatabase>(), Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Delete_Should_Return_204_On_Success()
|
||||||
|
{
|
||||||
|
_mediator.Send(Arg.Any<DeleteItemsFromDatabase>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(Right<BaseError, LanguageExt.Unit>(LanguageExt.Unit.Default));
|
||||||
|
|
||||||
|
IActionResult result = await _controller.Delete(
|
||||||
|
new DeleteMediaItemsRequest([1, 2, 3]),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
result.ShouldBeOfType<NoContentResult>();
|
||||||
|
await _mediator.Received(1).Send(
|
||||||
|
Arg.Is<DeleteItemsFromDatabase>(c => c.MediaItemIds.SequenceEqual(new[] { 1, 2, 3 })),
|
||||||
|
Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -213,6 +213,8 @@ public class OpenApiErrorResponseContractTests
|
|||||||
[TestCase("/api/settings/resolutions/{id}", "delete", "401")]
|
[TestCase("/api/settings/resolutions/{id}", "delete", "401")]
|
||||||
[TestCase("/api/settings/resolutions/{id}", "delete", "404")]
|
[TestCase("/api/settings/resolutions/{id}", "delete", "404")]
|
||||||
[TestCase("/api/settings/resolutions/{id}", "delete", "422")]
|
[TestCase("/api/settings/resolutions/{id}", "delete", "422")]
|
||||||
|
[TestCase("/api/search", "get", "422")]
|
||||||
|
[TestCase("/api/media-items", "delete", "422")]
|
||||||
public void Static_OpenApi_Should_Document_ProblemDetails_For_Api_Error_Responses(
|
public void Static_OpenApi_Should_Document_ProblemDetails_For_Api_Error_Responses(
|
||||||
string path,
|
string path,
|
||||||
string method,
|
string method,
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
using System.Reflection;
|
||||||
|
using ErsatzTV.Application.Search;
|
||||||
|
using ErsatzTV.Controllers.Api;
|
||||||
|
using ErsatzTV.Core.Api.Search;
|
||||||
|
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 SearchControllerTests
|
||||||
|
{
|
||||||
|
private SearchController _controller = null!;
|
||||||
|
private IMediator _mediator = null!;
|
||||||
|
|
||||||
|
[SetUp]
|
||||||
|
public void SetUp()
|
||||||
|
{
|
||||||
|
_mediator = Substitute.For<IMediator>();
|
||||||
|
_controller = new SearchController(_mediator);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Controller_Should_Expose_Search_Route_With_Stable_Operation_Name()
|
||||||
|
{
|
||||||
|
MethodInfo action = typeof(SearchController).GetMethod(nameof(SearchController.Search))
|
||||||
|
?? throw new AssertionException($"Missing action {nameof(SearchController.Search)}");
|
||||||
|
|
||||||
|
var attribute = action.GetCustomAttributes<HttpGetAttribute>().Single();
|
||||||
|
attribute.Template.ShouldBe("/api/search");
|
||||||
|
attribute.Name.ShouldBe("Search");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Search_Should_Return_422_For_Empty_Query()
|
||||||
|
{
|
||||||
|
IActionResult result = await _controller.Search(" ", 50, CancellationToken.None);
|
||||||
|
|
||||||
|
var unprocessable = result.ShouldBeOfType<UnprocessableEntityObjectResult>();
|
||||||
|
unprocessable.StatusCode.ShouldBe(422);
|
||||||
|
await _mediator.DidNotReceive().Send(Arg.Any<GetSearchResults>(), Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Search_Should_Clamp_PageSize_And_Send_Query()
|
||||||
|
{
|
||||||
|
_mediator.Send(Arg.Any<GetSearchResults>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(EmptyResults());
|
||||||
|
|
||||||
|
IActionResult result = await _controller.Search("star", 500, CancellationToken.None);
|
||||||
|
|
||||||
|
result.ShouldBeOfType<OkObjectResult>();
|
||||||
|
await _mediator.Received(1).Send(
|
||||||
|
Arg.Is<GetSearchResults>(q => q.Query == "star" && q.PageSize == 100),
|
||||||
|
Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Search_Should_Return_Grouped_Results()
|
||||||
|
{
|
||||||
|
SearchResultsResponseModel results = EmptyResults();
|
||||||
|
_mediator.Send(Arg.Any<GetSearchResults>(), Arg.Any<CancellationToken>()).Returns(results);
|
||||||
|
|
||||||
|
IActionResult result = await _controller.Search("star", 50, CancellationToken.None);
|
||||||
|
|
||||||
|
var ok = result.ShouldBeOfType<OkObjectResult>();
|
||||||
|
ok.Value.ShouldBe(results);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SearchResultsResponseModel EmptyResults()
|
||||||
|
{
|
||||||
|
var empty = new SearchResultGroupResponseModel(0, []);
|
||||||
|
return new SearchResultsResponseModel(empty, empty, empty, empty, empty, empty, empty, empty, empty, empty);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using ErsatzTV.Controllers.Api.Requests;
|
||||||
|
using ErsatzTV.Core;
|
||||||
|
using ErsatzTV.Extensions;
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace ErsatzTV.Controllers.Api;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
public class MediaItemsController(IMediator mediator) : ControllerBase
|
||||||
|
{
|
||||||
|
[HttpDelete("/api/media-items", Name = "DeleteMediaItems")]
|
||||||
|
[Tags("Media Items")]
|
||||||
|
[EndpointSummary("Delete media items from the database")]
|
||||||
|
[EndpointGroupName("general")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||||
|
public async Task<IActionResult> Delete(
|
||||||
|
[Required] [FromBody] DeleteMediaItemsRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (request.Ids is null || request.Ids.Count == 0)
|
||||||
|
{
|
||||||
|
return BaseError.New("At least one media item id is required").ToErrorResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
Either<BaseError, Unit> result = await mediator.Send(request.ToCommand(), cancellationToken);
|
||||||
|
return result.ToDeletedResult();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
using ErsatzTV.Application.Maintenance;
|
||||||
|
|
||||||
|
namespace ErsatzTV.Controllers.Api.Requests;
|
||||||
|
|
||||||
|
public record DeleteMediaItemsRequest(List<int> Ids)
|
||||||
|
{
|
||||||
|
public DeleteItemsFromDatabase ToCommand() => new(Ids);
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
using ErsatzTV.Application.Search;
|
||||||
|
using ErsatzTV.Core;
|
||||||
|
using ErsatzTV.Core.Api.Search;
|
||||||
|
using ErsatzTV.Extensions;
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace ErsatzTV.Controllers.Api;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
public class SearchController(IMediator mediator) : ControllerBase
|
||||||
|
{
|
||||||
|
private const int MaxPageSize = 100;
|
||||||
|
|
||||||
|
[HttpGet("/api/search", Name = "Search")]
|
||||||
|
[Tags("Search")]
|
||||||
|
[EndpointSummary("Search library items across all media kinds")]
|
||||||
|
[EndpointGroupName("general")]
|
||||||
|
[ProducesResponseType(typeof(SearchResultsResponseModel), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||||
|
public async Task<IActionResult> Search(
|
||||||
|
[FromQuery] string query = "",
|
||||||
|
[FromQuery] int pageSize = 50,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(query))
|
||||||
|
{
|
||||||
|
return BaseError.New("A non-empty query is required").ToErrorResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
int clampedPageSize = Math.Clamp(pageSize, 1, MaxPageSize);
|
||||||
|
SearchResultsResponseModel result = await mediator.Send(
|
||||||
|
new GetSearchResults(query, clampedPageSize),
|
||||||
|
cancellationToken);
|
||||||
|
return new OkObjectResult(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3196,6 +3196,65 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/media-items": {
|
||||||
|
"delete": {
|
||||||
|
"tags": [
|
||||||
|
"Media Items"
|
||||||
|
],
|
||||||
|
"summary": "Delete media items from the database",
|
||||||
|
"operationId": "DeleteMediaItems",
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json-patch+json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/DeleteMediaItemsRequest"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/DeleteMediaItemsRequest"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"text/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/DeleteMediaItemsRequest"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"application/*+json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/DeleteMediaItemsRequest"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"204": {
|
||||||
|
"description": "No Content"
|
||||||
|
},
|
||||||
|
"422": {
|
||||||
|
"description": "Unprocessable Entity",
|
||||||
|
"content": {
|
||||||
|
"text/plain": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ProblemDetails"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ProblemDetails"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"text/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ProblemDetails"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/media-sources": {
|
"/api/media-sources": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -4619,6 +4678,76 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/search": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"Search"
|
||||||
|
],
|
||||||
|
"summary": "Search library items across all media kinds",
|
||||||
|
"operationId": "Search",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "query",
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
"default": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "pageSize",
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32",
|
||||||
|
"default": 50
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"content": {
|
||||||
|
"text/plain": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/SearchResultsResponseModel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/SearchResultsResponseModel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"text/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/SearchResultsResponseModel"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"422": {
|
||||||
|
"description": "Unprocessable Entity",
|
||||||
|
"content": {
|
||||||
|
"text/plain": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ProblemDetails"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ProblemDetails"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"text/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ProblemDetails"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/sessions": {
|
"/api/sessions": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -8649,6 +8778,24 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"DeleteMediaItemsRequest": {
|
||||||
|
"required": [
|
||||||
|
"ids"
|
||||||
|
],
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"ids": {
|
||||||
|
"type": [
|
||||||
|
"null",
|
||||||
|
"array"
|
||||||
|
],
|
||||||
|
"items": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"FFmpegFullProfileResponseModel": {
|
"FFmpegFullProfileResponseModel": {
|
||||||
"required": [
|
"required": [
|
||||||
"id",
|
"id",
|
||||||
@@ -9450,6 +9597,12 @@
|
|||||||
"integer"
|
"integer"
|
||||||
],
|
],
|
||||||
"format": "int32"
|
"format": "int32"
|
||||||
|
},
|
||||||
|
"subtitle": {
|
||||||
|
"type": [
|
||||||
|
"null",
|
||||||
|
"string"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -9463,7 +9616,13 @@
|
|||||||
"SmartCollection",
|
"SmartCollection",
|
||||||
"MultiCollection",
|
"MultiCollection",
|
||||||
"RerunCollection",
|
"RerunCollection",
|
||||||
"Playlist"
|
"Playlist",
|
||||||
|
"Episode",
|
||||||
|
"MusicVideo",
|
||||||
|
"Song",
|
||||||
|
"OtherVideo",
|
||||||
|
"Image",
|
||||||
|
"RemoteStream"
|
||||||
],
|
],
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -10887,6 +11046,72 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"SearchResultGroupResponseModel": {
|
||||||
|
"required": [
|
||||||
|
"totalCount",
|
||||||
|
"items"
|
||||||
|
],
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"totalCount": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32"
|
||||||
|
},
|
||||||
|
"items": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/LibraryBrowseItemResponseModel"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"SearchResultsResponseModel": {
|
||||||
|
"required": [
|
||||||
|
"movies",
|
||||||
|
"shows",
|
||||||
|
"seasons",
|
||||||
|
"artists",
|
||||||
|
"episodes",
|
||||||
|
"musicVideos",
|
||||||
|
"songs",
|
||||||
|
"otherVideos",
|
||||||
|
"images",
|
||||||
|
"remoteStreams"
|
||||||
|
],
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"movies": {
|
||||||
|
"$ref": "#/components/schemas/SearchResultGroupResponseModel"
|
||||||
|
},
|
||||||
|
"shows": {
|
||||||
|
"$ref": "#/components/schemas/SearchResultGroupResponseModel"
|
||||||
|
},
|
||||||
|
"seasons": {
|
||||||
|
"$ref": "#/components/schemas/SearchResultGroupResponseModel"
|
||||||
|
},
|
||||||
|
"artists": {
|
||||||
|
"$ref": "#/components/schemas/SearchResultGroupResponseModel"
|
||||||
|
},
|
||||||
|
"episodes": {
|
||||||
|
"$ref": "#/components/schemas/SearchResultGroupResponseModel"
|
||||||
|
},
|
||||||
|
"musicVideos": {
|
||||||
|
"$ref": "#/components/schemas/SearchResultGroupResponseModel"
|
||||||
|
},
|
||||||
|
"songs": {
|
||||||
|
"$ref": "#/components/schemas/SearchResultGroupResponseModel"
|
||||||
|
},
|
||||||
|
"otherVideos": {
|
||||||
|
"$ref": "#/components/schemas/SearchResultGroupResponseModel"
|
||||||
|
},
|
||||||
|
"images": {
|
||||||
|
"$ref": "#/components/schemas/SearchResultGroupResponseModel"
|
||||||
|
},
|
||||||
|
"remoteStreams": {
|
||||||
|
"$ref": "#/components/schemas/SearchResultGroupResponseModel"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"SmartCollectionResponseModel": {
|
"SmartCollectionResponseModel": {
|
||||||
"required": [
|
"required": [
|
||||||
"id",
|
"id",
|
||||||
@@ -12252,6 +12477,9 @@
|
|||||||
{
|
{
|
||||||
"name": "Maintenance"
|
"name": "Maintenance"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "Media Items"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "Media Sources"
|
"name": "Media Sources"
|
||||||
},
|
},
|
||||||
@@ -12267,6 +12495,9 @@
|
|||||||
{
|
{
|
||||||
"name": "Schedules"
|
"name": "Schedules"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "Search"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "Sessions"
|
"name": "Sessions"
|
||||||
},
|
},
|
||||||
|
|||||||
Vendored
+21
-1
@@ -358,6 +358,9 @@ export interface components {
|
|||||||
"opacityExpression": null | string;
|
"opacityExpression": null | string;
|
||||||
"zIndex": number;
|
"zIndex": number;
|
||||||
"placeWithinSourceContent": boolean;
|
"placeWithinSourceContent": boolean;
|
||||||
|
};
|
||||||
|
"DeleteMediaItemsRequest": {
|
||||||
|
"ids": null | Array<number>;
|
||||||
};
|
};
|
||||||
"FFmpegFullProfileResponseModel": {
|
"FFmpegFullProfileResponseModel": {
|
||||||
"id": number;
|
"id": number;
|
||||||
@@ -504,8 +507,9 @@ export interface components {
|
|||||||
"rerunCollectionId": null | number;
|
"rerunCollectionId": null | number;
|
||||||
"mediaItemId": null | number;
|
"mediaItemId": null | number;
|
||||||
"playlistId": null | number;
|
"playlistId": null | number;
|
||||||
|
"subtitle"?: null | string;
|
||||||
};
|
};
|
||||||
"LibraryBrowseMediaType": "Movie" | "TelevisionShow" | "TelevisionSeason" | "Artist" | "Collection" | "SmartCollection" | "MultiCollection" | "RerunCollection" | "Playlist";
|
"LibraryBrowseMediaType": "Movie" | "TelevisionShow" | "TelevisionSeason" | "Artist" | "Collection" | "SmartCollection" | "MultiCollection" | "RerunCollection" | "Playlist" | "Episode" | "MusicVideo" | "Song" | "OtherVideo" | "Image" | "RemoteStream";
|
||||||
"LibraryMediaKind": "Movies" | "Shows" | "MusicVideos" | "OtherVideos" | "Songs" | "Images" | "RemoteStreams";
|
"LibraryMediaKind": "Movies" | "Shows" | "MusicVideos" | "OtherVideos" | "Songs" | "Images" | "RemoteStreams";
|
||||||
"LibraryScanStatusResponseModel": {
|
"LibraryScanStatusResponseModel": {
|
||||||
"libraryId": number;
|
"libraryId": number;
|
||||||
@@ -774,6 +778,22 @@ export interface components {
|
|||||||
"preferredAudioTitle": null | string;
|
"preferredAudioTitle": null | string;
|
||||||
"preferredSubtitleLanguageCode": null | string;
|
"preferredSubtitleLanguageCode": null | string;
|
||||||
"subtitleMode": null | components["schemas"]["ChannelSubtitleMode"];
|
"subtitleMode": null | components["schemas"]["ChannelSubtitleMode"];
|
||||||
|
};
|
||||||
|
"SearchResultGroupResponseModel": {
|
||||||
|
"totalCount": number;
|
||||||
|
"items": Array<components["schemas"]["LibraryBrowseItemResponseModel"]>;
|
||||||
|
};
|
||||||
|
"SearchResultsResponseModel": {
|
||||||
|
"movies": components["schemas"]["SearchResultGroupResponseModel"];
|
||||||
|
"shows": components["schemas"]["SearchResultGroupResponseModel"];
|
||||||
|
"seasons": components["schemas"]["SearchResultGroupResponseModel"];
|
||||||
|
"artists": components["schemas"]["SearchResultGroupResponseModel"];
|
||||||
|
"episodes": components["schemas"]["SearchResultGroupResponseModel"];
|
||||||
|
"musicVideos": components["schemas"]["SearchResultGroupResponseModel"];
|
||||||
|
"songs": components["schemas"]["SearchResultGroupResponseModel"];
|
||||||
|
"otherVideos": components["schemas"]["SearchResultGroupResponseModel"];
|
||||||
|
"images": components["schemas"]["SearchResultGroupResponseModel"];
|
||||||
|
"remoteStreams": components["schemas"]["SearchResultGroupResponseModel"];
|
||||||
};
|
};
|
||||||
"SmartCollectionResponseModel": {
|
"SmartCollectionResponseModel": {
|
||||||
"id": number;
|
"id": number;
|
||||||
|
|||||||
Reference in New Issue
Block a user