Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
282f59ea60 | ||
|
|
ca1265214e | ||
|
|
dc459c6cd5 | ||
|
|
cf85bf3093 | ||
|
|
72fc8cc850 | ||
|
|
f869dfe87a | ||
|
|
16c8789638 | ||
|
|
a496dad9c9 | ||
|
|
311411fa38 | ||
|
|
a7d3fc3055 | ||
|
|
316b673c3b | ||
|
|
9f73542296 |
@@ -0,0 +1,620 @@
|
||||
using ErsatzTV.Core.Api.LibraryBrowse;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Emby;
|
||||
using ErsatzTV.Core.Jellyfin;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Flurl;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.LibraryBrowse;
|
||||
|
||||
// Shared MediaItem -> LibraryBrowseItemResponseModel projection used by both the library-browse
|
||||
// search handler and the collection-items handler (#155). Keeping the per-kind hydration and the
|
||||
// rooted-artwork logic in one place avoids duplicating the Blazor-vs-SPA artwork rooting rules
|
||||
// (see the Artwork helper below and docs/api-conventions.md §4).
|
||||
internal static class LibraryBrowseItemMapper
|
||||
{
|
||||
// Hydrates an arbitrary set of media item ids (any kinds mixed) into response models. MediaItem
|
||||
// ids are globally unique across kinds, so passing the full id list to every per-kind query is
|
||||
// safe: each query only matches its own kind. Callers order/page the result themselves.
|
||||
public static async Task<List<LibraryBrowseItemResponseModel>> HydrateMediaItemsByIds(
|
||||
TvContext dbContext,
|
||||
IReadOnlyList<int> ids,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (ids.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var idList = ids.Distinct().ToList();
|
||||
|
||||
var results = new List<LibraryBrowseItemResponseModel>();
|
||||
results.AddRange(await GetMovies(dbContext, idList, cancellationToken));
|
||||
results.AddRange(await GetShows(dbContext, idList, cancellationToken));
|
||||
results.AddRange(await GetSeasons(dbContext, idList, cancellationToken));
|
||||
results.AddRange(await GetArtists(dbContext, idList, cancellationToken));
|
||||
results.AddRange(await GetEpisodes(dbContext, idList, cancellationToken));
|
||||
results.AddRange(await GetMusicVideos(dbContext, idList, cancellationToken));
|
||||
results.AddRange(await GetSongs(dbContext, idList, cancellationToken));
|
||||
results.AddRange(await GetOtherVideos(dbContext, idList, cancellationToken));
|
||||
results.AddRange(await GetImages(dbContext, idList, cancellationToken));
|
||||
results.AddRange(await GetRemoteStreams(dbContext, idList, cancellationToken));
|
||||
return results;
|
||||
}
|
||||
|
||||
public static async Task<List<LibraryBrowseItemResponseModel>> GetMovies(
|
||||
TvContext dbContext,
|
||||
List<int> ids,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (ids.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return await dbContext.MovieMetadata
|
||||
.AsNoTracking()
|
||||
.Where(mm => ids.Contains(mm.MovieId))
|
||||
.Include(mm => mm.Artwork)
|
||||
.Include(mm => mm.Movie)
|
||||
.ThenInclude(m => m.LibraryPath)
|
||||
.ThenInclude(lp => lp.Library)
|
||||
.Include(mm => mm.Movie)
|
||||
.ThenInclude(m => m.MediaVersions)
|
||||
.ToListAsync(cancellationToken)
|
||||
.Map(list => list
|
||||
.GroupBy(mm => mm.MovieId)
|
||||
.Select(g => g.OrderBy(mm => mm.Id).First())
|
||||
.Map(mm => new LibraryBrowseItemResponseModel(
|
||||
mm.MovieId,
|
||||
LibraryBrowseMediaType.Movie,
|
||||
mm.Title ?? string.Empty,
|
||||
mm.Movie.LibraryPath.LibraryId,
|
||||
mm.Movie.LibraryPath.Library.Name,
|
||||
Artwork(mm, ArtworkKind.Poster),
|
||||
BestDuration(mm.Movie.MediaVersions),
|
||||
1,
|
||||
null,
|
||||
CollectionType.Movie,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
mm.MovieId,
|
||||
null)).ToList());
|
||||
}
|
||||
|
||||
public static async Task<List<LibraryBrowseItemResponseModel>> GetShows(
|
||||
TvContext dbContext,
|
||||
List<int> ids,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (ids.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
Dictionary<int, int> counts = await dbContext.Episodes
|
||||
.AsNoTracking()
|
||||
.Where(e => ids.Contains(e.Season.ShowId))
|
||||
.GroupBy(e => e.Season.ShowId)
|
||||
.Select(g => new { ShowId = g.Key, Count = g.Count() })
|
||||
.ToDictionaryAsync(g => g.ShowId, g => g.Count, cancellationToken);
|
||||
|
||||
return await dbContext.ShowMetadata
|
||||
.AsNoTracking()
|
||||
.Where(sm => ids.Contains(sm.ShowId))
|
||||
.Include(sm => sm.Artwork)
|
||||
.Include(sm => sm.Show)
|
||||
.ThenInclude(s => s.LibraryPath)
|
||||
.ThenInclude(lp => lp.Library)
|
||||
.ToListAsync(cancellationToken)
|
||||
.Map(list => list
|
||||
.GroupBy(sm => sm.ShowId)
|
||||
.Select(g => g.OrderBy(sm => sm.Id).First())
|
||||
.Map(sm => new LibraryBrowseItemResponseModel(
|
||||
sm.ShowId,
|
||||
LibraryBrowseMediaType.TelevisionShow,
|
||||
sm.Title ?? string.Empty,
|
||||
sm.Show.LibraryPath.LibraryId,
|
||||
sm.Show.LibraryPath.Library.Name,
|
||||
Artwork(sm, ArtworkKind.Poster),
|
||||
null,
|
||||
counts.TryGetValue(sm.ShowId, out int count) ? count : 0,
|
||||
null,
|
||||
CollectionType.TelevisionShow,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
sm.ShowId,
|
||||
null)).ToList());
|
||||
}
|
||||
|
||||
public static async Task<List<LibraryBrowseItemResponseModel>> GetSeasons(
|
||||
TvContext dbContext,
|
||||
List<int> ids,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (ids.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
Dictionary<int, int> counts = await dbContext.Episodes
|
||||
.AsNoTracking()
|
||||
.Where(e => ids.Contains(e.SeasonId))
|
||||
.GroupBy(e => e.SeasonId)
|
||||
.Select(g => new { SeasonId = g.Key, Count = g.Count() })
|
||||
.ToDictionaryAsync(g => g.SeasonId, g => g.Count, cancellationToken);
|
||||
|
||||
return await dbContext.SeasonMetadata
|
||||
.AsNoTracking()
|
||||
.Where(sm => ids.Contains(sm.SeasonId))
|
||||
.Include(sm => sm.Artwork)
|
||||
.Include(sm => sm.Season)
|
||||
.ThenInclude(s => s.Show)
|
||||
.ThenInclude(s => s.ShowMetadata)
|
||||
.ThenInclude(shm => shm.Artwork)
|
||||
.Include(sm => sm.Season)
|
||||
.ThenInclude(s => s.LibraryPath)
|
||||
.ThenInclude(lp => lp.Library)
|
||||
.ToListAsync(cancellationToken)
|
||||
.Map(list => list
|
||||
.GroupBy(sm => sm.SeasonId)
|
||||
.Select(g => g.OrderBy(sm => sm.Id).First())
|
||||
.Map(sm => new LibraryBrowseItemResponseModel(
|
||||
sm.SeasonId,
|
||||
LibraryBrowseMediaType.TelevisionSeason,
|
||||
SeasonTitle(sm),
|
||||
sm.Season.LibraryPath.LibraryId,
|
||||
sm.Season.LibraryPath.Library.Name,
|
||||
SeasonArtwork(sm),
|
||||
null,
|
||||
counts.TryGetValue(sm.SeasonId, out int count) ? count : 0,
|
||||
null,
|
||||
CollectionType.TelevisionSeason,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
sm.SeasonId,
|
||||
null)).ToList());
|
||||
}
|
||||
|
||||
public static async Task<List<LibraryBrowseItemResponseModel>> GetArtists(
|
||||
TvContext dbContext,
|
||||
List<int> ids,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (ids.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
Dictionary<int, int> counts = await dbContext.MusicVideos
|
||||
.AsNoTracking()
|
||||
.Where(mv => ids.Contains(mv.ArtistId))
|
||||
.GroupBy(mv => mv.ArtistId)
|
||||
.Select(g => new { ArtistId = g.Key, Count = g.Count() })
|
||||
.ToDictionaryAsync(g => g.ArtistId, g => g.Count, cancellationToken);
|
||||
|
||||
return await dbContext.ArtistMetadata
|
||||
.AsNoTracking()
|
||||
.Where(am => ids.Contains(am.ArtistId))
|
||||
.Include(am => am.Artwork)
|
||||
.Include(am => am.Artist)
|
||||
.ThenInclude(a => a.LibraryPath)
|
||||
.ThenInclude(lp => lp.Library)
|
||||
.ToListAsync(cancellationToken)
|
||||
.Map(list => list
|
||||
.GroupBy(am => am.ArtistId)
|
||||
.Select(g => g.OrderBy(am => am.Id).First())
|
||||
.Map(am => new LibraryBrowseItemResponseModel(
|
||||
am.ArtistId,
|
||||
LibraryBrowseMediaType.Artist,
|
||||
am.Title ?? string.Empty,
|
||||
am.Artist.LibraryPath.LibraryId,
|
||||
am.Artist.LibraryPath.Library.Name,
|
||||
Artwork(am, ArtworkKind.Thumbnail),
|
||||
null,
|
||||
counts.TryGetValue(am.ArtistId, out int count) ? count : 0,
|
||||
null,
|
||||
CollectionType.Artist,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
am.ArtistId,
|
||||
null)).ToList());
|
||||
}
|
||||
|
||||
public 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());
|
||||
}
|
||||
|
||||
public 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());
|
||||
}
|
||||
|
||||
public 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());
|
||||
}
|
||||
|
||||
public 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());
|
||||
}
|
||||
|
||||
public 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());
|
||||
}
|
||||
|
||||
public 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());
|
||||
}
|
||||
|
||||
public static TimeSpan? BestDuration(IEnumerable<MediaVersion> versions)
|
||||
{
|
||||
TimeSpan duration = versions
|
||||
.Select(v => v.Duration)
|
||||
.Where(d => d > TimeSpan.Zero)
|
||||
.DefaultIfEmpty()
|
||||
.Max();
|
||||
return duration > TimeSpan.Zero ? duration : null;
|
||||
}
|
||||
|
||||
// Returns a rooted, directly-usable artwork URL for the SPA's <img src>. Blazor pages rely on
|
||||
// GetPosterUrl to prefix "artwork/posters/" and resolve relative to <base href="/">, but the SPA
|
||||
// renders the value raw from under /app/, so the API must root the URL itself (issue #180).
|
||||
public static string Artwork(Metadata metadata, ArtworkKind artworkKind)
|
||||
{
|
||||
string artwork = Optional(metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == artworkKind))
|
||||
.Match(a => a.Path, string.Empty);
|
||||
|
||||
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 is ArtworkKind.Thumbnail ? "thumbnails" : "posters";
|
||||
|
||||
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}";
|
||||
}
|
||||
|
||||
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 SeasonTitle(SeasonMetadata metadata)
|
||||
{
|
||||
string showTitle = metadata.Season.Show.ShowMetadata.HeadOrNone()
|
||||
.Map(sm => sm.Title ?? string.Empty)
|
||||
.IfNone(string.Empty);
|
||||
string seasonTitle = metadata.Season.SeasonNumber == 0
|
||||
? "Specials"
|
||||
: $"Season {metadata.Season.SeasonNumber}";
|
||||
return string.IsNullOrWhiteSpace(showTitle) ? seasonTitle : $"{showTitle} - {seasonTitle}";
|
||||
}
|
||||
|
||||
// Seasons often have no poster of their own; fall back to the parent show's poster (issue #180).
|
||||
private static string SeasonArtwork(SeasonMetadata metadata)
|
||||
{
|
||||
string artwork = Artwork(metadata, ArtworkKind.Poster);
|
||||
if (!string.IsNullOrWhiteSpace(artwork))
|
||||
{
|
||||
return artwork;
|
||||
}
|
||||
|
||||
return metadata.Season.Show.ShowMetadata.HeadOrNone()
|
||||
.Match(sm => Artwork(sm, ArtworkKind.Poster), string.Empty);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,9 @@
|
||||
using ErsatzTV.Core.Api.LibraryBrowse;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Emby;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Core.Jellyfin;
|
||||
using ErsatzTV.Core.Search;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Search;
|
||||
using Flurl;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.LibraryBrowse;
|
||||
@@ -167,53 +164,62 @@ public class GetLibraryBrowseItemsHandler(
|
||||
|
||||
Dictionary<(string Type, int Id), LibraryBrowseItemResponseModel> hydrated = [];
|
||||
|
||||
foreach (LibraryBrowseItemResponseModel item in await GetMovies(dbContext, movieIds, cancellationToken))
|
||||
foreach (LibraryBrowseItemResponseModel item in
|
||||
await LibraryBrowseItemMapper.GetMovies(dbContext, movieIds, cancellationToken))
|
||||
{
|
||||
hydrated[(LuceneSearchIndex.MovieType, item.Id)] = item;
|
||||
}
|
||||
|
||||
foreach (LibraryBrowseItemResponseModel item in await GetShows(dbContext, showIds, cancellationToken))
|
||||
foreach (LibraryBrowseItemResponseModel item in
|
||||
await LibraryBrowseItemMapper.GetShows(dbContext, showIds, cancellationToken))
|
||||
{
|
||||
hydrated[(LuceneSearchIndex.ShowType, item.Id)] = item;
|
||||
}
|
||||
|
||||
foreach (LibraryBrowseItemResponseModel item in await GetSeasons(dbContext, seasonIds, cancellationToken))
|
||||
foreach (LibraryBrowseItemResponseModel item in
|
||||
await LibraryBrowseItemMapper.GetSeasons(dbContext, seasonIds, cancellationToken))
|
||||
{
|
||||
hydrated[(LuceneSearchIndex.SeasonType, item.Id)] = item;
|
||||
}
|
||||
|
||||
foreach (LibraryBrowseItemResponseModel item in await GetArtists(dbContext, artistIds, cancellationToken))
|
||||
foreach (LibraryBrowseItemResponseModel item in
|
||||
await LibraryBrowseItemMapper.GetArtists(dbContext, artistIds, cancellationToken))
|
||||
{
|
||||
hydrated[(LuceneSearchIndex.ArtistType, item.Id)] = item;
|
||||
}
|
||||
|
||||
foreach (LibraryBrowseItemResponseModel item in await GetEpisodes(dbContext, episodeIds, cancellationToken))
|
||||
foreach (LibraryBrowseItemResponseModel item in
|
||||
await LibraryBrowseItemMapper.GetEpisodes(dbContext, episodeIds, cancellationToken))
|
||||
{
|
||||
hydrated[(LuceneSearchIndex.EpisodeType, item.Id)] = item;
|
||||
}
|
||||
|
||||
foreach (LibraryBrowseItemResponseModel item in await GetMusicVideos(dbContext, musicVideoIds, cancellationToken))
|
||||
foreach (LibraryBrowseItemResponseModel item in
|
||||
await LibraryBrowseItemMapper.GetMusicVideos(dbContext, musicVideoIds, cancellationToken))
|
||||
{
|
||||
hydrated[(LuceneSearchIndex.MusicVideoType, item.Id)] = item;
|
||||
}
|
||||
|
||||
foreach (LibraryBrowseItemResponseModel item in await GetSongs(dbContext, songIds, cancellationToken))
|
||||
foreach (LibraryBrowseItemResponseModel item in
|
||||
await LibraryBrowseItemMapper.GetSongs(dbContext, songIds, cancellationToken))
|
||||
{
|
||||
hydrated[(LuceneSearchIndex.SongType, item.Id)] = item;
|
||||
}
|
||||
|
||||
foreach (LibraryBrowseItemResponseModel item in await GetOtherVideos(dbContext, otherVideoIds, cancellationToken))
|
||||
foreach (LibraryBrowseItemResponseModel item in
|
||||
await LibraryBrowseItemMapper.GetOtherVideos(dbContext, otherVideoIds, cancellationToken))
|
||||
{
|
||||
hydrated[(LuceneSearchIndex.OtherVideoType, item.Id)] = item;
|
||||
}
|
||||
|
||||
foreach (LibraryBrowseItemResponseModel item in await GetImages(dbContext, imageIds, cancellationToken))
|
||||
foreach (LibraryBrowseItemResponseModel item in
|
||||
await LibraryBrowseItemMapper.GetImages(dbContext, imageIds, cancellationToken))
|
||||
{
|
||||
hydrated[(LuceneSearchIndex.ImageType, item.Id)] = item;
|
||||
}
|
||||
|
||||
foreach (LibraryBrowseItemResponseModel item in
|
||||
await GetRemoteStreams(dbContext, remoteStreamIds, cancellationToken))
|
||||
await LibraryBrowseItemMapper.GetRemoteStreams(dbContext, remoteStreamIds, cancellationToken))
|
||||
{
|
||||
hydrated[(LuceneSearchIndex.RemoteStreamType, item.Id)] = item;
|
||||
}
|
||||
@@ -224,95 +230,6 @@ public class GetLibraryBrowseItemsHandler(
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static async Task<List<LibraryBrowseItemResponseModel>> GetMovies(
|
||||
TvContext dbContext,
|
||||
List<int> ids,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (ids.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return await dbContext.MovieMetadata
|
||||
.AsNoTracking()
|
||||
.Where(mm => ids.Contains(mm.MovieId))
|
||||
.Include(mm => mm.Artwork)
|
||||
.Include(mm => mm.Movie)
|
||||
.ThenInclude(m => m.LibraryPath)
|
||||
.ThenInclude(lp => lp.Library)
|
||||
.Include(mm => mm.Movie)
|
||||
.ThenInclude(m => m.MediaVersions)
|
||||
.ToListAsync(cancellationToken)
|
||||
.Map(list => list
|
||||
.GroupBy(mm => mm.MovieId)
|
||||
.Select(g => g.OrderBy(mm => mm.Id).First())
|
||||
.Map(mm => new LibraryBrowseItemResponseModel(
|
||||
mm.MovieId,
|
||||
LibraryBrowseMediaType.Movie,
|
||||
mm.Title ?? string.Empty,
|
||||
mm.Movie.LibraryPath.LibraryId,
|
||||
mm.Movie.LibraryPath.Library.Name,
|
||||
Artwork(mm, ArtworkKind.Poster),
|
||||
BestDuration(mm.Movie.MediaVersions),
|
||||
1,
|
||||
null,
|
||||
CollectionType.Movie,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
mm.MovieId,
|
||||
null)).ToList());
|
||||
}
|
||||
|
||||
private static async Task<List<LibraryBrowseItemResponseModel>> GetShows(
|
||||
TvContext dbContext,
|
||||
List<int> ids,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (ids.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
Dictionary<int, int> counts = await dbContext.Episodes
|
||||
.AsNoTracking()
|
||||
.Where(e => ids.Contains(e.Season.ShowId))
|
||||
.GroupBy(e => e.Season.ShowId)
|
||||
.Select(g => new { ShowId = g.Key, Count = g.Count() })
|
||||
.ToDictionaryAsync(g => g.ShowId, g => g.Count, cancellationToken);
|
||||
|
||||
return await dbContext.ShowMetadata
|
||||
.AsNoTracking()
|
||||
.Where(sm => ids.Contains(sm.ShowId))
|
||||
.Include(sm => sm.Artwork)
|
||||
.Include(sm => sm.Show)
|
||||
.ThenInclude(s => s.LibraryPath)
|
||||
.ThenInclude(lp => lp.Library)
|
||||
.ToListAsync(cancellationToken)
|
||||
.Map(list => list
|
||||
.GroupBy(sm => sm.ShowId)
|
||||
.Select(g => g.OrderBy(sm => sm.Id).First())
|
||||
.Map(sm => new LibraryBrowseItemResponseModel(
|
||||
sm.ShowId,
|
||||
LibraryBrowseMediaType.TelevisionShow,
|
||||
sm.Title ?? string.Empty,
|
||||
sm.Show.LibraryPath.LibraryId,
|
||||
sm.Show.LibraryPath.Library.Name,
|
||||
Artwork(sm, ArtworkKind.Poster),
|
||||
null,
|
||||
counts.TryGetValue(sm.ShowId, out int count) ? count : 0,
|
||||
null,
|
||||
CollectionType.TelevisionShow,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
sm.ShowId,
|
||||
null)).ToList());
|
||||
}
|
||||
|
||||
private static async Task<PagedLibraryBrowseItemsResponseModel> BrowseSeasonsForShow(
|
||||
TvContext dbContext,
|
||||
GetLibraryBrowseItems request,
|
||||
@@ -331,7 +248,8 @@ public class GetLibraryBrowseItemsHandler(
|
||||
.Take(request.PageSize)
|
||||
.ToList();
|
||||
|
||||
List<LibraryBrowseItemResponseModel> seasons = await GetSeasons(dbContext, pageIds, cancellationToken);
|
||||
List<LibraryBrowseItemResponseModel> seasons =
|
||||
await LibraryBrowseItemMapper.GetSeasons(dbContext, pageIds, cancellationToken);
|
||||
|
||||
// GetSeasons groups by season id, so restore the requested season-number order.
|
||||
Dictionary<int, LibraryBrowseItemResponseModel> byId = seasons.ToDictionary(s => s.Id);
|
||||
@@ -366,7 +284,8 @@ public class GetLibraryBrowseItemsHandler(
|
||||
.Take(request.PageSize)
|
||||
.ToList();
|
||||
|
||||
List<LibraryBrowseItemResponseModel> episodes = await GetEpisodes(dbContext, pageIds, cancellationToken);
|
||||
List<LibraryBrowseItemResponseModel> episodes =
|
||||
await LibraryBrowseItemMapper.GetEpisodes(dbContext, pageIds, cancellationToken);
|
||||
|
||||
Dictionary<int, LibraryBrowseItemResponseModel> byId = episodes.ToDictionary(e => e.Id);
|
||||
List<LibraryBrowseItemResponseModel> ordered = pageIds
|
||||
@@ -401,7 +320,8 @@ public class GetLibraryBrowseItemsHandler(
|
||||
.Take(request.PageSize)
|
||||
.ToList();
|
||||
|
||||
List<LibraryBrowseItemResponseModel> musicVideos = await GetMusicVideos(dbContext, pageIds, cancellationToken);
|
||||
List<LibraryBrowseItemResponseModel> musicVideos =
|
||||
await LibraryBrowseItemMapper.GetMusicVideos(dbContext, pageIds, cancellationToken);
|
||||
|
||||
Dictionary<int, LibraryBrowseItemResponseModel> byId = musicVideos.ToDictionary(mv => mv.Id);
|
||||
List<LibraryBrowseItemResponseModel> ordered = pageIds
|
||||
@@ -412,405 +332,6 @@ public class GetLibraryBrowseItemsHandler(
|
||||
return new PagedLibraryBrowseItemsResponseModel(total, ordered);
|
||||
}
|
||||
|
||||
private static async Task<List<LibraryBrowseItemResponseModel>> GetSeasons(
|
||||
TvContext dbContext,
|
||||
List<int> ids,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (ids.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
Dictionary<int, int> counts = await dbContext.Episodes
|
||||
.AsNoTracking()
|
||||
.Where(e => ids.Contains(e.SeasonId))
|
||||
.GroupBy(e => e.SeasonId)
|
||||
.Select(g => new { SeasonId = g.Key, Count = g.Count() })
|
||||
.ToDictionaryAsync(g => g.SeasonId, g => g.Count, cancellationToken);
|
||||
|
||||
return await dbContext.SeasonMetadata
|
||||
.AsNoTracking()
|
||||
.Where(sm => ids.Contains(sm.SeasonId))
|
||||
.Include(sm => sm.Artwork)
|
||||
.Include(sm => sm.Season)
|
||||
.ThenInclude(s => s.Show)
|
||||
.ThenInclude(s => s.ShowMetadata)
|
||||
.ThenInclude(shm => shm.Artwork)
|
||||
.Include(sm => sm.Season)
|
||||
.ThenInclude(s => s.LibraryPath)
|
||||
.ThenInclude(lp => lp.Library)
|
||||
.ToListAsync(cancellationToken)
|
||||
.Map(list => list
|
||||
.GroupBy(sm => sm.SeasonId)
|
||||
.Select(g => g.OrderBy(sm => sm.Id).First())
|
||||
.Map(sm => new LibraryBrowseItemResponseModel(
|
||||
sm.SeasonId,
|
||||
LibraryBrowseMediaType.TelevisionSeason,
|
||||
SeasonTitle(sm),
|
||||
sm.Season.LibraryPath.LibraryId,
|
||||
sm.Season.LibraryPath.Library.Name,
|
||||
SeasonArtwork(sm),
|
||||
null,
|
||||
counts.TryGetValue(sm.SeasonId, out int count) ? count : 0,
|
||||
null,
|
||||
CollectionType.TelevisionSeason,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
sm.SeasonId,
|
||||
null)).ToList());
|
||||
}
|
||||
|
||||
private static async Task<List<LibraryBrowseItemResponseModel>> GetArtists(
|
||||
TvContext dbContext,
|
||||
List<int> ids,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (ids.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
Dictionary<int, int> counts = await dbContext.MusicVideos
|
||||
.AsNoTracking()
|
||||
.Where(mv => ids.Contains(mv.ArtistId))
|
||||
.GroupBy(mv => mv.ArtistId)
|
||||
.Select(g => new { ArtistId = g.Key, Count = g.Count() })
|
||||
.ToDictionaryAsync(g => g.ArtistId, g => g.Count, cancellationToken);
|
||||
|
||||
return await dbContext.ArtistMetadata
|
||||
.AsNoTracking()
|
||||
.Where(am => ids.Contains(am.ArtistId))
|
||||
.Include(am => am.Artwork)
|
||||
.Include(am => am.Artist)
|
||||
.ThenInclude(a => a.LibraryPath)
|
||||
.ThenInclude(lp => lp.Library)
|
||||
.ToListAsync(cancellationToken)
|
||||
.Map(list => list
|
||||
.GroupBy(am => am.ArtistId)
|
||||
.Select(g => g.OrderBy(am => am.Id).First())
|
||||
.Map(am => new LibraryBrowseItemResponseModel(
|
||||
am.ArtistId,
|
||||
LibraryBrowseMediaType.Artist,
|
||||
am.Title ?? string.Empty,
|
||||
am.Artist.LibraryPath.LibraryId,
|
||||
am.Artist.LibraryPath.Library.Name,
|
||||
Artwork(am, ArtworkKind.Thumbnail),
|
||||
null,
|
||||
counts.TryGetValue(am.ArtistId, out int count) ? count : 0,
|
||||
null,
|
||||
CollectionType.Artist,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
am.ArtistId,
|
||||
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(
|
||||
TvContext dbContext,
|
||||
GetLibraryBrowseItems request,
|
||||
@@ -1140,16 +661,6 @@ public class GetLibraryBrowseItemsHandler(
|
||||
private static bool ShouldInclude(LibraryBrowseMediaType? requestType, LibraryBrowseMediaType itemType) =>
|
||||
requestType is null || requestType == itemType;
|
||||
|
||||
private static TimeSpan? BestDuration(IEnumerable<MediaVersion> versions)
|
||||
{
|
||||
TimeSpan duration = versions
|
||||
.Select(v => v.Duration)
|
||||
.Where(d => d > TimeSpan.Zero)
|
||||
.DefaultIfEmpty()
|
||||
.Max();
|
||||
return duration > TimeSpan.Zero ? duration : null;
|
||||
}
|
||||
|
||||
private static async Task<Dictionary<int, TimeSpan?>> GetManualCollectionDurations(
|
||||
TvContext dbContext,
|
||||
List<int> collectionIds,
|
||||
@@ -1165,7 +676,7 @@ public class GetLibraryBrowseItemsHandler(
|
||||
.Include(m => m.MediaVersions)
|
||||
.ToListAsync(cancellationToken))
|
||||
{
|
||||
TimeSpan? duration = BestDuration(movie.MediaVersions);
|
||||
TimeSpan? duration = LibraryBrowseItemMapper.BestDuration(movie.MediaVersions);
|
||||
if (duration.HasValue)
|
||||
{
|
||||
mediaItemDurations[movie.Id] = duration.Value;
|
||||
@@ -1178,7 +689,7 @@ public class GetLibraryBrowseItemsHandler(
|
||||
.Include(e => e.MediaVersions)
|
||||
.ToListAsync(cancellationToken))
|
||||
{
|
||||
TimeSpan? duration = BestDuration(episode.MediaVersions);
|
||||
TimeSpan? duration = LibraryBrowseItemMapper.BestDuration(episode.MediaVersions);
|
||||
if (duration.HasValue)
|
||||
{
|
||||
mediaItemDurations[episode.Id] = duration.Value;
|
||||
@@ -1191,7 +702,7 @@ public class GetLibraryBrowseItemsHandler(
|
||||
.Include(mv => mv.MediaVersions)
|
||||
.ToListAsync(cancellationToken))
|
||||
{
|
||||
TimeSpan? duration = BestDuration(musicVideo.MediaVersions);
|
||||
TimeSpan? duration = LibraryBrowseItemMapper.BestDuration(musicVideo.MediaVersions);
|
||||
if (duration.HasValue)
|
||||
{
|
||||
mediaItemDurations[musicVideo.Id] = duration.Value;
|
||||
@@ -1204,7 +715,7 @@ public class GetLibraryBrowseItemsHandler(
|
||||
.Include(ov => ov.MediaVersions)
|
||||
.ToListAsync(cancellationToken))
|
||||
{
|
||||
TimeSpan? duration = BestDuration(otherVideo.MediaVersions);
|
||||
TimeSpan? duration = LibraryBrowseItemMapper.BestDuration(otherVideo.MediaVersions);
|
||||
if (duration.HasValue)
|
||||
{
|
||||
mediaItemDurations[otherVideo.Id] = duration.Value;
|
||||
@@ -1217,7 +728,7 @@ public class GetLibraryBrowseItemsHandler(
|
||||
.Include(s => s.MediaVersions)
|
||||
.ToListAsync(cancellationToken))
|
||||
{
|
||||
TimeSpan? duration = BestDuration(song.MediaVersions);
|
||||
TimeSpan? duration = LibraryBrowseItemMapper.BestDuration(song.MediaVersions);
|
||||
if (duration.HasValue)
|
||||
{
|
||||
mediaItemDurations[song.Id] = duration.Value;
|
||||
@@ -1230,7 +741,7 @@ public class GetLibraryBrowseItemsHandler(
|
||||
.Include(i => i.MediaVersions)
|
||||
.ToListAsync(cancellationToken))
|
||||
{
|
||||
TimeSpan? duration = BestDuration(image.MediaVersions);
|
||||
TimeSpan? duration = LibraryBrowseItemMapper.BestDuration(image.MediaVersions);
|
||||
if (duration.HasValue)
|
||||
{
|
||||
mediaItemDurations[image.Id] = duration.Value;
|
||||
@@ -1243,7 +754,7 @@ public class GetLibraryBrowseItemsHandler(
|
||||
.Include(rs => rs.MediaVersions)
|
||||
.ToListAsync(cancellationToken))
|
||||
{
|
||||
TimeSpan? duration = BestDuration(remoteStream.MediaVersions);
|
||||
TimeSpan? duration = LibraryBrowseItemMapper.BestDuration(remoteStream.MediaVersions);
|
||||
if (duration.HasValue)
|
||||
{
|
||||
mediaItemDurations[remoteStream.Id] = duration.Value;
|
||||
@@ -1274,7 +785,7 @@ public class GetLibraryBrowseItemsHandler(
|
||||
.OrderBy(mm => mm.Id)
|
||||
.ToListAsync(cancellationToken))
|
||||
{
|
||||
string poster = Artwork(metadata, ArtworkKind.Poster);
|
||||
string poster = LibraryBrowseItemMapper.Artwork(metadata, ArtworkKind.Poster);
|
||||
if (!string.IsNullOrWhiteSpace(poster) && !mediaItemArtwork.ContainsKey(metadata.MovieId))
|
||||
{
|
||||
mediaItemArtwork[metadata.MovieId] = poster;
|
||||
@@ -1288,7 +799,7 @@ public class GetLibraryBrowseItemsHandler(
|
||||
.OrderBy(sm => sm.Id)
|
||||
.ToListAsync(cancellationToken))
|
||||
{
|
||||
string poster = Artwork(metadata, ArtworkKind.Poster);
|
||||
string poster = LibraryBrowseItemMapper.Artwork(metadata, ArtworkKind.Poster);
|
||||
if (!string.IsNullOrWhiteSpace(poster) && !mediaItemArtwork.ContainsKey(metadata.ShowId))
|
||||
{
|
||||
mediaItemArtwork[metadata.ShowId] = poster;
|
||||
@@ -1302,7 +813,7 @@ public class GetLibraryBrowseItemsHandler(
|
||||
.OrderBy(sm => sm.Id)
|
||||
.ToListAsync(cancellationToken))
|
||||
{
|
||||
string poster = Artwork(metadata, ArtworkKind.Poster);
|
||||
string poster = LibraryBrowseItemMapper.Artwork(metadata, ArtworkKind.Poster);
|
||||
if (!string.IsNullOrWhiteSpace(poster) && !mediaItemArtwork.ContainsKey(metadata.SeasonId))
|
||||
{
|
||||
mediaItemArtwork[metadata.SeasonId] = poster;
|
||||
@@ -1316,7 +827,7 @@ public class GetLibraryBrowseItemsHandler(
|
||||
.OrderBy(ovm => ovm.Id)
|
||||
.ToListAsync(cancellationToken))
|
||||
{
|
||||
string poster = Artwork(metadata, ArtworkKind.Poster);
|
||||
string poster = LibraryBrowseItemMapper.Artwork(metadata, ArtworkKind.Poster);
|
||||
if (!string.IsNullOrWhiteSpace(poster) && !mediaItemArtwork.ContainsKey(metadata.OtherVideoId))
|
||||
{
|
||||
mediaItemArtwork[metadata.OtherVideoId] = poster;
|
||||
@@ -1330,7 +841,7 @@ public class GetLibraryBrowseItemsHandler(
|
||||
.OrderBy(sm => sm.Id)
|
||||
.ToListAsync(cancellationToken))
|
||||
{
|
||||
string poster = Artwork(metadata, ArtworkKind.Poster);
|
||||
string poster = LibraryBrowseItemMapper.Artwork(metadata, ArtworkKind.Poster);
|
||||
if (!string.IsNullOrWhiteSpace(poster) && !mediaItemArtwork.ContainsKey(metadata.SongId))
|
||||
{
|
||||
mediaItemArtwork[metadata.SongId] = poster;
|
||||
@@ -1344,7 +855,7 @@ public class GetLibraryBrowseItemsHandler(
|
||||
.OrderBy(im => im.Id)
|
||||
.ToListAsync(cancellationToken))
|
||||
{
|
||||
string poster = Artwork(metadata, ArtworkKind.Poster);
|
||||
string poster = LibraryBrowseItemMapper.Artwork(metadata, ArtworkKind.Poster);
|
||||
if (!string.IsNullOrWhiteSpace(poster) && !mediaItemArtwork.ContainsKey(metadata.ImageId))
|
||||
{
|
||||
mediaItemArtwork[metadata.ImageId] = poster;
|
||||
@@ -1358,7 +869,7 @@ public class GetLibraryBrowseItemsHandler(
|
||||
.OrderBy(rsm => rsm.Id)
|
||||
.ToListAsync(cancellationToken))
|
||||
{
|
||||
string poster = Artwork(metadata, ArtworkKind.Poster);
|
||||
string poster = LibraryBrowseItemMapper.Artwork(metadata, ArtworkKind.Poster);
|
||||
if (!string.IsNullOrWhiteSpace(poster) && !mediaItemArtwork.ContainsKey(metadata.RemoteStreamId))
|
||||
{
|
||||
mediaItemArtwork[metadata.RemoteStreamId] = poster;
|
||||
@@ -1383,83 +894,6 @@ public class GetLibraryBrowseItemsHandler(
|
||||
.Select(ci => new CollectionMediaItem(ci.CollectionId, ci.MediaItemId, ci.CustomIndex))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
private static string SeasonTitle(SeasonMetadata metadata)
|
||||
{
|
||||
string showTitle = metadata.Season.Show.ShowMetadata.HeadOrNone()
|
||||
.Map(sm => sm.Title ?? string.Empty)
|
||||
.IfNone(string.Empty);
|
||||
string seasonTitle = metadata.Season.SeasonNumber == 0
|
||||
? "Specials"
|
||||
: $"Season {metadata.Season.SeasonNumber}";
|
||||
return string.IsNullOrWhiteSpace(showTitle) ? seasonTitle : $"{showTitle} - {seasonTitle}";
|
||||
}
|
||||
|
||||
// Seasons often have no poster of their own; fall back to the parent show's poster (issue #180).
|
||||
private static string SeasonArtwork(SeasonMetadata metadata)
|
||||
{
|
||||
string artwork = Artwork(metadata, ArtworkKind.Poster);
|
||||
if (!string.IsNullOrWhiteSpace(artwork))
|
||||
{
|
||||
return artwork;
|
||||
}
|
||||
|
||||
return metadata.Season.Show.ShowMetadata.HeadOrNone()
|
||||
.Match(sm => Artwork(sm, ArtworkKind.Poster), string.Empty);
|
||||
}
|
||||
|
||||
private static string ArtworkWithFallback(Metadata metadata, ArtworkKind primary, ArtworkKind fallback)
|
||||
{
|
||||
string artwork = Artwork(metadata, primary);
|
||||
return string.IsNullOrWhiteSpace(artwork) ? Artwork(metadata, fallback) : artwork;
|
||||
}
|
||||
|
||||
// Returns a rooted, directly-usable artwork URL for the SPA's <img src>. Blazor pages rely on
|
||||
// GetPosterUrl to prefix "artwork/posters/" and resolve relative to <base href="/">, but the SPA
|
||||
// renders the value raw from under /app/, so the API must root the URL itself (issue #180).
|
||||
private static string Artwork(Metadata metadata, ArtworkKind artworkKind)
|
||||
{
|
||||
string artwork = Optional(metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == artworkKind))
|
||||
.Match(a => a.Path, string.Empty);
|
||||
|
||||
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 is ArtworkKind.Thumbnail ? "thumbnails" : "posters";
|
||||
|
||||
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}";
|
||||
}
|
||||
|
||||
private static string EscapeLike(string searchQuery) =>
|
||||
searchQuery
|
||||
.Replace("\\", "\\\\", StringComparison.Ordinal)
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Api.LibraryBrowse;
|
||||
|
||||
namespace ErsatzTV.Application.MediaCollections;
|
||||
|
||||
public record GetCollectionItems(int Id, int PageNum, int PageSize)
|
||||
: IRequest<Either<BaseError, PagedLibraryBrowseItemsResponseModel>>;
|
||||
@@ -0,0 +1,56 @@
|
||||
using ErsatzTV.Application.LibraryBrowse;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Api.LibraryBrowse;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.MediaCollections;
|
||||
|
||||
public class GetCollectionItemsHandler(IDbContextFactory<TvContext> dbContextFactory)
|
||||
: IRequestHandler<GetCollectionItems, Either<BaseError, PagedLibraryBrowseItemsResponseModel>>
|
||||
{
|
||||
public async Task<Either<BaseError, PagedLibraryBrowseItemsResponseModel>> Handle(
|
||||
GetCollectionItems request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
bool exists = await dbContext.Collections
|
||||
.AsNoTracking()
|
||||
.AnyAsync(c => c.Id == request.Id, cancellationToken);
|
||||
if (!exists)
|
||||
{
|
||||
return new NotFoundError($"Collection {request.Id} does not exist.");
|
||||
}
|
||||
|
||||
// The collection graph is bounded, so load every member id and hydrate them in one shared
|
||||
// pass (LibraryBrowseItemMapper), then order + page in-memory. Mixed media kinds are supported
|
||||
// because MediaItem ids are globally unique across kinds.
|
||||
List<int> mediaItemIds = await dbContext.CollectionItems
|
||||
.AsNoTracking()
|
||||
.Where(ci => ci.CollectionId == request.Id)
|
||||
.Select(ci => ci.MediaItemId)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
List<LibraryBrowseItemResponseModel> all =
|
||||
await LibraryBrowseItemMapper.HydrateMediaItemsByIds(dbContext, mediaItemIds, cancellationToken);
|
||||
|
||||
// Stable title ordering mirrors the library-browse handler (which orders its rows by name),
|
||||
// giving the SPA a deterministic, browsable list independent of collection insertion order.
|
||||
List<LibraryBrowseItemResponseModel> ordered = all
|
||||
.OrderBy(i => i.Title, StringComparer.OrdinalIgnoreCase)
|
||||
.ThenBy(i => i.Id)
|
||||
.ToList();
|
||||
|
||||
int pageNum = Math.Max(0, request.PageNum);
|
||||
int pageSize = Math.Clamp(request.PageSize, 1, 100);
|
||||
|
||||
List<LibraryBrowseItemResponseModel> page = ordered
|
||||
.Skip(pageNum * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToList();
|
||||
|
||||
return new PagedLibraryBrowseItemsResponseModel(ordered.Count, page);
|
||||
}
|
||||
}
|
||||
@@ -24,16 +24,42 @@ public class GetSearchResultsHandler(IMediator mediator)
|
||||
return new SearchResultGroupResponseModel(paged.TotalCount, paged.Page);
|
||||
}
|
||||
|
||||
// Each ForKind call sends through IMediator to GetLibraryBrowseItemsHandler, which creates its
|
||||
// own TvContext per invocation via IDbContextFactory<TvContext> (never shares one across calls),
|
||||
// so these are safe to run concurrently.
|
||||
Task<SearchResultGroupResponseModel> movies = ForKind(LibraryBrowseMediaType.Movie);
|
||||
Task<SearchResultGroupResponseModel> shows = ForKind(LibraryBrowseMediaType.TelevisionShow);
|
||||
Task<SearchResultGroupResponseModel> seasons = ForKind(LibraryBrowseMediaType.TelevisionSeason);
|
||||
Task<SearchResultGroupResponseModel> artists = ForKind(LibraryBrowseMediaType.Artist);
|
||||
Task<SearchResultGroupResponseModel> episodes = ForKind(LibraryBrowseMediaType.Episode);
|
||||
Task<SearchResultGroupResponseModel> musicVideos = ForKind(LibraryBrowseMediaType.MusicVideo);
|
||||
Task<SearchResultGroupResponseModel> songs = ForKind(LibraryBrowseMediaType.Song);
|
||||
Task<SearchResultGroupResponseModel> otherVideos = ForKind(LibraryBrowseMediaType.OtherVideo);
|
||||
Task<SearchResultGroupResponseModel> images = ForKind(LibraryBrowseMediaType.Image);
|
||||
Task<SearchResultGroupResponseModel> remoteStreams = ForKind(LibraryBrowseMediaType.RemoteStream);
|
||||
|
||||
await Task.WhenAll(
|
||||
movies,
|
||||
shows,
|
||||
seasons,
|
||||
artists,
|
||||
episodes,
|
||||
musicVideos,
|
||||
songs,
|
||||
otherVideos,
|
||||
images,
|
||||
remoteStreams);
|
||||
|
||||
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));
|
||||
movies.Result,
|
||||
shows.Result,
|
||||
seasons.Result,
|
||||
artists.Result,
|
||||
episodes.Result,
|
||||
musicVideos.Result,
|
||||
songs.Result,
|
||||
otherVideos.Result,
|
||||
images.Result,
|
||||
remoteStreams.Result);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
#nullable enable
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Core.Api.MediaCollections;
|
||||
|
||||
public record MultiCollectionResponseModel(
|
||||
int Id,
|
||||
string Name,
|
||||
List<MultiCollectionItemResponseModel> Items);
|
||||
|
||||
public record MultiCollectionItemResponseModel(
|
||||
int? CollectionId,
|
||||
int? SmartCollectionId,
|
||||
string Name,
|
||||
bool ScheduleAsGroup,
|
||||
PlaybackOrder PlaybackOrder);
|
||||
|
||||
public record PagedMultiCollectionsResponseModel(
|
||||
int TotalCount,
|
||||
List<MultiCollectionResponseModel> Page);
|
||||
@@ -0,0 +1,17 @@
|
||||
#nullable enable
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Core.Api.MediaCollections;
|
||||
|
||||
public record RerunCollectionResponseModel(
|
||||
int Id,
|
||||
string Name,
|
||||
CollectionType CollectionType,
|
||||
int? SelectedId,
|
||||
string? SelectedName,
|
||||
PlaybackOrder FirstRunPlaybackOrder,
|
||||
PlaybackOrder RerunPlaybackOrder);
|
||||
|
||||
public record PagedRerunCollectionsResponseModel(
|
||||
int TotalCount,
|
||||
List<RerunCollectionResponseModel> Page);
|
||||
@@ -2,8 +2,11 @@ using ErsatzTV.Application;
|
||||
using ErsatzTV.Application.MediaCollections;
|
||||
using ErsatzTV.Application.Search;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Api.LibraryBrowse;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using LanguageExt;
|
||||
using ErsatzTV.Tests.Support;
|
||||
using NSubstitute;
|
||||
@@ -130,6 +133,93 @@ public class CollectionHandlerTests : MediaCollectionHandlerTestBase
|
||||
LeftOf(result).ShouldBeOfType<NotFoundError>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetItems_Should_Return_NotFoundError_When_Collection_Missing()
|
||||
{
|
||||
var handler = new GetCollectionItemsHandler(Db.Factory);
|
||||
|
||||
Either<BaseError, PagedLibraryBrowseItemsResponseModel> result =
|
||||
await handler.Handle(new GetCollectionItems(999, 0, 100), CancellationToken.None);
|
||||
|
||||
LeftOf(result).ShouldBeOfType<NotFoundError>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetItems_Should_Return_Members_With_Rooted_Artwork()
|
||||
{
|
||||
await SeedCollectionWithMovie(collectionId: 1, movieId: 10, title: "Fake Movie", poster: "movie.jpg");
|
||||
var handler = new GetCollectionItemsHandler(Db.Factory);
|
||||
|
||||
Either<BaseError, PagedLibraryBrowseItemsResponseModel> result =
|
||||
await handler.Handle(new GetCollectionItems(1, 0, 100), CancellationToken.None);
|
||||
|
||||
PagedLibraryBrowseItemsResponseModel page = result.Match(
|
||||
Left: _ => throw new AssertionException("Expected a Right result"),
|
||||
Right: value => value);
|
||||
|
||||
page.TotalCount.ShouldBe(1);
|
||||
page.Page.Count.ShouldBe(1);
|
||||
page.Page[0].Title.ShouldBe("Fake Movie");
|
||||
page.Page[0].MediaItemId.ShouldBe(10);
|
||||
page.Page[0].MediaType.ShouldBe(LibraryBrowseMediaType.Movie);
|
||||
page.Page[0].Artwork.ShouldBe("/artwork/posters/movie.jpg");
|
||||
}
|
||||
|
||||
private async Task SeedCollectionWithMovie(int collectionId, int movieId, string title, string poster)
|
||||
{
|
||||
await using TvContext context = Db.CreateContext();
|
||||
var library = new LocalLibrary
|
||||
{
|
||||
Id = collectionId,
|
||||
Name = "Local",
|
||||
MediaKind = LibraryMediaKind.Movies,
|
||||
Paths = []
|
||||
};
|
||||
var path = new LibraryPath
|
||||
{
|
||||
Id = collectionId,
|
||||
Path = "/media",
|
||||
Library = library,
|
||||
LibraryFolders = [],
|
||||
MediaItems = []
|
||||
};
|
||||
library.Paths.Add(path);
|
||||
|
||||
var collection = new Collection { Id = collectionId, Name = "Collection", MediaItems = [] };
|
||||
var movie = new Movie
|
||||
{
|
||||
Id = movieId,
|
||||
LibraryPath = path,
|
||||
Collections = [collection],
|
||||
CollectionItems = [],
|
||||
TraktListItems = [],
|
||||
MediaVersions = [new MediaVersion { Duration = TimeSpan.FromMinutes(42) }],
|
||||
MovieMetadata =
|
||||
[
|
||||
new MovieMetadata
|
||||
{
|
||||
Title = title,
|
||||
SortTitle = title,
|
||||
Artwork = [new Artwork { Path = poster, ArtworkKind = ArtworkKind.Poster }],
|
||||
Genres = [],
|
||||
Tags = [],
|
||||
Studios = [],
|
||||
Actors = [],
|
||||
Guids = [],
|
||||
Subtitles = [],
|
||||
Directors = [],
|
||||
Writers = []
|
||||
}
|
||||
]
|
||||
};
|
||||
collection.MediaItems.Add(movie);
|
||||
|
||||
context.LocalLibraries.Add(library);
|
||||
context.Collections.Add(collection);
|
||||
context.Movies.Add(movie);
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private static AddItemsToCollection MakeAddItems(int collectionId, List<int>? movieIds = null) =>
|
||||
new(
|
||||
collectionId,
|
||||
|
||||
@@ -23,37 +23,26 @@ public class ApiControllerSecurityTests
|
||||
[Test]
|
||||
public void Every_Mutating_Api_Action_Should_Be_Globally_Protected_Or_Explicitly_Exempt()
|
||||
{
|
||||
Type[] apiControllers =
|
||||
[
|
||||
typeof(ArtistsController),
|
||||
typeof(BlockController),
|
||||
typeof(ChannelController),
|
||||
typeof(CollectionController),
|
||||
typeof(DecoController),
|
||||
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),
|
||||
typeof(TraktController),
|
||||
typeof(TroubleshootController),
|
||||
typeof(WatermarkController)
|
||||
];
|
||||
// Scan the assembly instead of hand-maintaining a list of controllers here: a
|
||||
// hardcoded array silently drifts as new controllers are added (9 were missing at
|
||||
// one point, including two with real mutating actions - ArtworkUploadController POST
|
||||
// and ChannelTemplateController POST/PUT/DELETE - that were never actually checked).
|
||||
// Note: not every controller in this namespace derives from ControllerBase (e.g.
|
||||
// ChannelController, ScannerController, SessionController, MaintenanceController,
|
||||
// TroubleshootController) - filter on [ApiController] + concrete class only, matching
|
||||
// the pattern already used by ScannerController_Should_Be_Only_Api_Key_Exempt_Api_Controller
|
||||
// below, so those controllers stay covered rather than silently dropping out of the scan.
|
||||
Type[] apiControllers = typeof(CollectionController)
|
||||
.Assembly
|
||||
.GetTypes()
|
||||
.Where(t => t.Namespace == typeof(CollectionController).Namespace)
|
||||
.Where(t => t is { IsClass: true, IsAbstract: false })
|
||||
.Where(t => t.GetCustomAttributes<ApiControllerAttribute>(inherit: true).Any())
|
||||
.ToArray();
|
||||
|
||||
// Guard against the scan silently matching nothing (e.g. a namespace rename) and
|
||||
// giving this test a false pass.
|
||||
apiControllers.Length.ShouldBeGreaterThanOrEqualTo(20);
|
||||
|
||||
foreach (Type controllerType in apiControllers)
|
||||
{
|
||||
|
||||
@@ -3,6 +3,7 @@ using ErsatzTV.Application.MediaCollections;
|
||||
using ErsatzTV.Controllers.Api;
|
||||
using ErsatzTV.Controllers.Api.Requests;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Api.LibraryBrowse;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using LanguageExt;
|
||||
@@ -35,6 +36,7 @@ public class CollectionControllerTests
|
||||
{
|
||||
ShouldHaveActionRoute(nameof(CollectionController.GetAll), "GET", "/api/collections");
|
||||
ShouldHaveActionRoute(nameof(CollectionController.GetById), "GET", "/api/collections/{id:int}");
|
||||
ShouldHaveActionRoute(nameof(CollectionController.GetItems), "GET", "/api/collections/{id:int}/items");
|
||||
ShouldHaveActionRoute(nameof(CollectionController.Create), "POST", "/api/collections");
|
||||
ShouldHaveActionRoute(nameof(CollectionController.Update), "PUT", "/api/collections/{id:int}");
|
||||
ShouldHaveActionRoute(nameof(CollectionController.Delete), "DELETE", "/api/collections/{id:int}");
|
||||
@@ -238,6 +240,33 @@ public class CollectionControllerTests
|
||||
problemDetails.Title.ShouldBe("Resource not found");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetItems_Should_Return_200_With_Page_And_Clamp_Paging()
|
||||
{
|
||||
var page = new PagedLibraryBrowseItemsResponseModel(0, []);
|
||||
_mediator.Send(Arg.Any<GetCollectionItems>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, PagedLibraryBrowseItemsResponseModel>(page));
|
||||
|
||||
IActionResult result = await _controller.GetItems(7, -5, 999, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBe(page);
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<GetCollectionItems>(q => q.Id == 7 && q.PageNum == 0 && q.PageSize == 100),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetItems_Should_Return_404_For_NotFoundError()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetCollectionItems>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Left<BaseError, PagedLibraryBrowseItemsResponseModel>(new NotFoundError("missing")));
|
||||
|
||||
IActionResult result = await _controller.GetItems(99, 0, 100, CancellationToken.None);
|
||||
|
||||
var notFound = result.ShouldBeOfType<NotFoundObjectResult>();
|
||||
notFound.Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(404);
|
||||
}
|
||||
|
||||
private static MediaCollectionViewModel MakeVm(int id, string name) =>
|
||||
new(CollectionType.Collection, id, name, false, MediaItemState.Normal);
|
||||
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
using System.Reflection;
|
||||
using ErsatzTV.Application.MediaCollections;
|
||||
using ErsatzTV.Controllers.Api;
|
||||
using ErsatzTV.Controllers.Api.Requests;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Api.MediaCollections;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Routing;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
using static LanguageExt.Prelude;
|
||||
using Unit = LanguageExt.Unit;
|
||||
|
||||
namespace ErsatzTV.Tests.Controllers;
|
||||
|
||||
[TestFixture]
|
||||
public class MultiCollectionControllerTests
|
||||
{
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
_mediator = Substitute.For<IMediator>();
|
||||
_controller = new MultiCollectionController(_mediator);
|
||||
}
|
||||
|
||||
private MultiCollectionController _controller = null!;
|
||||
private IMediator _mediator = null!;
|
||||
|
||||
[Test]
|
||||
public void Controller_Should_Expose_Idiomatic_Rest_Routes()
|
||||
{
|
||||
ShouldHaveActionRoute(nameof(MultiCollectionController.GetAll), "GET", "/api/multi-collections");
|
||||
ShouldHaveActionRoute(nameof(MultiCollectionController.GetById), "GET", "/api/multi-collections/{id:int}");
|
||||
ShouldHaveActionRoute(nameof(MultiCollectionController.Create), "POST", "/api/multi-collections");
|
||||
ShouldHaveActionRoute(nameof(MultiCollectionController.Update), "PUT", "/api/multi-collections/{id:int}");
|
||||
ShouldHaveActionRoute(nameof(MultiCollectionController.Delete), "DELETE", "/api/multi-collections/{id:int}");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetAll_Should_Clamp_Paging_And_Map_Response()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetPagedMultiCollections>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new PagedMultiCollectionsViewModel(1, new List<MultiCollectionViewModel> { MakeMulti(3, "Kids") }));
|
||||
|
||||
PagedMultiCollectionsResponseModel result =
|
||||
await _controller.GetAll("k", -5, 9999, CancellationToken.None);
|
||||
|
||||
result.TotalCount.ShouldBe(1);
|
||||
result.Page.Count.ShouldBe(1);
|
||||
result.Page[0].Id.ShouldBe(3);
|
||||
result.Page[0].Items.Count.ShouldBe(2);
|
||||
result.Page[0].Items[0].CollectionId.ShouldBe(10);
|
||||
result.Page[0].Items[1].SmartCollectionId.ShouldBe(20);
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<GetPagedMultiCollections>(q => q.Query == "k" && q.PageNum == 0 && q.PageSize == 100),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetById_Should_Return_200_For_Some()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetMultiCollectionById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<MultiCollectionViewModel>.Some(MakeMulti(4, "Kids")));
|
||||
|
||||
IActionResult result = await _controller.GetById(4, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBeOfType<MultiCollectionResponseModel>().Id.ShouldBe(4);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetById_Should_Return_404_For_None()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetMultiCollectionById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<MultiCollectionViewModel>.None);
|
||||
|
||||
IActionResult result = await _controller.GetById(4, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<NotFoundObjectResult>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Create_Should_Return_201_And_Map_Request()
|
||||
{
|
||||
_mediator.Send(Arg.Any<CreateMultiCollection>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, MultiCollectionViewModel>(MakeMulti(8, "Kids")));
|
||||
|
||||
IActionResult result = await _controller.Create(
|
||||
new CreateMultiCollectionRequest(
|
||||
"Kids",
|
||||
new List<MultiCollectionItemRequest>
|
||||
{
|
||||
new(10, null, true, PlaybackOrder.Chronological),
|
||||
new(null, 20, false, PlaybackOrder.Shuffle)
|
||||
}),
|
||||
CancellationToken.None);
|
||||
|
||||
var created = result.ShouldBeOfType<CreatedResult>();
|
||||
created.Location.ShouldBe("/api/multi-collections/8");
|
||||
created.Value.ShouldBeOfType<MultiCollectionResponseModel>().Name.ShouldBe("Kids");
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<CreateMultiCollection>(c =>
|
||||
c.Name == "Kids" &&
|
||||
c.Items.Count == 2 &&
|
||||
c.Items[0].CollectionId == 10 &&
|
||||
c.Items[0].ScheduleAsGroup &&
|
||||
c.Items[0].PlaybackOrder == PlaybackOrder.Chronological &&
|
||||
c.Items[1].SmartCollectionId == 20),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Create_Should_Return_422_On_Validation_Error()
|
||||
{
|
||||
_mediator.Send(Arg.Any<CreateMultiCollection>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Left<BaseError, MultiCollectionViewModel>(BaseError.New("dupe")));
|
||||
|
||||
IActionResult result = await _controller.Create(
|
||||
new CreateMultiCollectionRequest("Kids", new List<MultiCollectionItemRequest>()),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Update_Should_Return_200_With_Refreshed_Body_And_Map_Request()
|
||||
{
|
||||
_mediator.Send(Arg.Any<UpdateMultiCollection>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, Unit>(Unit.Default));
|
||||
_mediator.Send(Arg.Any<GetMultiCollectionById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<MultiCollectionViewModel>.Some(MakeMulti(4, "Kids")));
|
||||
|
||||
IActionResult result = await _controller.Update(
|
||||
4,
|
||||
new UpdateMultiCollectionRequest(
|
||||
"Kids",
|
||||
new List<MultiCollectionItemRequest> { new(10, null, true, PlaybackOrder.Random) }),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBeOfType<MultiCollectionResponseModel>().Id.ShouldBe(4);
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<UpdateMultiCollection>(c =>
|
||||
c.MultiCollectionId == 4 &&
|
||||
c.Name == "Kids" &&
|
||||
c.Items.Count == 1 &&
|
||||
c.Items[0].CollectionId == 10 &&
|
||||
c.Items[0].PlaybackOrder == PlaybackOrder.Random),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Update_Should_Return_422_On_Validation_Error()
|
||||
{
|
||||
_mediator.Send(Arg.Any<UpdateMultiCollection>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Left<BaseError, Unit>(BaseError.New("bad")));
|
||||
|
||||
IActionResult result = await _controller.Update(
|
||||
4,
|
||||
new UpdateMultiCollectionRequest("Kids", new List<MultiCollectionItemRequest>()),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
|
||||
await _mediator.DidNotReceive().Send(Arg.Any<GetMultiCollectionById>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Delete_Should_Return_204_On_Success()
|
||||
{
|
||||
_mediator.Send(Arg.Any<DeleteMultiCollection>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, Unit>(Unit.Default));
|
||||
|
||||
IActionResult result = await _controller.Delete(4, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<NoContentResult>();
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<DeleteMultiCollection>(c => c.MultiCollectionId == 4),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Delete_Should_Return_404_On_NotFoundError()
|
||||
{
|
||||
_mediator.Send(Arg.Any<DeleteMultiCollection>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Left<BaseError, Unit>(new ErsatzTV.Core.Errors.NotFoundError("missing")));
|
||||
|
||||
IActionResult result = await _controller.Delete(4, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<NotFoundObjectResult>();
|
||||
}
|
||||
|
||||
private static MultiCollectionViewModel MakeMulti(int id, string name) =>
|
||||
new(
|
||||
id,
|
||||
name,
|
||||
new List<MultiCollectionItemViewModel>
|
||||
{
|
||||
new(
|
||||
id,
|
||||
new MediaCollectionViewModel(CollectionType.Collection, 10, "Movies", false, MediaItemState.Normal),
|
||||
true,
|
||||
PlaybackOrder.Chronological)
|
||||
},
|
||||
new List<MultiCollectionSmartItemViewModel>
|
||||
{
|
||||
new(id, new SmartCollectionViewModel(20, "Smart", "query"), false, PlaybackOrder.Shuffle)
|
||||
});
|
||||
|
||||
private static void ShouldHaveActionRoute(string actionName, string httpMethod, string route)
|
||||
{
|
||||
MethodInfo action = typeof(MultiCollectionController).GetMethod(actionName)
|
||||
?? throw new AssertionException($"Missing action {actionName}");
|
||||
|
||||
HttpMethodAttribute attribute = action.GetCustomAttributes<HttpMethodAttribute>(inherit: true).Single();
|
||||
attribute.HttpMethods.ShouldContain(httpMethod);
|
||||
attribute.Template.ShouldBe(route);
|
||||
}
|
||||
}
|
||||
@@ -126,6 +126,7 @@ public class OpenApiErrorResponseContractTests
|
||||
[TestCase("/api/channel-templates/{id}", "delete", "404")]
|
||||
[TestCase("/api/channel-templates/{id}", "delete", "422")]
|
||||
[TestCase("/api/collections/{id}", "get", "404")]
|
||||
[TestCase("/api/collections/{id}/items", "get", "404")]
|
||||
[TestCase("/api/collections", "post", "404")]
|
||||
[TestCase("/api/collections", "post", "422")]
|
||||
[TestCase("/api/collections/{id}", "put", "404")]
|
||||
@@ -143,6 +144,20 @@ public class OpenApiErrorResponseContractTests
|
||||
[TestCase("/api/smart-collections/{id}", "put", "422")]
|
||||
[TestCase("/api/smart-collections/{id}", "delete", "404")]
|
||||
[TestCase("/api/smart-collections/{id}", "delete", "422")]
|
||||
[TestCase("/api/multi-collections/{id}", "get", "404")]
|
||||
[TestCase("/api/multi-collections", "post", "404")]
|
||||
[TestCase("/api/multi-collections", "post", "422")]
|
||||
[TestCase("/api/multi-collections/{id}", "put", "404")]
|
||||
[TestCase("/api/multi-collections/{id}", "put", "422")]
|
||||
[TestCase("/api/multi-collections/{id}", "delete", "404")]
|
||||
[TestCase("/api/multi-collections/{id}", "delete", "422")]
|
||||
[TestCase("/api/rerun-collections/{id}", "get", "404")]
|
||||
[TestCase("/api/rerun-collections", "post", "404")]
|
||||
[TestCase("/api/rerun-collections", "post", "422")]
|
||||
[TestCase("/api/rerun-collections/{id}", "put", "404")]
|
||||
[TestCase("/api/rerun-collections/{id}", "put", "422")]
|
||||
[TestCase("/api/rerun-collections/{id}", "delete", "404")]
|
||||
[TestCase("/api/rerun-collections/{id}", "delete", "422")]
|
||||
[TestCase("/api/schedules/{id}", "get", "404")]
|
||||
[TestCase("/api/schedules", "post", "404")]
|
||||
[TestCase("/api/schedules", "post", "422")]
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
using System.Reflection;
|
||||
using ErsatzTV.Application.MediaCollections;
|
||||
using ErsatzTV.Controllers.Api;
|
||||
using ErsatzTV.Controllers.Api.Requests;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Api.MediaCollections;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Routing;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
using static LanguageExt.Prelude;
|
||||
using Unit = LanguageExt.Unit;
|
||||
|
||||
namespace ErsatzTV.Tests.Controllers;
|
||||
|
||||
[TestFixture]
|
||||
public class RerunCollectionControllerTests
|
||||
{
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
_mediator = Substitute.For<IMediator>();
|
||||
_controller = new RerunCollectionController(_mediator);
|
||||
}
|
||||
|
||||
private RerunCollectionController _controller = null!;
|
||||
private IMediator _mediator = null!;
|
||||
|
||||
[Test]
|
||||
public void Controller_Should_Expose_Idiomatic_Rest_Routes()
|
||||
{
|
||||
ShouldHaveActionRoute(nameof(RerunCollectionController.GetAll), "GET", "/api/rerun-collections");
|
||||
ShouldHaveActionRoute(nameof(RerunCollectionController.GetById), "GET", "/api/rerun-collections/{id:int}");
|
||||
ShouldHaveActionRoute(nameof(RerunCollectionController.Create), "POST", "/api/rerun-collections");
|
||||
ShouldHaveActionRoute(nameof(RerunCollectionController.Update), "PUT", "/api/rerun-collections/{id:int}");
|
||||
ShouldHaveActionRoute(nameof(RerunCollectionController.Delete), "DELETE", "/api/rerun-collections/{id:int}");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetAll_Should_Clamp_Paging_And_Map_Response()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetPagedRerunCollections>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new PagedRerunCollectionsViewModel(
|
||||
1,
|
||||
new List<RerunCollectionViewModel> { MakeRerunFromSmart(3, "Nightly") }));
|
||||
|
||||
PagedRerunCollectionsResponseModel result =
|
||||
await _controller.GetAll("n", -5, 9999, CancellationToken.None);
|
||||
|
||||
result.TotalCount.ShouldBe(1);
|
||||
result.Page.Count.ShouldBe(1);
|
||||
result.Page[0].Id.ShouldBe(3);
|
||||
result.Page[0].CollectionType.ShouldBe(CollectionType.SmartCollection);
|
||||
result.Page[0].SelectedId.ShouldBe(20);
|
||||
result.Page[0].SelectedName.ShouldBe("Smart");
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<GetPagedRerunCollections>(q => q.Query == "n" && q.PageNum == 0 && q.PageSize == 100),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetById_Should_Return_200_For_Some()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetRerunCollectionById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<RerunCollectionViewModel>.Some(MakeRerunFromSmart(4, "Nightly")));
|
||||
|
||||
IActionResult result = await _controller.GetById(4, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBeOfType<RerunCollectionResponseModel>().Id.ShouldBe(4);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetById_Should_Return_404_For_None()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetRerunCollectionById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<RerunCollectionViewModel>.None);
|
||||
|
||||
IActionResult result = await _controller.GetById(4, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<NotFoundObjectResult>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Create_Should_Return_201_And_Resolve_Selected_Id_Into_Collection_Vm()
|
||||
{
|
||||
_mediator.Send(Arg.Any<CreateRerunCollection>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, RerunCollectionViewModel>(MakeRerunFromSmart(8, "Nightly")));
|
||||
|
||||
IActionResult result = await _controller.Create(
|
||||
new CreateRerunCollectionRequest(
|
||||
"Nightly",
|
||||
CollectionType.Collection,
|
||||
42,
|
||||
PlaybackOrder.Chronological,
|
||||
PlaybackOrder.Shuffle),
|
||||
CancellationToken.None);
|
||||
|
||||
var created = result.ShouldBeOfType<CreatedResult>();
|
||||
created.Location.ShouldBe("/api/rerun-collections/8");
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<CreateRerunCollection>(c =>
|
||||
c.Name == "Nightly" &&
|
||||
c.CollectionType == CollectionType.Collection &&
|
||||
c.Collection != null && c.Collection.Id == 42 &&
|
||||
c.MultiCollection == null &&
|
||||
c.SmartCollection == null &&
|
||||
c.MediaItem == null &&
|
||||
c.FirstRunPlaybackOrder == PlaybackOrder.Chronological &&
|
||||
c.RerunPlaybackOrder == PlaybackOrder.Shuffle),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Create_Should_Resolve_Media_Item_Selection_For_Show_Type()
|
||||
{
|
||||
_mediator.Send(Arg.Any<CreateRerunCollection>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, RerunCollectionViewModel>(MakeRerunFromSmart(9, "Nightly")));
|
||||
|
||||
await _controller.Create(
|
||||
new CreateRerunCollectionRequest(
|
||||
"Nightly",
|
||||
CollectionType.TelevisionShow,
|
||||
77,
|
||||
PlaybackOrder.Chronological,
|
||||
PlaybackOrder.Chronological),
|
||||
CancellationToken.None);
|
||||
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<CreateRerunCollection>(c =>
|
||||
c.CollectionType == CollectionType.TelevisionShow &&
|
||||
c.Collection == null &&
|
||||
c.MediaItem != null && c.MediaItem.MediaItemId == 77),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Create_Should_Return_422_On_Validation_Error()
|
||||
{
|
||||
_mediator.Send(Arg.Any<CreateRerunCollection>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Left<BaseError, RerunCollectionViewModel>(BaseError.New("dupe")));
|
||||
|
||||
IActionResult result = await _controller.Create(
|
||||
new CreateRerunCollectionRequest(
|
||||
"Nightly",
|
||||
CollectionType.SmartCollection,
|
||||
1,
|
||||
PlaybackOrder.Chronological,
|
||||
PlaybackOrder.Chronological),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Create_Should_Return_422_For_Unsupported_Collection_Type()
|
||||
{
|
||||
IActionResult result = await _controller.Create(
|
||||
new CreateRerunCollectionRequest(
|
||||
"Nightly",
|
||||
CollectionType.Playlist,
|
||||
1,
|
||||
PlaybackOrder.Chronological,
|
||||
PlaybackOrder.Chronological),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
|
||||
await _mediator.DidNotReceive().Send(Arg.Any<CreateRerunCollection>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Update_Should_Return_200_With_Refreshed_Body_And_Resolve_Selection()
|
||||
{
|
||||
_mediator.Send(Arg.Any<UpdateRerunCollection>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, Unit>(Unit.Default));
|
||||
_mediator.Send(Arg.Any<GetRerunCollectionById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<RerunCollectionViewModel>.Some(MakeRerunFromSmart(4, "Nightly")));
|
||||
|
||||
IActionResult result = await _controller.Update(
|
||||
4,
|
||||
new UpdateRerunCollectionRequest(
|
||||
"Nightly",
|
||||
CollectionType.MultiCollection,
|
||||
55,
|
||||
PlaybackOrder.Chronological,
|
||||
PlaybackOrder.Shuffle),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBeOfType<RerunCollectionResponseModel>().Id.ShouldBe(4);
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<UpdateRerunCollection>(c =>
|
||||
c.RerunCollectionId == 4 &&
|
||||
c.Name == "Nightly" &&
|
||||
c.CollectionType == CollectionType.MultiCollection &&
|
||||
c.MultiCollection != null && c.MultiCollection.Id == 55 &&
|
||||
c.Collection == null &&
|
||||
c.SmartCollection == null &&
|
||||
c.MediaItem == null),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Update_Should_Return_422_On_Validation_Error()
|
||||
{
|
||||
_mediator.Send(Arg.Any<UpdateRerunCollection>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Left<BaseError, Unit>(BaseError.New("bad")));
|
||||
|
||||
IActionResult result = await _controller.Update(
|
||||
4,
|
||||
new UpdateRerunCollectionRequest(
|
||||
"Nightly",
|
||||
CollectionType.SmartCollection,
|
||||
1,
|
||||
PlaybackOrder.Chronological,
|
||||
PlaybackOrder.Chronological),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
|
||||
await _mediator.DidNotReceive().Send(Arg.Any<GetRerunCollectionById>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Update_Should_Return_422_For_Unsupported_Collection_Type()
|
||||
{
|
||||
IActionResult result = await _controller.Update(
|
||||
4,
|
||||
new UpdateRerunCollectionRequest(
|
||||
"Nightly",
|
||||
CollectionType.RerunFirstRun,
|
||||
1,
|
||||
PlaybackOrder.Chronological,
|
||||
PlaybackOrder.Chronological),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
|
||||
await _mediator.DidNotReceive().Send(Arg.Any<UpdateRerunCollection>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Delete_Should_Return_204_On_Success()
|
||||
{
|
||||
_mediator.Send(Arg.Any<DeleteRerunCollection>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, Unit>(Unit.Default));
|
||||
|
||||
IActionResult result = await _controller.Delete(4, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<NoContentResult>();
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<DeleteRerunCollection>(c => c.RerunCollectionId == 4),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Delete_Should_Return_404_On_NotFoundError()
|
||||
{
|
||||
_mediator.Send(Arg.Any<DeleteRerunCollection>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Left<BaseError, Unit>(new ErsatzTV.Core.Errors.NotFoundError("missing")));
|
||||
|
||||
IActionResult result = await _controller.Delete(4, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<NotFoundObjectResult>();
|
||||
}
|
||||
|
||||
private static RerunCollectionViewModel MakeRerunFromSmart(int id, string name) =>
|
||||
new(
|
||||
id,
|
||||
name,
|
||||
CollectionType.SmartCollection,
|
||||
null,
|
||||
null,
|
||||
new SmartCollectionViewModel(20, "Smart", "query"),
|
||||
null,
|
||||
PlaybackOrder.Chronological,
|
||||
PlaybackOrder.Shuffle);
|
||||
|
||||
private static void ShouldHaveActionRoute(string actionName, string httpMethod, string route)
|
||||
{
|
||||
MethodInfo action = typeof(RerunCollectionController).GetMethod(actionName)
|
||||
?? throw new AssertionException($"Missing action {actionName}");
|
||||
|
||||
HttpMethodAttribute attribute = action.GetCustomAttributes<HttpMethodAttribute>(inherit: true).Single();
|
||||
attribute.HttpMethods.ShouldContain(httpMethod);
|
||||
attribute.Template.ShouldBe(route);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ using System.ComponentModel.DataAnnotations;
|
||||
using ErsatzTV.Application.MediaCollections;
|
||||
using ErsatzTV.Controllers.Api.Requests;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Api.LibraryBrowse;
|
||||
using ErsatzTV.Extensions;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
@@ -32,6 +33,28 @@ public class CollectionController(IMediator mediator) : ControllerBase
|
||||
return result.ToGetResult();
|
||||
}
|
||||
|
||||
[HttpGet("/api/collections/{id:int}/items", Name = "GetCollectionItems")]
|
||||
[Tags("Collections")]
|
||||
[EndpointSummary("Get the items in a manual collection")]
|
||||
[EndpointDescription("Returns a manual collection's full contents (all media kinds), paged.")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(PagedLibraryBrowseItemsResponseModel), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> GetItems(
|
||||
int id,
|
||||
[FromQuery] int pageNum = 0,
|
||||
[FromQuery] int pageSize = 100,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
int clampedPageNum = Math.Max(0, pageNum);
|
||||
int clampedPageSize = Math.Clamp(pageSize, 1, 100);
|
||||
|
||||
Either<BaseError, PagedLibraryBrowseItemsResponseModel> result = await mediator.Send(
|
||||
new GetCollectionItems(id, clampedPageNum, clampedPageSize),
|
||||
cancellationToken);
|
||||
return result.ToUpdatedResult();
|
||||
}
|
||||
|
||||
[HttpPost("/api/collections")]
|
||||
[Tags("Collections")]
|
||||
[EndpointSummary("Create a collection")]
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using ErsatzTV.Application.MediaCollections;
|
||||
using ErsatzTV.Controllers.Api.Requests;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Api.MediaCollections;
|
||||
using ErsatzTV.Extensions;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ErsatzTV.Controllers.Api;
|
||||
|
||||
[ApiController]
|
||||
public class MultiCollectionController(IMediator mediator) : ControllerBase
|
||||
{
|
||||
private const int MaxPageSize = 100;
|
||||
|
||||
[HttpGet("/api/multi-collections", Name = "GetMultiCollections")]
|
||||
[Tags("Multi Collections")]
|
||||
[EndpointSummary("Get all multi collections (paged)")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(PagedMultiCollectionsResponseModel), StatusCodes.Status200OK)]
|
||||
public async Task<PagedMultiCollectionsResponseModel> GetAll(
|
||||
[FromQuery] string query = "",
|
||||
[FromQuery] int pageNum = 0,
|
||||
[FromQuery] int pageSize = 100,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
int clampedPageNum = Math.Max(0, pageNum);
|
||||
int clampedPageSize = Math.Clamp(pageSize, 1, MaxPageSize);
|
||||
|
||||
PagedMultiCollectionsViewModel result = await mediator.Send(
|
||||
new GetPagedMultiCollections(query ?? string.Empty, clampedPageNum, clampedPageSize),
|
||||
cancellationToken);
|
||||
|
||||
return new PagedMultiCollectionsResponseModel(
|
||||
result.TotalCount,
|
||||
result.Page.Map(ProjectToResponseModel).ToList());
|
||||
}
|
||||
|
||||
[HttpGet("/api/multi-collections/{id:int}", Name = "GetMultiCollectionById")]
|
||||
[Tags("Multi Collections")]
|
||||
[EndpointSummary("Get a multi collection by id")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(MultiCollectionResponseModel), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> GetById(int id, CancellationToken cancellationToken)
|
||||
{
|
||||
Option<MultiCollectionViewModel> result =
|
||||
await mediator.Send(new GetMultiCollectionById(id), cancellationToken);
|
||||
return result.Map(ProjectToResponseModel).ToGetResult();
|
||||
}
|
||||
|
||||
[HttpPost("/api/multi-collections")]
|
||||
[Tags("Multi Collections")]
|
||||
[EndpointSummary("Create a multi collection")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(MultiCollectionResponseModel), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> Create(
|
||||
[Required] [FromBody] CreateMultiCollectionRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Either<BaseError, MultiCollectionViewModel> result =
|
||||
await mediator.Send(request.ToCommand(), cancellationToken);
|
||||
return result.ToCreatedResult(
|
||||
vm => $"/api/multi-collections/{vm.Id}",
|
||||
ProjectToResponseModel);
|
||||
}
|
||||
|
||||
[HttpPut("/api/multi-collections/{id:int}")]
|
||||
[Tags("Multi Collections")]
|
||||
[EndpointSummary("Update a multi collection")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(MultiCollectionResponseModel), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> Update(
|
||||
int id,
|
||||
[Required] [FromBody] UpdateMultiCollectionRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Either<BaseError, Unit> result = await mediator.Send(request.ToCommand(id), cancellationToken);
|
||||
return await result.Match(
|
||||
Left: error => Task.FromResult(error.ToErrorResult()),
|
||||
Right: async _ =>
|
||||
{
|
||||
Option<MultiCollectionViewModel> multiCollection =
|
||||
await mediator.Send(new GetMultiCollectionById(id), cancellationToken);
|
||||
return multiCollection.Match(
|
||||
Some: vm => (IActionResult)new OkObjectResult(ProjectToResponseModel(vm)),
|
||||
None: () => ApiResults.NotFoundProblem());
|
||||
});
|
||||
}
|
||||
|
||||
[HttpDelete("/api/multi-collections/{id:int}")]
|
||||
[Tags("Multi Collections")]
|
||||
[EndpointSummary("Delete a multi collection")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> Delete(int id, CancellationToken cancellationToken)
|
||||
{
|
||||
Either<BaseError, Unit> result = await mediator.Send(new DeleteMultiCollection(id), cancellationToken);
|
||||
return result.ToDeletedResult();
|
||||
}
|
||||
|
||||
private static MultiCollectionResponseModel ProjectToResponseModel(MultiCollectionViewModel vm)
|
||||
{
|
||||
List<MultiCollectionItemResponseModel> items = vm.Items
|
||||
.Map(i => new MultiCollectionItemResponseModel(
|
||||
i.Collection.Id,
|
||||
null,
|
||||
i.Collection.Name,
|
||||
i.ScheduleAsGroup,
|
||||
i.PlaybackOrder))
|
||||
.Concat(vm.SmartItems.Map(i => new MultiCollectionItemResponseModel(
|
||||
null,
|
||||
i.SmartCollection.Id,
|
||||
i.SmartCollection.Name,
|
||||
i.ScheduleAsGroup,
|
||||
i.PlaybackOrder)))
|
||||
.ToList();
|
||||
|
||||
return new MultiCollectionResponseModel(vm.Id, vm.Name, items);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using ErsatzTV.Application.MediaCollections;
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Controllers.Api.Requests;
|
||||
|
||||
public record MultiCollectionItemRequest(
|
||||
int? CollectionId,
|
||||
int? SmartCollectionId,
|
||||
bool ScheduleAsGroup,
|
||||
PlaybackOrder PlaybackOrder);
|
||||
|
||||
public record CreateMultiCollectionRequest(string Name, List<MultiCollectionItemRequest> Items)
|
||||
{
|
||||
public CreateMultiCollection ToCommand() =>
|
||||
new(
|
||||
Name,
|
||||
(Items ?? new List<MultiCollectionItemRequest>())
|
||||
.Select(i => new CreateMultiCollectionItem(
|
||||
i.CollectionId,
|
||||
i.SmartCollectionId,
|
||||
i.ScheduleAsGroup,
|
||||
i.PlaybackOrder))
|
||||
.ToList());
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using ErsatzTV.Application.MediaCollections;
|
||||
using ErsatzTV.Application.MediaItems;
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Controllers.Api.Requests;
|
||||
|
||||
public record CreateRerunCollectionRequest(
|
||||
string Name,
|
||||
CollectionType CollectionType,
|
||||
int SelectedId,
|
||||
PlaybackOrder FirstRunPlaybackOrder,
|
||||
PlaybackOrder RerunPlaybackOrder)
|
||||
{
|
||||
public CreateRerunCollection ToCommand()
|
||||
{
|
||||
(MediaCollectionViewModel collection,
|
||||
MultiCollectionViewModel multiCollection,
|
||||
SmartCollectionViewModel smartCollection,
|
||||
NamedMediaItemViewModel mediaItem) =
|
||||
RerunCollectionRequestMapping.ResolveSelection(CollectionType, SelectedId);
|
||||
|
||||
return new CreateRerunCollection(
|
||||
Name,
|
||||
CollectionType,
|
||||
collection,
|
||||
multiCollection,
|
||||
smartCollection,
|
||||
mediaItem,
|
||||
FirstRunPlaybackOrder,
|
||||
RerunPlaybackOrder);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
using ErsatzTV.Application.MediaCollections;
|
||||
using ErsatzTV.Application.MediaItems;
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Controllers.Api.Requests;
|
||||
|
||||
// A rerun collection is a tagged union: CollectionType selects which of Collection /
|
||||
// MultiCollection / SmartCollection / MediaItem is populated. The Create/Update handlers
|
||||
// read ONLY the `.Id` (or `.MediaItemId`) off the chosen VM — verified in
|
||||
// CreateRerunCollectionHandler.Validate / UpdateRerunCollectionHandler.ApplyUpdateRequest,
|
||||
// both of which do `request.Collection?.Id`, `request.MultiCollection?.Id`,
|
||||
// `request.SmartCollection?.Id`, `request.MediaItem?.MediaItemId` and nothing else. So we
|
||||
// construct a minimal VM carrying just the selected id (defaults elsewhere) rather than
|
||||
// doing extra lookup queries. The remaining VMs are left null (the handler expects that).
|
||||
internal static class RerunCollectionRequestMapping
|
||||
{
|
||||
/// <summary>
|
||||
/// The only <see cref="CollectionType" /> values valid as a rerun collection's selected
|
||||
/// source. Excludes Playlist / RerunFirstRun / RerunRerun / SearchQuery / Fake* — those
|
||||
/// are not media-item-backed collection types and must not be resolved via
|
||||
/// <see cref="ResolveSelection" />. Callers (the controller) must check this first.
|
||||
/// </summary>
|
||||
public static bool IsSupportedSelectionType(CollectionType collectionType) =>
|
||||
collectionType is CollectionType.Collection
|
||||
or CollectionType.MultiCollection
|
||||
or CollectionType.SmartCollection
|
||||
or CollectionType.TelevisionShow
|
||||
or CollectionType.TelevisionSeason
|
||||
or CollectionType.Artist
|
||||
or CollectionType.Movie
|
||||
or CollectionType.Episode
|
||||
or CollectionType.MusicVideo
|
||||
or CollectionType.OtherVideo
|
||||
or CollectionType.Song
|
||||
or CollectionType.Image
|
||||
or CollectionType.RemoteStream;
|
||||
|
||||
public static (MediaCollectionViewModel Collection,
|
||||
MultiCollectionViewModel MultiCollection,
|
||||
SmartCollectionViewModel SmartCollection,
|
||||
NamedMediaItemViewModel MediaItem) ResolveSelection(CollectionType collectionType, int selectedId) =>
|
||||
collectionType switch
|
||||
{
|
||||
CollectionType.Collection => (
|
||||
new MediaCollectionViewModel(
|
||||
CollectionType.Collection,
|
||||
selectedId,
|
||||
string.Empty,
|
||||
false,
|
||||
MediaItemState.Normal),
|
||||
null,
|
||||
null,
|
||||
null),
|
||||
CollectionType.MultiCollection => (
|
||||
null,
|
||||
new MultiCollectionViewModel(selectedId, string.Empty, [], []),
|
||||
null,
|
||||
null),
|
||||
CollectionType.SmartCollection => (
|
||||
null,
|
||||
null,
|
||||
new SmartCollectionViewModel(selectedId, string.Empty, string.Empty),
|
||||
null),
|
||||
|
||||
// Any media-item-backed collection type (TelevisionShow, TelevisionSeason,
|
||||
// Artist, Movie, Episode, MusicVideo, OtherVideo, Song, Image, ...) resolves to
|
||||
// the MediaItem VM keyed by media item id. The caller (RerunCollectionController)
|
||||
// has already validated CollectionType via IsSupportedSelectionType, so this
|
||||
// catch-all only ever receives a real media-item type.
|
||||
_ => (
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
new NamedMediaItemViewModel(selectedId, string.Empty))
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using ErsatzTV.Application.MediaCollections;
|
||||
|
||||
namespace ErsatzTV.Controllers.Api.Requests;
|
||||
|
||||
public record UpdateMultiCollectionRequest(string Name, List<MultiCollectionItemRequest> Items)
|
||||
{
|
||||
public UpdateMultiCollection ToCommand(int id) =>
|
||||
new(
|
||||
id,
|
||||
Name,
|
||||
(Items ?? new List<MultiCollectionItemRequest>())
|
||||
.Select(i => new UpdateMultiCollectionItem(
|
||||
i.CollectionId,
|
||||
i.SmartCollectionId,
|
||||
i.ScheduleAsGroup,
|
||||
i.PlaybackOrder))
|
||||
.ToList());
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using ErsatzTV.Application.MediaCollections;
|
||||
using ErsatzTV.Application.MediaItems;
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Controllers.Api.Requests;
|
||||
|
||||
public record UpdateRerunCollectionRequest(
|
||||
string Name,
|
||||
CollectionType CollectionType,
|
||||
int SelectedId,
|
||||
PlaybackOrder FirstRunPlaybackOrder,
|
||||
PlaybackOrder RerunPlaybackOrder)
|
||||
{
|
||||
public UpdateRerunCollection ToCommand(int id)
|
||||
{
|
||||
(MediaCollectionViewModel collection,
|
||||
MultiCollectionViewModel multiCollection,
|
||||
SmartCollectionViewModel smartCollection,
|
||||
NamedMediaItemViewModel mediaItem) =
|
||||
RerunCollectionRequestMapping.ResolveSelection(CollectionType, SelectedId);
|
||||
|
||||
return new UpdateRerunCollection(
|
||||
id,
|
||||
Name,
|
||||
CollectionType,
|
||||
collection,
|
||||
multiCollection,
|
||||
smartCollection,
|
||||
mediaItem,
|
||||
FirstRunPlaybackOrder,
|
||||
RerunPlaybackOrder);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using ErsatzTV.Application.MediaCollections;
|
||||
using ErsatzTV.Controllers.Api.Requests;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Api.MediaCollections;
|
||||
using ErsatzTV.Extensions;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ErsatzTV.Controllers.Api;
|
||||
|
||||
[ApiController]
|
||||
public class RerunCollectionController(IMediator mediator) : ControllerBase
|
||||
{
|
||||
private const int MaxPageSize = 100;
|
||||
|
||||
[HttpGet("/api/rerun-collections", Name = "GetRerunCollections")]
|
||||
[Tags("Rerun Collections")]
|
||||
[EndpointSummary("Get all rerun collections (paged)")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(PagedRerunCollectionsResponseModel), StatusCodes.Status200OK)]
|
||||
public async Task<PagedRerunCollectionsResponseModel> GetAll(
|
||||
[FromQuery] string query = "",
|
||||
[FromQuery] int pageNum = 0,
|
||||
[FromQuery] int pageSize = 100,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
int clampedPageNum = Math.Max(0, pageNum);
|
||||
int clampedPageSize = Math.Clamp(pageSize, 1, MaxPageSize);
|
||||
|
||||
PagedRerunCollectionsViewModel result = await mediator.Send(
|
||||
new GetPagedRerunCollections(query ?? string.Empty, clampedPageNum, clampedPageSize),
|
||||
cancellationToken);
|
||||
|
||||
return new PagedRerunCollectionsResponseModel(
|
||||
result.TotalCount,
|
||||
result.Page.Map(ProjectToResponseModel).ToList());
|
||||
}
|
||||
|
||||
[HttpGet("/api/rerun-collections/{id:int}", Name = "GetRerunCollectionById")]
|
||||
[Tags("Rerun Collections")]
|
||||
[EndpointSummary("Get a rerun collection by id")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(RerunCollectionResponseModel), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> GetById(int id, CancellationToken cancellationToken)
|
||||
{
|
||||
Option<RerunCollectionViewModel> result =
|
||||
await mediator.Send(new GetRerunCollectionById(id), cancellationToken);
|
||||
return result.Map(ProjectToResponseModel).ToGetResult();
|
||||
}
|
||||
|
||||
[HttpPost("/api/rerun-collections")]
|
||||
[Tags("Rerun Collections")]
|
||||
[EndpointSummary("Create a rerun collection")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(RerunCollectionResponseModel), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> Create(
|
||||
[Required] [FromBody] CreateRerunCollectionRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!RerunCollectionRequestMapping.IsSupportedSelectionType(request.CollectionType))
|
||||
{
|
||||
return BaseError.New(
|
||||
$"Unsupported collection type '{request.CollectionType}' for a rerun collection")
|
||||
.ToErrorResult();
|
||||
}
|
||||
|
||||
Either<BaseError, RerunCollectionViewModel> result =
|
||||
await mediator.Send(request.ToCommand(), cancellationToken);
|
||||
return result.ToCreatedResult(
|
||||
vm => $"/api/rerun-collections/{vm.Id}",
|
||||
ProjectToResponseModel);
|
||||
}
|
||||
|
||||
[HttpPut("/api/rerun-collections/{id:int}")]
|
||||
[Tags("Rerun Collections")]
|
||||
[EndpointSummary("Update a rerun collection")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(RerunCollectionResponseModel), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> Update(
|
||||
int id,
|
||||
[Required] [FromBody] UpdateRerunCollectionRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!RerunCollectionRequestMapping.IsSupportedSelectionType(request.CollectionType))
|
||||
{
|
||||
return BaseError.New(
|
||||
$"Unsupported collection type '{request.CollectionType}' for a rerun collection")
|
||||
.ToErrorResult();
|
||||
}
|
||||
|
||||
Either<BaseError, Unit> result = await mediator.Send(request.ToCommand(id), cancellationToken);
|
||||
return await result.Match(
|
||||
Left: error => Task.FromResult(error.ToErrorResult()),
|
||||
Right: async _ =>
|
||||
{
|
||||
Option<RerunCollectionViewModel> rerunCollection =
|
||||
await mediator.Send(new GetRerunCollectionById(id), cancellationToken);
|
||||
return rerunCollection.Match(
|
||||
Some: vm => (IActionResult)new OkObjectResult(ProjectToResponseModel(vm)),
|
||||
None: () => ApiResults.NotFoundProblem());
|
||||
});
|
||||
}
|
||||
|
||||
[HttpDelete("/api/rerun-collections/{id:int}")]
|
||||
[Tags("Rerun Collections")]
|
||||
[EndpointSummary("Delete a rerun collection")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> Delete(int id, CancellationToken cancellationToken)
|
||||
{
|
||||
Either<BaseError, Unit> result = await mediator.Send(new DeleteRerunCollection(id), cancellationToken);
|
||||
return result.ToDeletedResult();
|
||||
}
|
||||
|
||||
// A rerun collection is a tagged union keyed by CollectionType; exactly one of
|
||||
// Collection / MultiCollection / SmartCollection / MediaItem is populated on the VM.
|
||||
// Flatten it to a single selected id + name for the SPA.
|
||||
private static RerunCollectionResponseModel ProjectToResponseModel(RerunCollectionViewModel vm)
|
||||
{
|
||||
int? selectedId = vm.Collection?.Id
|
||||
?? vm.MultiCollection?.Id
|
||||
?? vm.SmartCollection?.Id
|
||||
?? vm.MediaItem?.MediaItemId;
|
||||
|
||||
string selectedName = vm.Collection?.Name
|
||||
?? vm.MultiCollection?.Name
|
||||
?? vm.SmartCollection?.Name
|
||||
?? vm.MediaItem?.Name;
|
||||
|
||||
return new RerunCollectionResponseModel(
|
||||
vm.Id,
|
||||
vm.Name,
|
||||
vm.CollectionType,
|
||||
selectedId,
|
||||
selectedName,
|
||||
vm.FirstRunPlaybackOrder,
|
||||
vm.RerunPlaybackOrder);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+10
-10
@@ -130,16 +130,16 @@ new DTO with a `DayOfWeek` (or `DayOfWeek[]`) member — don't trust the generat
|
||||
`mediator.Received(1).Send(Arg.Is<Command>(...))` call. `PlayoutControllerTests.cs` is the same
|
||||
pattern for a larger, mixed-verb controller — use it as the template for a new controller with
|
||||
many actions.
|
||||
- **`ApiControllerSecurityTests.cs`** (`ErsatzTV.Tests/Controllers/`): has a hardcoded
|
||||
`Type[] apiControllers` registry. **Add every new controller to that array** — the test walks
|
||||
every mutating (POST/PUT/PATCH/DELETE) action on each listed controller and asserts it's covered
|
||||
by the global `ApiKeyAuthorizationFilter` (or has an explicit `[SkipApiKeyAuthorizationAttribute]`
|
||||
exemption; only `ScannerController` is exempt today). **Note**: as of this writing the array does
|
||||
not include every controller under `ErsatzTV/Controllers/Api/` (e.g. `ArtworkUploadController`,
|
||||
`ChannelTemplateController`, `GraphicsElementController`, `LibraryBrowseController`,
|
||||
`SearchController`, `VersionController`, `HealthController`, `PlaylistController`,
|
||||
`MediaSourcesController` are missing) — treat that as a pre-existing gap to close opportunistically,
|
||||
not a precedent to extend.
|
||||
- **`ApiControllerSecurityTests.cs`** (`ErsatzTV.Tests/Controllers/`): reflects over the
|
||||
`ErsatzTV.Controllers.Api` namespace to find every concrete, `[ApiController]`-marked
|
||||
controller class — no manual registry to maintain; a new controller is covered
|
||||
automatically. (It intentionally does **not** filter on `ControllerBase`: several API
|
||||
controllers — including the lone exempt `ScannerController` — do not derive from it, and a
|
||||
`ControllerBase` filter would silently drop them.) The test walks every mutating (POST/PUT/PATCH/DELETE) action on each scanned
|
||||
controller and asserts it's covered by the global `ApiKeyAuthorizationFilter` (or has an
|
||||
explicit `[SkipApiKeyAuthorizationAttribute]` exemption; only `ScannerController` is exempt
|
||||
today). A minimum-count guard asserts the scan found a sane number of controllers, so a
|
||||
namespace rename can't silently make the scan match nothing and give this test a false pass.
|
||||
- **Handler tests** (business logic behind the controller) use the shared in-memory SQLite fixture:
|
||||
`ErsatzTV.Tests/Support/InMemoryTvContext.cs`. Pattern: `SqliteConnection("Data Source=:memory:;Foreign
|
||||
Keys=False")` kept open for the fixture's lifetime, `EnsureCreatedAsync()` (not full migration
|
||||
|
||||
@@ -100,13 +100,19 @@ been added to the redirect map yet.
|
||||
|
||||
## Section 3 — BLAZOR-ONLY (blocking issues)
|
||||
|
||||
### Multi/rerun collections & playlist variants — API gaps #151/#152/#153/#155
|
||||
### Multi/rerun collections & playlist variants — API gaps #151/#152/#153
|
||||
|
||||
| Blazor route | File | Blocking issue |
|
||||
|---|---|---|
|
||||
| `/media/multi-collections`(`/add`, `/{Id}/edit`) | `MultiCollections.razor`, `MultiCollectionEditor.razor` | #151 (multi-collection management API) |
|
||||
| `/media/rerun-collections`(`/add`, `/{Id}/edit`) | `RerunCollections.razor`, `RerunCollectionEditor.razor` | #152 (rerun-collection management API) |
|
||||
| `/media/playlists`(`/{Id}`) editing depth beyond what `/app/collections` covers | `Playlists.razor`, `PlaylistEditor.razor` | #153/#155 (playlist variant management API + collection-items editing depth) |
|
||||
| `/media/multi-collections`(`/add`, `/{Id}/edit`) | `MultiCollections.razor`, `MultiCollectionEditor.razor` | #151 — **REST API DONE** (`MultiCollectionController`, `/api/multi-collections` CRUD + `web/src/api/multiCollections.ts`); SPA editor screen still pending (follow-up) |
|
||||
| `/media/rerun-collections`(`/add`, `/{Id}/edit`) | `RerunCollections.razor`, `RerunCollectionEditor.razor` | #152 — **REST API DONE** (`RerunCollectionController`, `/api/rerun-collections` CRUD + `web/src/api/rerunCollections.ts`); SPA editor screen still pending (follow-up) |
|
||||
| `/media/playlists`(`/{Id}`) editing depth beyond what `/app/collections` covers | `Playlists.razor`, `PlaylistEditor.razor` | #153 (playlist variant management API) |
|
||||
|
||||
**#155 RESOLVED** (collection-items enumeration): `GET /api/collections/{id}/items` (paged) now returns a
|
||||
manual collection's full contents across all media kinds (reusing `LibraryBrowseItemResponseModel`), so the
|
||||
SPA `/app/collections` items view lists real members instead of the old lossy Lucene `collection:"name"`
|
||||
search preview. The `POST /api/collections/{id}/items` bogus-id case already returns 422 (guarded by
|
||||
`AddItemsToCollectionHandler.ValidateMediaItems`), not 500.
|
||||
|
||||
### Playback troubleshooting — #145
|
||||
|
||||
|
||||
+22
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
*Generated by `scripts/generate-endpoint-index.py` from `ErsatzTV/wwwroot/openapi/v1.json`. Do not edit by hand -- regenerated by `scripts/update-openapi.sh`.*
|
||||
|
||||
114 endpoints, 175 operations.
|
||||
118 endpoints, 186 operations.
|
||||
|
||||
## Artists
|
||||
|
||||
@@ -75,6 +75,7 @@
|
||||
| DELETE | `/api/collections/{id}` | | Delete a collection |
|
||||
| GET | `/api/collections/{id}` | GetCollectionById | Get a collection by id |
|
||||
| PUT | `/api/collections/{id}` | | Update a collection |
|
||||
| GET | `/api/collections/{id}/items` | GetCollectionItems | Get the items in a manual collection |
|
||||
| POST | `/api/collections/{id}/items` | | Add items to a collection |
|
||||
| DELETE | `/api/collections/{id}/items/{mediaItemId}` | | Remove an item from a collection |
|
||||
|
||||
@@ -187,6 +188,16 @@
|
||||
|---|---|---|---|
|
||||
| GET | `/api/movies/{id}` | GetMovieById | Get a movie by id |
|
||||
|
||||
## Multi Collections
|
||||
|
||||
| Method | Path | Operation | Summary |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/multi-collections` | GetMultiCollections | Get all multi collections (paged) |
|
||||
| POST | `/api/multi-collections` | | Create a multi collection |
|
||||
| DELETE | `/api/multi-collections/{id}` | | Delete a multi collection |
|
||||
| GET | `/api/multi-collections/{id}` | GetMultiCollectionById | Get a multi collection by id |
|
||||
| PUT | `/api/multi-collections/{id}` | | Update a multi collection |
|
||||
|
||||
## Playlists
|
||||
|
||||
| Method | Path | Operation | Summary |
|
||||
@@ -215,6 +226,16 @@
|
||||
| GET | `/api/playouts/{id}/templates` | GetPlayoutTemplates | Get a block playout's templates |
|
||||
| PUT | `/api/playouts/{id}/templates` | | Replace a block playout's templates |
|
||||
|
||||
## Rerun Collections
|
||||
|
||||
| Method | Path | Operation | Summary |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/rerun-collections` | GetRerunCollections | Get all rerun collections (paged) |
|
||||
| POST | `/api/rerun-collections` | | Create a rerun collection |
|
||||
| DELETE | `/api/rerun-collections/{id}` | | Delete a rerun collection |
|
||||
| GET | `/api/rerun-collections/{id}` | GetRerunCollectionById | Get a rerun collection by id |
|
||||
| PUT | `/api/rerun-collections/{id}` | | Update a rerun collection |
|
||||
|
||||
## Resolution
|
||||
|
||||
| Method | Path | Operation | Summary |
|
||||
|
||||
+4
-4
@@ -290,7 +290,7 @@ const routes: ScreenRoute[] = [
|
||||
kicker: 'Programming',
|
||||
description: 'Group blocks and edit their items, durations, and playout preview.',
|
||||
icon: <Boxes aria-hidden="true" size={16} />,
|
||||
primaryAction: 'New Group',
|
||||
primaryAction: '',
|
||||
placeholder: 'Blocks workspace',
|
||||
allowSubPaths: true
|
||||
},
|
||||
@@ -303,7 +303,7 @@ const routes: ScreenRoute[] = [
|
||||
kicker: 'Programming',
|
||||
description: 'Group templates and assign blocks to times of day.',
|
||||
icon: <CalendarDays aria-hidden="true" size={16} />,
|
||||
primaryAction: 'New Group',
|
||||
primaryAction: '',
|
||||
placeholder: 'Templates workspace',
|
||||
allowSubPaths: true
|
||||
},
|
||||
@@ -316,7 +316,7 @@ const routes: ScreenRoute[] = [
|
||||
kicker: 'Programming',
|
||||
description: 'Group decos and configure watermark, graphics, break content, and filler overrides.',
|
||||
icon: <Palette aria-hidden="true" size={16} />,
|
||||
primaryAction: 'New Group',
|
||||
primaryAction: '',
|
||||
placeholder: 'Decos workspace',
|
||||
allowSubPaths: true
|
||||
},
|
||||
@@ -329,7 +329,7 @@ const routes: ScreenRoute[] = [
|
||||
kicker: 'Programming',
|
||||
description: 'Group deco templates and assign decos to times of day.',
|
||||
icon: <CalendarRange aria-hidden="true" size={16} />,
|
||||
primaryAction: 'New Group',
|
||||
primaryAction: '',
|
||||
placeholder: 'Deco templates workspace',
|
||||
allowSubPaths: true
|
||||
},
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
deleteCollection,
|
||||
deleteSmartCollection,
|
||||
emptyAddItemsRequest,
|
||||
getCollectionItemsPreview,
|
||||
getCollectionItems,
|
||||
getCollections,
|
||||
getSmartCollections,
|
||||
removeItemFromCollection,
|
||||
@@ -158,23 +158,31 @@ describe('collections api client', () => {
|
||||
expect(JSON.parse(String(createCall?.[1]?.body))).toEqual({ name: 'Sci-Fi', query: 'genre:scifi' });
|
||||
});
|
||||
|
||||
it('getCollectionItemsPreview issues a quoted collection: Lucene query', async () => {
|
||||
it('getCollectionItems requests the paged collection-items endpoint', async () => {
|
||||
const fetchMock = vi
|
||||
.spyOn(window, 'fetch')
|
||||
.mockResolvedValue(jsonResponse({ totalCount: 1, page: [browseItem(5, 'Movie')] }));
|
||||
|
||||
const result = await getCollectionItems(7, 2, 50);
|
||||
|
||||
const url = new URL(String(fetchMock.mock.calls[0][0]), 'http://localhost');
|
||||
expect(url.pathname).toBe('/api/collections/7/items');
|
||||
expect(url.searchParams.get('pageNum')).toBe('2');
|
||||
expect(url.searchParams.get('pageSize')).toBe('50');
|
||||
expect(result.totalCount).toBe(1);
|
||||
});
|
||||
|
||||
it('getCollectionItems defaults to the first page of 100', async () => {
|
||||
const fetchMock = vi
|
||||
.spyOn(window, 'fetch')
|
||||
.mockResolvedValue(jsonResponse({ totalCount: 0, page: [] }));
|
||||
|
||||
await getCollectionItemsPreview('The Office');
|
||||
await getCollectionItems(9);
|
||||
|
||||
const url = new URL(String(fetchMock.mock.calls[0][0]), 'http://localhost');
|
||||
expect(url.pathname).toBe('/api/library/browse');
|
||||
expect(url.searchParams.get('query')).toBe('collection:"The Office"');
|
||||
});
|
||||
|
||||
it('getCollectionItemsPreview returns [] for a blank name without calling the API', async () => {
|
||||
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({ totalCount: 0, page: [] }));
|
||||
|
||||
await expect(getCollectionItemsPreview(' ')).resolves.toEqual([]);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(url.pathname).toBe('/api/collections/9/items');
|
||||
expect(url.searchParams.get('pageNum')).toBe('0');
|
||||
expect(url.searchParams.get('pageSize')).toBe('100');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+15
-17
@@ -1,6 +1,6 @@
|
||||
import { ApiError, request } from './client';
|
||||
import type { components } from './generated/v1';
|
||||
import { getLibraryBrowseItems, type LibraryBrowseItem } from './libraryBrowse';
|
||||
import type { LibraryBrowseItem, PagedLibraryBrowseItems } from './libraryBrowse';
|
||||
|
||||
export type MediaCollection = components['schemas']['MediaCollectionViewModel'];
|
||||
export type SmartCollection = components['schemas']['SmartCollectionViewModel'];
|
||||
@@ -40,6 +40,20 @@ export function removeItemFromCollection(id: number, mediaItemId: number): Promi
|
||||
return request<void>(`/api/collections/${id}/items/${mediaItemId}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
// Lists a manual collection's full contents (all media kinds), paged. Backed by
|
||||
// GET /api/collections/{id}/items (#155), which reuses the library-browse item shape.
|
||||
export function getCollectionItems(
|
||||
id: number,
|
||||
pageNum = 0,
|
||||
pageSize = 100
|
||||
): Promise<PagedLibraryBrowseItems> {
|
||||
const params = new URLSearchParams({
|
||||
pageNum: String(pageNum),
|
||||
pageSize: String(pageSize)
|
||||
});
|
||||
return request<PagedLibraryBrowseItems>(`/api/collections/${id}/items?${params.toString()}`);
|
||||
}
|
||||
|
||||
/* ---------- smart collections ---------- */
|
||||
|
||||
export function getSmartCollections(): Promise<SmartCollection[]> {
|
||||
@@ -116,22 +130,6 @@ export function toAddItemsRequest(items: LibraryBrowseItem[]): AddItemsToCollect
|
||||
return requestBody;
|
||||
}
|
||||
|
||||
// Best-effort partial listing of a manual collection's members. No API returns a manual
|
||||
// collection's items by id; the only path is the Lucene `collection:"name"` search field
|
||||
// via library-browse, which covers Movie / Show / Season / Artist only. Callers must treat
|
||||
// this as an incomplete preview, never as the authoritative contents.
|
||||
export async function getCollectionItemsPreview(name: string): Promise<LibraryBrowseItem[]> {
|
||||
const trimmed = name.trim();
|
||||
|
||||
if (!trimmed) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const escaped = trimmed.replace(/"/g, '\\"');
|
||||
const result = await getLibraryBrowseItems({ pageSize: 100, query: `collection:"${escaped}"` });
|
||||
return result.page ?? [];
|
||||
}
|
||||
|
||||
export function messageFromCollectionError(error: unknown, fallback = 'Unable to load collections'): string {
|
||||
if (error instanceof ApiError) {
|
||||
return error.detail ?? error.message;
|
||||
|
||||
Vendored
+57
@@ -432,12 +432,23 @@ export interface components {
|
||||
"playlistId": null | number;
|
||||
"expression": null | string;
|
||||
"useChaptersAsMediaItems": boolean;
|
||||
};
|
||||
"CreateMultiCollectionRequest": {
|
||||
"name": null | string;
|
||||
"items": null | Array<components["schemas"]["MultiCollectionItemRequest"]>;
|
||||
};
|
||||
"CreatePlayoutRequest": {
|
||||
"channelId": number;
|
||||
"scheduleKind": components["schemas"]["PlayoutScheduleKind"];
|
||||
"programScheduleId": null | number;
|
||||
"scheduleFile": null | string;
|
||||
};
|
||||
"CreateRerunCollectionRequest": {
|
||||
"name": null | string;
|
||||
"collectionType": components["schemas"]["CollectionType"];
|
||||
"selectedId": number;
|
||||
"firstRunPlaybackOrder": components["schemas"]["PlaybackOrder"];
|
||||
"rerunPlaybackOrder": components["schemas"]["PlaybackOrder"];
|
||||
};
|
||||
"CreateResolutionRequest": {
|
||||
"width": number;
|
||||
@@ -846,12 +857,30 @@ export interface components {
|
||||
"state": components["schemas"]["MediaItemState"];
|
||||
"poster": string;
|
||||
"fanArt": string;
|
||||
};
|
||||
"MultiCollectionItemRequest": {
|
||||
"collectionId": null | number;
|
||||
"smartCollectionId": null | number;
|
||||
"scheduleAsGroup": boolean;
|
||||
"playbackOrder": components["schemas"]["PlaybackOrder"];
|
||||
};
|
||||
"MultiCollectionItemResponseModel": {
|
||||
"collectionId": null | number;
|
||||
"smartCollectionId": null | number;
|
||||
"name": string;
|
||||
"scheduleAsGroup": boolean;
|
||||
"playbackOrder": components["schemas"]["PlaybackOrder"];
|
||||
};
|
||||
"MultiCollectionItemViewModel": {
|
||||
"multiCollectionId": number;
|
||||
"collection": components["schemas"]["MediaCollectionViewModel"];
|
||||
"scheduleAsGroup": boolean;
|
||||
"playbackOrder": components["schemas"]["PlaybackOrder"];
|
||||
};
|
||||
"MultiCollectionResponseModel": {
|
||||
"id": number;
|
||||
"name": string;
|
||||
"items": Array<components["schemas"]["MultiCollectionItemResponseModel"]>;
|
||||
};
|
||||
"MultiCollectionSmartItemViewModel": {
|
||||
"multiCollectionId": number;
|
||||
@@ -879,6 +908,10 @@ export interface components {
|
||||
"PagedLogEntriesResponseModel": {
|
||||
"totalCount": number;
|
||||
"page": Array<components["schemas"]["LogEntryResponseModel"]>;
|
||||
};
|
||||
"PagedMultiCollectionsResponseModel": {
|
||||
"totalCount": number;
|
||||
"page": Array<components["schemas"]["MultiCollectionResponseModel"]>;
|
||||
};
|
||||
"PagedPlayoutHistoryResponseModel": {
|
||||
"totalCount": number;
|
||||
@@ -891,6 +924,10 @@ export interface components {
|
||||
"PagedPlayoutsResponseModel": {
|
||||
"totalCount": number;
|
||||
"page": null | Array<components["schemas"]["PlayoutListItemResponseModel"]>;
|
||||
};
|
||||
"PagedRerunCollectionsResponseModel": {
|
||||
"totalCount": number;
|
||||
"page": Array<components["schemas"]["RerunCollectionResponseModel"]>;
|
||||
};
|
||||
"PagedTraktListsResponseModel": {
|
||||
"totalCount": number;
|
||||
@@ -1139,6 +1176,15 @@ export interface components {
|
||||
"ReplaceTemplateRequest": {
|
||||
"name": null | string;
|
||||
"items": null | Array<components["schemas"]["TemplateItemRequest"]>;
|
||||
};
|
||||
"RerunCollectionResponseModel": {
|
||||
"id": number;
|
||||
"name": string;
|
||||
"collectionType": components["schemas"]["CollectionType"];
|
||||
"selectedId": null | number;
|
||||
"selectedName": null | string;
|
||||
"firstRunPlaybackOrder": components["schemas"]["PlaybackOrder"];
|
||||
"rerunPlaybackOrder": components["schemas"]["PlaybackOrder"];
|
||||
};
|
||||
"RerunCollectionViewModel": {
|
||||
"id": number;
|
||||
@@ -1468,6 +1514,10 @@ export interface components {
|
||||
"searchingMinimumLogLevel": components["schemas"]["LogEventLevel"];
|
||||
"streamingMinimumLogLevel": components["schemas"]["LogEventLevel"];
|
||||
"httpMinimumLogLevel": components["schemas"]["LogEventLevel"];
|
||||
};
|
||||
"UpdateMultiCollectionRequest": {
|
||||
"name": null | string;
|
||||
"items": null | Array<components["schemas"]["MultiCollectionItemRequest"]>;
|
||||
};
|
||||
"UpdatePlayoutDetailsRequest": {
|
||||
"dailyRebuildTime": null | string;
|
||||
@@ -1477,6 +1527,13 @@ export interface components {
|
||||
"daysToBuild": number;
|
||||
"skipMissingItems": boolean;
|
||||
"scriptedScheduleTimeoutSeconds": number;
|
||||
};
|
||||
"UpdateRerunCollectionRequest": {
|
||||
"name": null | string;
|
||||
"collectionType": components["schemas"]["CollectionType"];
|
||||
"selectedId": number;
|
||||
"firstRunPlaybackOrder": components["schemas"]["PlaybackOrder"];
|
||||
"rerunPlaybackOrder": components["schemas"]["PlaybackOrder"];
|
||||
};
|
||||
"UpdateScannerSettingsRequest": {
|
||||
"libraryRefreshInterval": number;
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { request } from './client';
|
||||
import type { components } from './generated/v1';
|
||||
|
||||
export type MultiCollection = components['schemas']['MultiCollectionResponseModel'];
|
||||
export type MultiCollectionItem = components['schemas']['MultiCollectionItemResponseModel'];
|
||||
export type PagedMultiCollections = components['schemas']['PagedMultiCollectionsResponseModel'];
|
||||
export type CreateMultiCollectionRequest = components['schemas']['CreateMultiCollectionRequest'];
|
||||
export type UpdateMultiCollectionRequest = components['schemas']['UpdateMultiCollectionRequest'];
|
||||
|
||||
export interface GetMultiCollectionsParams {
|
||||
query?: string;
|
||||
pageNum?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export function getMultiCollections(params: GetMultiCollectionsParams = {}): Promise<PagedMultiCollections> {
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
if (params.query != null) {
|
||||
searchParams.set('query', params.query);
|
||||
}
|
||||
|
||||
if (params.pageNum != null) {
|
||||
searchParams.set('pageNum', String(params.pageNum));
|
||||
}
|
||||
|
||||
if (params.pageSize != null) {
|
||||
searchParams.set('pageSize', String(params.pageSize));
|
||||
}
|
||||
|
||||
const queryString = searchParams.toString();
|
||||
|
||||
return request<PagedMultiCollections>(`/api/multi-collections${queryString ? `?${queryString}` : ''}`);
|
||||
}
|
||||
|
||||
export function getMultiCollection(id: number): Promise<MultiCollection> {
|
||||
return request<MultiCollection>(`/api/multi-collections/${id}`);
|
||||
}
|
||||
|
||||
export function createMultiCollection(body: CreateMultiCollectionRequest): Promise<MultiCollection> {
|
||||
return request<MultiCollection>('/api/multi-collections', { body, method: 'POST' });
|
||||
}
|
||||
|
||||
export function updateMultiCollection(id: number, body: UpdateMultiCollectionRequest): Promise<MultiCollection> {
|
||||
return request<MultiCollection>(`/api/multi-collections/${id}`, { body, method: 'PUT' });
|
||||
}
|
||||
|
||||
export function deleteMultiCollection(id: number): Promise<void> {
|
||||
return request<void>(`/api/multi-collections/${id}`, { method: 'DELETE' });
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { request } from './client';
|
||||
import type { components } from './generated/v1';
|
||||
|
||||
export type RerunCollection = components['schemas']['RerunCollectionResponseModel'];
|
||||
export type PagedRerunCollections = components['schemas']['PagedRerunCollectionsResponseModel'];
|
||||
export type CreateRerunCollectionRequest = components['schemas']['CreateRerunCollectionRequest'];
|
||||
export type UpdateRerunCollectionRequest = components['schemas']['UpdateRerunCollectionRequest'];
|
||||
|
||||
export interface GetRerunCollectionsParams {
|
||||
query?: string;
|
||||
pageNum?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export function getRerunCollections(params: GetRerunCollectionsParams = {}): Promise<PagedRerunCollections> {
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
if (params.query != null) {
|
||||
searchParams.set('query', params.query);
|
||||
}
|
||||
|
||||
if (params.pageNum != null) {
|
||||
searchParams.set('pageNum', String(params.pageNum));
|
||||
}
|
||||
|
||||
if (params.pageSize != null) {
|
||||
searchParams.set('pageSize', String(params.pageSize));
|
||||
}
|
||||
|
||||
const queryString = searchParams.toString();
|
||||
|
||||
return request<PagedRerunCollections>(`/api/rerun-collections${queryString ? `?${queryString}` : ''}`);
|
||||
}
|
||||
|
||||
export function getRerunCollection(id: number): Promise<RerunCollection> {
|
||||
return request<RerunCollection>(`/api/rerun-collections/${id}`);
|
||||
}
|
||||
|
||||
export function createRerunCollection(body: CreateRerunCollectionRequest): Promise<RerunCollection> {
|
||||
return request<RerunCollection>('/api/rerun-collections', { body, method: 'POST' });
|
||||
}
|
||||
|
||||
export function updateRerunCollection(id: number, body: UpdateRerunCollectionRequest): Promise<RerunCollection> {
|
||||
return request<RerunCollection>(`/api/rerun-collections/${id}`, { body, method: 'PUT' });
|
||||
}
|
||||
|
||||
export function deleteRerunCollection(id: number): Promise<void> {
|
||||
return request<void>(`/api/rerun-collections/${id}`, { method: 'DELETE' });
|
||||
}
|
||||
@@ -51,6 +51,10 @@ function mockApi(options: MockOptions = {}) {
|
||||
return Promise.resolve(jsonResponse({ page: [], totalCount: 0 }));
|
||||
}
|
||||
|
||||
if (/^\/api\/collections\/\d+\/items/.test(url) && method === 'GET') {
|
||||
return Promise.resolve(jsonResponse({ page: [], totalCount: 0 }));
|
||||
}
|
||||
|
||||
return Promise.resolve(new Response(null, { status: 204 }));
|
||||
});
|
||||
}
|
||||
@@ -237,15 +241,14 @@ describe('CollectionsScreen', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('opens a manual collection and previews its items via a collection: query', async () => {
|
||||
it('opens a manual collection and lists its items via the collection-items endpoint', async () => {
|
||||
const fetchMock = mockApi({
|
||||
onRequest: (url) => {
|
||||
if (url.startsWith('/api/library/browse')) {
|
||||
const q = new URL(url, 'http://localhost').searchParams.get('query') ?? '';
|
||||
|
||||
if (q.startsWith('collection:')) {
|
||||
return jsonResponse({ page: [{ id: 5, mediaItemId: 5, mediaType: 'Movie', title: 'Inception' }], totalCount: 1 });
|
||||
}
|
||||
onRequest: (url, method) => {
|
||||
if (/^\/api\/collections\/1\/items/.test(url) && method === 'GET') {
|
||||
return jsonResponse({
|
||||
page: [{ id: 5, mediaItemId: 5, mediaType: 'Movie', title: 'Inception' }],
|
||||
totalCount: 1
|
||||
});
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -258,12 +261,15 @@ describe('CollectionsScreen', () => {
|
||||
fireEvent.click(screen.getByText('Favorites'));
|
||||
|
||||
expect(await screen.findByText('Inception')).toBeInTheDocument();
|
||||
expect(screen.getByText(/best-effort search preview/)).toBeInTheDocument();
|
||||
// The lossy best-effort search-preview banner is gone now that a real endpoint exists.
|
||||
expect(screen.queryByText(/best-effort search preview/)).not.toBeInTheDocument();
|
||||
|
||||
const browseCall = fetchMock.mock.calls.find(([u]) =>
|
||||
u.toString().includes('collection%3A')
|
||||
);
|
||||
expect(new URL(String(browseCall?.[0]), 'http://localhost').searchParams.get('query')).toBe('collection:"Favorites"');
|
||||
const itemsCall = fetchMock.mock.calls.find(([u]) => /\/api\/collections\/1\/items/.test(u.toString()));
|
||||
expect(itemsCall).toBeDefined();
|
||||
const itemsUrl = new URL(String(itemsCall?.[0]), 'http://localhost');
|
||||
expect(itemsUrl.pathname).toBe('/api/collections/1/items');
|
||||
expect(itemsUrl.searchParams.get('pageNum')).toBe('0');
|
||||
expect(itemsUrl.searchParams.get('pageSize')).toBe('100');
|
||||
});
|
||||
|
||||
const addItemsByType: Record<string, { id: number; mediaType: string; title: string }> = {
|
||||
@@ -301,7 +307,7 @@ describe('CollectionsScreen', () => {
|
||||
await screen.findByText('Favorites');
|
||||
|
||||
fireEvent.click(screen.getByText('Favorites'));
|
||||
await screen.findByText(/best-effort search preview/);
|
||||
await screen.findByRole('button', { name: 'Add items' });
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add items' }));
|
||||
return screen.getByRole('dialog');
|
||||
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
createSmartCollection,
|
||||
deleteCollection,
|
||||
deleteSmartCollection,
|
||||
getCollectionItemsPreview,
|
||||
getCollectionItems,
|
||||
getCollections,
|
||||
getLibraryBrowseItems,
|
||||
getSmartCollections,
|
||||
@@ -482,20 +482,24 @@ function ManualItemsView({
|
||||
collection: MediaCollection;
|
||||
onBack: () => void;
|
||||
}) {
|
||||
const PAGE_SIZE = 100;
|
||||
const [items, setItems] = useState<LibraryBrowseItem[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const [removing, setRemoving] = useState<number | null>(null);
|
||||
const activeRef = useRef(true);
|
||||
|
||||
// No synchronous setState here: `loading` starts true and flips false in `finally`, so
|
||||
// this is safe to call from an effect. Reloads (after add/remove) keep the list visible.
|
||||
// this is safe to call from an effect. Reloads (after add/remove) reset to the first page.
|
||||
const load = useCallback(() => {
|
||||
getCollectionItemsPreview(collection.name ?? '')
|
||||
.then((preview) => {
|
||||
getCollectionItems(collection.id, 0, PAGE_SIZE)
|
||||
.then((result) => {
|
||||
if (activeRef.current) {
|
||||
setItems(preview);
|
||||
setItems(result.page ?? []);
|
||||
setTotal(result.totalCount ?? 0);
|
||||
setError(null);
|
||||
}
|
||||
})
|
||||
@@ -509,7 +513,23 @@ function ManualItemsView({
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
}, [collection.name]);
|
||||
}, [collection.id]);
|
||||
|
||||
const loadMore = async () => {
|
||||
const nextPage = Math.floor(items.length / PAGE_SIZE);
|
||||
setLoadingMore(true);
|
||||
try {
|
||||
const result = await getCollectionItems(collection.id, nextPage, PAGE_SIZE);
|
||||
if (activeRef.current) {
|
||||
setItems((prev) => [...prev, ...(result.page ?? [])]);
|
||||
setTotal(result.totalCount ?? 0);
|
||||
}
|
||||
} catch (moreError) {
|
||||
setError(messageFromCollectionError(moreError, 'Unable to load more items'));
|
||||
} finally {
|
||||
setLoadingMore(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
activeRef.current = true;
|
||||
@@ -547,15 +567,6 @@ function ManualItemsView({
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="ctv-settings-warn-callout" role="note">
|
||||
<Info aria-hidden="true" color="var(--status-warn)" size={14} />
|
||||
<span>
|
||||
The API has no endpoint to list a manual collection's items. This is a best-effort search preview covering
|
||||
movies, shows, seasons and artists only — other kinds in this collection won't appear. Adding items works for
|
||||
all shown kinds.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="ctv-channels-error" role="alert">
|
||||
<TriangleAlert aria-hidden="true" size={15} />
|
||||
@@ -570,7 +581,7 @@ function ManualItemsView({
|
||||
<span>Loading items…</span>
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="ctv-collections-empty">No previewable items in this collection.</div>
|
||||
<div className="ctv-collections-empty">No items in this collection.</div>
|
||||
) : (
|
||||
items.map((item, index) => (
|
||||
<div
|
||||
@@ -598,6 +609,17 @@ function ManualItemsView({
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{!loading && items.length < total && (
|
||||
<div className="ctv-collections-loadmore">
|
||||
<span className="ctv-collections-count">
|
||||
Showing {items.length} of {total}
|
||||
</span>
|
||||
<Button disabled={loadingMore} onClick={() => void loadMore()} size="sm" variant="ghost">
|
||||
{loadingMore ? <Spinner size={14} /> : 'Load more'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AddItemsDialog
|
||||
collection={collection}
|
||||
key={`add-${pickerOpen}`}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { ArrowLeft, ExternalLink, Pencil, Plus, RefreshCw, Search, Trash2, TriangleAlert } from 'lucide-react';
|
||||
import { ArrowLeft, Pencil, Plus, RefreshCw, Search, Trash2, TriangleAlert } from 'lucide-react';
|
||||
import { navigateToPath } from '../routing';
|
||||
import { Badge, Button, Card, ConfirmDialog, Dialog, IconButton, Input, Spinner, Switch } from '../components';
|
||||
import {
|
||||
@@ -16,11 +16,8 @@ import {
|
||||
|
||||
const TRAKT_BASE_PATH = '/app/trakt-lists';
|
||||
|
||||
// The SPA has no search screen yet (#161) — "view matched items" opens the classic Blazor
|
||||
// search page, same interim deep-link pattern used elsewhere for un-migrated screens.
|
||||
function classicSearchUrl(traktId: number): string {
|
||||
const params = new URLSearchParams({ query: `trakt_list:${traktId}` });
|
||||
return `/search?${params.toString()}`;
|
||||
function viewMatchedItems(traktId: number) {
|
||||
navigateToPath(`/app/search?query=${encodeURIComponent(`trakt_list:${traktId}`)}`);
|
||||
}
|
||||
|
||||
function traktListIdFromPathname(pathname: string): number | null {
|
||||
@@ -462,15 +459,14 @@ export function TraktListsScreen() {
|
||||
>
|
||||
<RefreshCw aria-hidden="true" size={14} />
|
||||
</IconButton>
|
||||
<a
|
||||
className="ctv-icon-button ctv-icon-button-sm"
|
||||
href={classicSearchUrl(list.traktId)}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
title="View matched items (opens the Classic UI search page)"
|
||||
<IconButton
|
||||
disabled={busy}
|
||||
onClick={() => viewMatchedItems(list.traktId)}
|
||||
size="sm"
|
||||
title="View matched items"
|
||||
>
|
||||
<ExternalLink aria-hidden="true" size={14} />
|
||||
</a>
|
||||
<Search aria-hidden="true" size={14} />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
disabled={busy}
|
||||
onClick={() => setDeleteTarget(list)}
|
||||
|
||||
Reference in New Issue
Block a user