@@ -33,6 +33,8 @@ public class GetLibraryBrowseItemsHandler(
|
||||
cancellationToken);
|
||||
|
||||
int collectionTotal = await CountCollections(dbContext, request, cancellationToken);
|
||||
// Lucene total count can briefly include stale media ids until the async rescan catches up; collection
|
||||
// paging may drift in that window, matching the staleness behavior accepted by the existing search UI.
|
||||
int collectionSkip = Math.Max(0, offset - mediaResult.TotalCount);
|
||||
int collectionTake = request.PageSize - mediaItems.Count;
|
||||
List<LibraryBrowseItemResponseModel> collectionItems = collectionTake > 0
|
||||
@@ -162,7 +164,10 @@ public class GetLibraryBrowseItemsHandler(
|
||||
.Include(mm => mm.Movie)
|
||||
.ThenInclude(m => m.MediaVersions)
|
||||
.ToListAsync(cancellationToken)
|
||||
.Map(list => list.Map(mm => new LibraryBrowseItemResponseModel(
|
||||
.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,
|
||||
@@ -206,7 +211,10 @@ public class GetLibraryBrowseItemsHandler(
|
||||
.ThenInclude(s => s.LibraryPath)
|
||||
.ThenInclude(lp => lp.Library)
|
||||
.ToListAsync(cancellationToken)
|
||||
.Map(list => list.Map(sm => new LibraryBrowseItemResponseModel(
|
||||
.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,
|
||||
@@ -253,7 +261,10 @@ public class GetLibraryBrowseItemsHandler(
|
||||
.ThenInclude(s => s.LibraryPath)
|
||||
.ThenInclude(lp => lp.Library)
|
||||
.ToListAsync(cancellationToken)
|
||||
.Map(list => list.Map(sm => new LibraryBrowseItemResponseModel(
|
||||
.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),
|
||||
@@ -297,7 +308,10 @@ public class GetLibraryBrowseItemsHandler(
|
||||
.ThenInclude(a => a.LibraryPath)
|
||||
.ThenInclude(lp => lp.Library)
|
||||
.ToListAsync(cancellationToken)
|
||||
.Map(list => list.Map(am => new LibraryBrowseItemResponseModel(
|
||||
.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,
|
||||
@@ -385,6 +399,11 @@ public class GetLibraryBrowseItemsHandler(
|
||||
.GroupBy(ci => ci.CollectionId)
|
||||
.Select(g => new { CollectionId = g.Key, Count = g.Count() })
|
||||
.ToDictionaryAsync(g => g.CollectionId, g => g.Count, cancellationToken);
|
||||
Dictionary<int, TimeSpan?> durations = await GetManualCollectionDurations(
|
||||
dbContext,
|
||||
collectionIds,
|
||||
cancellationToken);
|
||||
Dictionary<int, string> artwork = await GetManualCollectionArtwork(dbContext, collectionIds, cancellationToken);
|
||||
|
||||
results.AddRange(collections.Map(c => new LibraryBrowseItemResponseModel(
|
||||
c.Id,
|
||||
@@ -392,8 +411,8 @@ public class GetLibraryBrowseItemsHandler(
|
||||
c.Name,
|
||||
null,
|
||||
null,
|
||||
string.Empty,
|
||||
null,
|
||||
artwork.TryGetValue(c.Id, out string poster) ? poster : string.Empty,
|
||||
durations.TryGetValue(c.Id, out TimeSpan? duration) ? duration : null,
|
||||
itemCounts.TryGetValue(c.Id, out int itemCount) ? itemCount : 0,
|
||||
"Manual",
|
||||
CollectionType.Collection,
|
||||
@@ -552,6 +571,7 @@ public class GetLibraryBrowseItemsHandler(
|
||||
null,
|
||||
null,
|
||||
"Rerun",
|
||||
// RerunFirstRun addresses the first-run side; the SPA can substitute RerunRerun for rerun schedule items.
|
||||
CollectionType.RerunFirstRun,
|
||||
null,
|
||||
null,
|
||||
@@ -633,7 +653,7 @@ public class GetLibraryBrowseItemsHandler(
|
||||
return query;
|
||||
}
|
||||
|
||||
return query.Where(c => EF.Functions.Like(EF.Property<string>(c, "Name"), $"%{searchQuery}%"));
|
||||
return query.Where(c => EF.Functions.Like(EF.Property<string>(c, "Name"), $"%{EscapeLike(searchQuery)}%", "\\"));
|
||||
}
|
||||
|
||||
private static bool ShouldInclude(LibraryBrowseMediaType? requestType, LibraryBrowseMediaType itemType) =>
|
||||
@@ -641,10 +661,247 @@ public class GetLibraryBrowseItemsHandler(
|
||||
|
||||
private static TimeSpan? BestDuration(IEnumerable<MediaVersion> versions)
|
||||
{
|
||||
TimeSpan duration = versions.Select(v => v.Duration).FirstOrDefault(d => d > TimeSpan.Zero);
|
||||
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,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
List<CollectionMediaItem> items = await GetCollectionMediaItems(dbContext, collectionIds, cancellationToken);
|
||||
List<int> mediaItemIds = items.Map(i => i.MediaItemId).Distinct().ToList();
|
||||
Dictionary<int, TimeSpan> mediaItemDurations = [];
|
||||
|
||||
foreach (Movie movie in await dbContext.Movies
|
||||
.AsNoTracking()
|
||||
.Where(m => mediaItemIds.Contains(m.Id))
|
||||
.Include(m => m.MediaVersions)
|
||||
.ToListAsync(cancellationToken))
|
||||
{
|
||||
TimeSpan? duration = BestDuration(movie.MediaVersions);
|
||||
if (duration.HasValue)
|
||||
{
|
||||
mediaItemDurations[movie.Id] = duration.Value;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (Episode episode in await dbContext.Episodes
|
||||
.AsNoTracking()
|
||||
.Where(e => mediaItemIds.Contains(e.Id))
|
||||
.Include(e => e.MediaVersions)
|
||||
.ToListAsync(cancellationToken))
|
||||
{
|
||||
TimeSpan? duration = BestDuration(episode.MediaVersions);
|
||||
if (duration.HasValue)
|
||||
{
|
||||
mediaItemDurations[episode.Id] = duration.Value;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (MusicVideo musicVideo in await dbContext.MusicVideos
|
||||
.AsNoTracking()
|
||||
.Where(mv => mediaItemIds.Contains(mv.Id))
|
||||
.Include(mv => mv.MediaVersions)
|
||||
.ToListAsync(cancellationToken))
|
||||
{
|
||||
TimeSpan? duration = BestDuration(musicVideo.MediaVersions);
|
||||
if (duration.HasValue)
|
||||
{
|
||||
mediaItemDurations[musicVideo.Id] = duration.Value;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (OtherVideo otherVideo in await dbContext.OtherVideos
|
||||
.AsNoTracking()
|
||||
.Where(ov => mediaItemIds.Contains(ov.Id))
|
||||
.Include(ov => ov.MediaVersions)
|
||||
.ToListAsync(cancellationToken))
|
||||
{
|
||||
TimeSpan? duration = BestDuration(otherVideo.MediaVersions);
|
||||
if (duration.HasValue)
|
||||
{
|
||||
mediaItemDurations[otherVideo.Id] = duration.Value;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (Song song in await dbContext.Songs
|
||||
.AsNoTracking()
|
||||
.Where(s => mediaItemIds.Contains(s.Id))
|
||||
.Include(s => s.MediaVersions)
|
||||
.ToListAsync(cancellationToken))
|
||||
{
|
||||
TimeSpan? duration = BestDuration(song.MediaVersions);
|
||||
if (duration.HasValue)
|
||||
{
|
||||
mediaItemDurations[song.Id] = duration.Value;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (Image image in await dbContext.Images
|
||||
.AsNoTracking()
|
||||
.Where(i => mediaItemIds.Contains(i.Id))
|
||||
.Include(i => i.MediaVersions)
|
||||
.ToListAsync(cancellationToken))
|
||||
{
|
||||
TimeSpan? duration = BestDuration(image.MediaVersions);
|
||||
if (duration.HasValue)
|
||||
{
|
||||
mediaItemDurations[image.Id] = duration.Value;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (RemoteStream remoteStream in await dbContext.RemoteStreams
|
||||
.AsNoTracking()
|
||||
.Where(rs => mediaItemIds.Contains(rs.Id))
|
||||
.Include(rs => rs.MediaVersions)
|
||||
.ToListAsync(cancellationToken))
|
||||
{
|
||||
TimeSpan? duration = BestDuration(remoteStream.MediaVersions);
|
||||
if (duration.HasValue)
|
||||
{
|
||||
mediaItemDurations[remoteStream.Id] = duration.Value;
|
||||
}
|
||||
}
|
||||
|
||||
return items
|
||||
.Where(i => mediaItemDurations.ContainsKey(i.MediaItemId))
|
||||
.GroupBy(i => i.CollectionId)
|
||||
.ToDictionary(
|
||||
g => g.Key,
|
||||
g => (TimeSpan?)g.Aggregate(TimeSpan.Zero, (sum, item) => sum + mediaItemDurations[item.MediaItemId]));
|
||||
}
|
||||
|
||||
private static async Task<Dictionary<int, string>> GetManualCollectionArtwork(
|
||||
TvContext dbContext,
|
||||
List<int> collectionIds,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
List<CollectionMediaItem> items = await GetCollectionMediaItems(dbContext, collectionIds, cancellationToken);
|
||||
List<int> mediaItemIds = items.Map(i => i.MediaItemId).Distinct().ToList();
|
||||
Dictionary<int, string> mediaItemArtwork = [];
|
||||
|
||||
foreach (MovieMetadata metadata in await dbContext.MovieMetadata
|
||||
.AsNoTracking()
|
||||
.Where(mm => mediaItemIds.Contains(mm.MovieId))
|
||||
.Include(mm => mm.Artwork)
|
||||
.OrderBy(mm => mm.Id)
|
||||
.ToListAsync(cancellationToken))
|
||||
{
|
||||
string poster = Artwork(metadata, ArtworkKind.Poster);
|
||||
if (!string.IsNullOrWhiteSpace(poster) && !mediaItemArtwork.ContainsKey(metadata.MovieId))
|
||||
{
|
||||
mediaItemArtwork[metadata.MovieId] = poster;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (ShowMetadata metadata in await dbContext.ShowMetadata
|
||||
.AsNoTracking()
|
||||
.Where(sm => mediaItemIds.Contains(sm.ShowId))
|
||||
.Include(sm => sm.Artwork)
|
||||
.OrderBy(sm => sm.Id)
|
||||
.ToListAsync(cancellationToken))
|
||||
{
|
||||
string poster = Artwork(metadata, ArtworkKind.Poster);
|
||||
if (!string.IsNullOrWhiteSpace(poster) && !mediaItemArtwork.ContainsKey(metadata.ShowId))
|
||||
{
|
||||
mediaItemArtwork[metadata.ShowId] = poster;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (SeasonMetadata metadata in await dbContext.SeasonMetadata
|
||||
.AsNoTracking()
|
||||
.Where(sm => mediaItemIds.Contains(sm.SeasonId))
|
||||
.Include(sm => sm.Artwork)
|
||||
.OrderBy(sm => sm.Id)
|
||||
.ToListAsync(cancellationToken))
|
||||
{
|
||||
string poster = Artwork(metadata, ArtworkKind.Poster);
|
||||
if (!string.IsNullOrWhiteSpace(poster) && !mediaItemArtwork.ContainsKey(metadata.SeasonId))
|
||||
{
|
||||
mediaItemArtwork[metadata.SeasonId] = poster;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (OtherVideoMetadata metadata in await dbContext.OtherVideoMetadata
|
||||
.AsNoTracking()
|
||||
.Where(ovm => mediaItemIds.Contains(ovm.OtherVideoId))
|
||||
.Include(ovm => ovm.Artwork)
|
||||
.OrderBy(ovm => ovm.Id)
|
||||
.ToListAsync(cancellationToken))
|
||||
{
|
||||
string poster = Artwork(metadata, ArtworkKind.Poster);
|
||||
if (!string.IsNullOrWhiteSpace(poster) && !mediaItemArtwork.ContainsKey(metadata.OtherVideoId))
|
||||
{
|
||||
mediaItemArtwork[metadata.OtherVideoId] = poster;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (SongMetadata metadata in await dbContext.SongMetadata
|
||||
.AsNoTracking()
|
||||
.Where(sm => mediaItemIds.Contains(sm.SongId))
|
||||
.Include(sm => sm.Artwork)
|
||||
.OrderBy(sm => sm.Id)
|
||||
.ToListAsync(cancellationToken))
|
||||
{
|
||||
string poster = Artwork(metadata, ArtworkKind.Poster);
|
||||
if (!string.IsNullOrWhiteSpace(poster) && !mediaItemArtwork.ContainsKey(metadata.SongId))
|
||||
{
|
||||
mediaItemArtwork[metadata.SongId] = poster;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (ImageMetadata metadata in await dbContext.ImageMetadata
|
||||
.AsNoTracking()
|
||||
.Where(im => mediaItemIds.Contains(im.ImageId))
|
||||
.Include(im => im.Artwork)
|
||||
.OrderBy(im => im.Id)
|
||||
.ToListAsync(cancellationToken))
|
||||
{
|
||||
string poster = Artwork(metadata, ArtworkKind.Poster);
|
||||
if (!string.IsNullOrWhiteSpace(poster) && !mediaItemArtwork.ContainsKey(metadata.ImageId))
|
||||
{
|
||||
mediaItemArtwork[metadata.ImageId] = poster;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (RemoteStreamMetadata metadata in await dbContext.RemoteStreamMetadata
|
||||
.AsNoTracking()
|
||||
.Where(rsm => mediaItemIds.Contains(rsm.RemoteStreamId))
|
||||
.Include(rsm => rsm.Artwork)
|
||||
.OrderBy(rsm => rsm.Id)
|
||||
.ToListAsync(cancellationToken))
|
||||
{
|
||||
string poster = Artwork(metadata, ArtworkKind.Poster);
|
||||
if (!string.IsNullOrWhiteSpace(poster) && !mediaItemArtwork.ContainsKey(metadata.RemoteStreamId))
|
||||
{
|
||||
mediaItemArtwork[metadata.RemoteStreamId] = poster;
|
||||
}
|
||||
}
|
||||
|
||||
return items
|
||||
.Where(i => mediaItemArtwork.ContainsKey(i.MediaItemId))
|
||||
.GroupBy(i => i.CollectionId)
|
||||
.ToDictionary(
|
||||
g => g.Key,
|
||||
g => mediaItemArtwork[g.OrderBy(i => i.CustomIndex ?? int.MaxValue).ThenBy(i => i.MediaItemId).First().MediaItemId]);
|
||||
}
|
||||
|
||||
private static async Task<List<CollectionMediaItem>> GetCollectionMediaItems(
|
||||
TvContext dbContext,
|
||||
List<int> collectionIds,
|
||||
CancellationToken cancellationToken) =>
|
||||
await dbContext.CollectionItems
|
||||
.AsNoTracking()
|
||||
.Where(ci => collectionIds.Contains(ci.CollectionId))
|
||||
.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()
|
||||
@@ -685,4 +942,12 @@ public class GetLibraryBrowseItemsHandler(
|
||||
|
||||
return artwork;
|
||||
}
|
||||
|
||||
private static string EscapeLike(string searchQuery) =>
|
||||
searchQuery
|
||||
.Replace("\\", "\\\\", StringComparison.Ordinal)
|
||||
.Replace("%", "\\%", StringComparison.Ordinal)
|
||||
.Replace("_", "\\_", StringComparison.Ordinal);
|
||||
|
||||
private sealed record CollectionMediaItem(int CollectionId, int MediaItemId, int? CustomIndex);
|
||||
}
|
||||
|
||||
@@ -137,6 +137,161 @@ public class GetLibraryBrowseItemsHandlerTests
|
||||
i.PlaylistId == 60);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Page_Across_Media_Manual_Collections_And_Smart_Collections()
|
||||
{
|
||||
await SeedMediaAndCollectionPagingGraph();
|
||||
_searchIndex.Search(
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<string>(),
|
||||
Arg.Is<int>(offset => offset == 0),
|
||||
Arg.Any<int>(),
|
||||
Arg.Any<List<string>>(),
|
||||
Arg.Any<CancellationToken>())
|
||||
.Returns(new SearchResult(
|
||||
[
|
||||
new SearchItem(LuceneSearchIndex.MovieType, 101),
|
||||
new SearchItem(LuceneSearchIndex.MovieType, 102)
|
||||
],
|
||||
2));
|
||||
_searchIndex.Search(
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<string>(),
|
||||
Arg.Is<int>(offset => offset > 0),
|
||||
Arg.Any<int>(),
|
||||
Arg.Any<List<string>>(),
|
||||
Arg.Any<CancellationToken>())
|
||||
.Returns(new SearchResult([], 2));
|
||||
var handler = new GetLibraryBrowseItemsHandler(_searchIndex, _db.Factory);
|
||||
|
||||
PagedLibraryBrowseItemsResponseModel page0 = await handler.Handle(
|
||||
new GetLibraryBrowseItems("", null, null, 0, 3),
|
||||
CancellationToken.None);
|
||||
PagedLibraryBrowseItemsResponseModel page1 = await handler.Handle(
|
||||
new GetLibraryBrowseItems("", null, null, 1, 3),
|
||||
CancellationToken.None);
|
||||
PagedLibraryBrowseItemsResponseModel page2 = await handler.Handle(
|
||||
new GetLibraryBrowseItems("", null, null, 2, 3),
|
||||
CancellationToken.None);
|
||||
|
||||
page0.TotalCount.ShouldBe(7);
|
||||
page0.Page.Select(i => i.Title).ShouldBe(["Movie One", "Movie Two", "Manual A"]);
|
||||
page1.TotalCount.ShouldBe(7);
|
||||
page1.Page.Select(i => i.Title).ShouldBe(["Manual B", "Manual C", "Smart A"]);
|
||||
page2.TotalCount.ShouldBe(7);
|
||||
page2.Page.Select(i => i.Title).ShouldBe(["Smart B"]);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Hydrate_Manual_Collection_Artwork_And_Total_Duration()
|
||||
{
|
||||
await SeedManualCollectionMetadataGraph();
|
||||
_searchIndex.Search(
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<int>(),
|
||||
Arg.Any<int>(),
|
||||
Arg.Any<List<string>>(),
|
||||
Arg.Any<CancellationToken>())
|
||||
.Returns(new SearchResult([], 0));
|
||||
var handler = new GetLibraryBrowseItemsHandler(_searchIndex, _db.Factory);
|
||||
|
||||
PagedLibraryBrowseItemsResponseModel result = await handler.Handle(
|
||||
new GetLibraryBrowseItems("", null, LibraryBrowseMediaType.Collection, 0, 10),
|
||||
CancellationToken.None);
|
||||
|
||||
result.TotalCount.ShouldBe(1);
|
||||
result.Page.Count.ShouldBe(1);
|
||||
result.Page[0].Title.ShouldBe("Manual Metadata");
|
||||
result.Page[0].Artwork.ShouldBe("first-poster.jpg");
|
||||
result.Page[0].Duration.ShouldBe(TimeSpan.FromMinutes(75));
|
||||
result.Page[0].ItemCount.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Hydrate_Seasons_With_Composed_Titles_And_Episode_Counts()
|
||||
{
|
||||
await SeedSeasonGraph();
|
||||
_searchIndex.Search(
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<int>(),
|
||||
Arg.Any<int>(),
|
||||
Arg.Any<List<string>>(),
|
||||
Arg.Any<CancellationToken>())
|
||||
.Returns(new SearchResult(
|
||||
[
|
||||
new SearchItem(LuceneSearchIndex.SeasonType, 301),
|
||||
new SearchItem(LuceneSearchIndex.SeasonType, 302)
|
||||
],
|
||||
2));
|
||||
var handler = new GetLibraryBrowseItemsHandler(_searchIndex, _db.Factory);
|
||||
|
||||
PagedLibraryBrowseItemsResponseModel result = await handler.Handle(
|
||||
new GetLibraryBrowseItems("", null, LibraryBrowseMediaType.TelevisionSeason, 0, 10),
|
||||
CancellationToken.None);
|
||||
|
||||
result.TotalCount.ShouldBe(2);
|
||||
result.Page[0].Title.ShouldBe("Season Show - Season 2");
|
||||
result.Page[0].ItemCount.ShouldBe(2);
|
||||
result.Page[0].CollectionType.ShouldBe(CollectionType.TelevisionSeason);
|
||||
result.Page[0].MediaItemId.ShouldBe(301);
|
||||
result.Page[1].Title.ShouldBe("Season Show - Specials");
|
||||
result.Page[1].ItemCount.ShouldBe(1);
|
||||
result.Page[1].MediaItemId.ShouldBe(302);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Hydrate_Artists_With_Music_Video_Counts()
|
||||
{
|
||||
await SeedArtistGraph();
|
||||
_searchIndex.Search(
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<int>(),
|
||||
Arg.Any<int>(),
|
||||
Arg.Any<List<string>>(),
|
||||
Arg.Any<CancellationToken>())
|
||||
.Returns(new SearchResult([new SearchItem(LuceneSearchIndex.ArtistType, 401)], 1));
|
||||
var handler = new GetLibraryBrowseItemsHandler(_searchIndex, _db.Factory);
|
||||
|
||||
PagedLibraryBrowseItemsResponseModel result = await handler.Handle(
|
||||
new GetLibraryBrowseItems("", null, LibraryBrowseMediaType.Artist, 0, 10),
|
||||
CancellationToken.None);
|
||||
|
||||
result.TotalCount.ShouldBe(1);
|
||||
result.Page[0].Title.ShouldBe("Counted Artist");
|
||||
result.Page[0].ItemCount.ShouldBe(2);
|
||||
result.Page[0].Artwork.ShouldBe("artist-thumb.jpg");
|
||||
result.Page[0].CollectionType.ShouldBe(CollectionType.Artist);
|
||||
result.Page[0].MediaItemId.ShouldBe(401);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Apply_Library_Filter_To_Manual_Collections_Only()
|
||||
{
|
||||
await SeedLibraryFilteredCollectionsGraph();
|
||||
_searchIndex.Search(
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<int>(),
|
||||
Arg.Any<int>(),
|
||||
Arg.Any<List<string>>(),
|
||||
Arg.Any<CancellationToken>())
|
||||
.Returns(new SearchResult([], 0));
|
||||
var handler = new GetLibraryBrowseItemsHandler(_searchIndex, _db.Factory);
|
||||
|
||||
PagedLibraryBrowseItemsResponseModel result = await handler.Handle(
|
||||
new GetLibraryBrowseItems("", 501, null, 0, 10),
|
||||
CancellationToken.None);
|
||||
|
||||
result.TotalCount.ShouldBe(1);
|
||||
result.Page.Count.ShouldBe(1);
|
||||
result.Page[0].Title.ShouldBe("Included Manual");
|
||||
result.Page[0].MediaType.ShouldBe(LibraryBrowseMediaType.Collection);
|
||||
result.Page[0].CollectionId.ShouldBe(510);
|
||||
}
|
||||
|
||||
private async Task SeedLibraryGraph()
|
||||
{
|
||||
await using TvContext context = _db.CreateContext();
|
||||
@@ -252,6 +407,202 @@ public class GetLibraryBrowseItemsHandlerTests
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private async Task SeedMediaAndCollectionPagingGraph()
|
||||
{
|
||||
await using TvContext context = _db.CreateContext();
|
||||
(LocalLibrary library, LibraryPath path) = MakeLibrary(100, "Paging Library");
|
||||
|
||||
var movie1 = MakeMovie(101, path, "Movie One", "movie-one.jpg", TimeSpan.FromMinutes(10));
|
||||
var movie2 = MakeMovie(102, path, "Movie Two", "movie-two.jpg", TimeSpan.FromMinutes(20));
|
||||
var manualA = MakeCollection(201, "Manual A");
|
||||
var manualB = MakeCollection(202, "Manual B");
|
||||
var manualC = MakeCollection(203, "Manual C");
|
||||
|
||||
context.LocalLibraries.Add(library);
|
||||
context.Movies.AddRange(movie1, movie2);
|
||||
context.Collections.AddRange(manualA, manualB, manualC);
|
||||
context.SmartCollections.AddRange(
|
||||
new SmartCollection { Id = 301, Name = "Smart A", Query = "tag:a", MultiCollections = [], MultiCollectionSmartItems = [] },
|
||||
new SmartCollection { Id = 302, Name = "Smart B", Query = "tag:b", MultiCollections = [], MultiCollectionSmartItems = [] });
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private async Task SeedManualCollectionMetadataGraph()
|
||||
{
|
||||
await using TvContext context = _db.CreateContext();
|
||||
(LocalLibrary library, LibraryPath path) = MakeLibrary(200, "Manual Metadata Library");
|
||||
var collection = MakeCollection(210, "Manual Metadata");
|
||||
var first = MakeMovie(211, path, "First", "first-poster.jpg", TimeSpan.FromMinutes(30));
|
||||
var second = MakeMovie(212, path, "Second", "second-poster.jpg", TimeSpan.FromMinutes(45));
|
||||
collection.MediaItems.AddRange([first, second]);
|
||||
|
||||
context.LocalLibraries.Add(library);
|
||||
context.Collections.Add(collection);
|
||||
context.Movies.AddRange(first, second);
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private async Task SeedSeasonGraph()
|
||||
{
|
||||
await using TvContext context = _db.CreateContext();
|
||||
(LocalLibrary library, LibraryPath path) = MakeLibrary(300, "Season Library");
|
||||
var show = new Show
|
||||
{
|
||||
Id = 300,
|
||||
LibraryPath = path,
|
||||
Collections = [],
|
||||
CollectionItems = [],
|
||||
TraktListItems = [],
|
||||
Seasons = [],
|
||||
ShowMetadata = [MakeShowMetadata("Season Show", "show.jpg")]
|
||||
};
|
||||
var season = MakeSeason(301, path, show, 2);
|
||||
var specials = MakeSeason(302, path, show, 0);
|
||||
season.Episodes.AddRange([
|
||||
MakeEpisode(311, path, season),
|
||||
MakeEpisode(312, path, season)
|
||||
]);
|
||||
specials.Episodes.Add(MakeEpisode(313, path, specials));
|
||||
show.Seasons.AddRange([season, specials]);
|
||||
path.MediaItems.AddRange([show, season, specials, .. season.Episodes, .. specials.Episodes]);
|
||||
|
||||
context.LocalLibraries.Add(library);
|
||||
context.Shows.Add(show);
|
||||
context.Seasons.AddRange(season, specials);
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private async Task SeedArtistGraph()
|
||||
{
|
||||
await using TvContext context = _db.CreateContext();
|
||||
(LocalLibrary library, LibraryPath path) = MakeLibrary(400, "Artist Library");
|
||||
var artist = new Artist
|
||||
{
|
||||
Id = 401,
|
||||
LibraryPath = path,
|
||||
Collections = [],
|
||||
CollectionItems = [],
|
||||
TraktListItems = [],
|
||||
MusicVideos = [],
|
||||
ArtistMetadata = [MakeArtistMetadata("Counted Artist", "artist-thumb.jpg")]
|
||||
};
|
||||
var musicVideo1 = MakeMusicVideo(411, path, artist);
|
||||
var musicVideo2 = MakeMusicVideo(412, path, artist);
|
||||
artist.MusicVideos.AddRange([musicVideo1, musicVideo2]);
|
||||
path.MediaItems.AddRange([artist, musicVideo1, musicVideo2]);
|
||||
|
||||
context.LocalLibraries.Add(library);
|
||||
context.Artists.Add(artist);
|
||||
context.MusicVideos.AddRange(musicVideo1, musicVideo2);
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private async Task SeedLibraryFilteredCollectionsGraph()
|
||||
{
|
||||
await using TvContext context = _db.CreateContext();
|
||||
(LocalLibrary includedLibrary, LibraryPath includedPath) = MakeLibrary(501, "Included Library");
|
||||
(LocalLibrary excludedLibrary, LibraryPath excludedPath) = MakeLibrary(502, "Excluded Library");
|
||||
var includedCollection = MakeCollection(510, "Included Manual");
|
||||
var excludedCollection = MakeCollection(511, "Excluded Manual");
|
||||
var includedMovie = MakeMovie(520, includedPath, "Included Movie", "included.jpg", TimeSpan.FromMinutes(5));
|
||||
var excludedMovie = MakeMovie(521, excludedPath, "Excluded Movie", "excluded.jpg", TimeSpan.FromMinutes(5));
|
||||
includedCollection.MediaItems.Add(includedMovie);
|
||||
excludedCollection.MediaItems.Add(excludedMovie);
|
||||
|
||||
context.LocalLibraries.AddRange(includedLibrary, excludedLibrary);
|
||||
context.Movies.AddRange(includedMovie, excludedMovie);
|
||||
context.Collections.AddRange(includedCollection, excludedCollection);
|
||||
context.SmartCollections.Add(new SmartCollection { Id = 530, Name = "Smart", Query = "tag", MultiCollections = [], MultiCollectionSmartItems = [] });
|
||||
context.MultiCollections.Add(new MultiCollection { Id = 540, Name = "Multi", Collections = [], SmartCollections = [], MultiCollectionItems = [], MultiCollectionSmartItems = [] });
|
||||
context.RerunCollections.Add(new RerunCollection { Id = 550, Name = "Rerun", CollectionType = CollectionType.Collection });
|
||||
context.Playlists.Add(new Playlist { Id = 560, Name = "Playlist", Items = [] });
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private static (LocalLibrary Library, LibraryPath Path) MakeLibrary(int id, string name)
|
||||
{
|
||||
var library = new LocalLibrary
|
||||
{
|
||||
Id = id,
|
||||
Name = name,
|
||||
MediaKind = LibraryMediaKind.Movies,
|
||||
Paths = []
|
||||
};
|
||||
var path = new LibraryPath
|
||||
{
|
||||
Id = id,
|
||||
Path = $"/media/{id}",
|
||||
Library = library,
|
||||
LibraryFolders = [],
|
||||
MediaItems = []
|
||||
};
|
||||
library.Paths.Add(path);
|
||||
return (library, path);
|
||||
}
|
||||
|
||||
private static Collection MakeCollection(int id, string name) =>
|
||||
new()
|
||||
{
|
||||
Id = id,
|
||||
Name = name,
|
||||
MediaItems = [],
|
||||
CollectionItems = [],
|
||||
MultiCollections = [],
|
||||
MultiCollectionItems = []
|
||||
};
|
||||
|
||||
private static Movie MakeMovie(int id, LibraryPath path, string title, string poster, TimeSpan duration) =>
|
||||
new()
|
||||
{
|
||||
Id = id,
|
||||
LibraryPath = path,
|
||||
Collections = [],
|
||||
CollectionItems = [],
|
||||
TraktListItems = [],
|
||||
MovieMetadata = [MakeMovieMetadata(title, poster)],
|
||||
MediaVersions = [new MediaVersion { Duration = duration }]
|
||||
};
|
||||
|
||||
private static Season MakeSeason(int id, LibraryPath path, Show show, int seasonNumber) =>
|
||||
new()
|
||||
{
|
||||
Id = id,
|
||||
LibraryPath = path,
|
||||
Show = show,
|
||||
SeasonNumber = seasonNumber,
|
||||
Collections = [],
|
||||
CollectionItems = [],
|
||||
TraktListItems = [],
|
||||
Episodes = [],
|
||||
SeasonMetadata = [MakeSeasonMetadata()]
|
||||
};
|
||||
|
||||
private static Episode MakeEpisode(int id, LibraryPath path, Season season) =>
|
||||
new()
|
||||
{
|
||||
Id = id,
|
||||
LibraryPath = path,
|
||||
Season = season,
|
||||
Collections = [],
|
||||
CollectionItems = [],
|
||||
TraktListItems = [],
|
||||
EpisodeMetadata = [],
|
||||
MediaVersions = []
|
||||
};
|
||||
|
||||
private static MusicVideo MakeMusicVideo(int id, LibraryPath path, Artist artist) =>
|
||||
new()
|
||||
{
|
||||
Id = id,
|
||||
LibraryPath = path,
|
||||
Artist = artist,
|
||||
Collections = [],
|
||||
CollectionItems = [],
|
||||
TraktListItems = [],
|
||||
MusicVideoMetadata = [],
|
||||
MediaVersions = []
|
||||
};
|
||||
|
||||
private static MovieMetadata MakeMovieMetadata(string title, string poster) =>
|
||||
new()
|
||||
{
|
||||
@@ -281,4 +632,34 @@ public class GetLibraryBrowseItemsHandlerTests
|
||||
Guids = [],
|
||||
Subtitles = []
|
||||
};
|
||||
|
||||
private static SeasonMetadata MakeSeasonMetadata() =>
|
||||
new()
|
||||
{
|
||||
Title = string.Empty,
|
||||
SortTitle = string.Empty,
|
||||
Artwork = [],
|
||||
Genres = [],
|
||||
Tags = [],
|
||||
Studios = [],
|
||||
Actors = [],
|
||||
Guids = [],
|
||||
Subtitles = []
|
||||
};
|
||||
|
||||
private static ArtistMetadata MakeArtistMetadata(string title, string thumbnail) =>
|
||||
new()
|
||||
{
|
||||
Title = title,
|
||||
SortTitle = title,
|
||||
Artwork = [new Artwork { Path = thumbnail, ArtworkKind = ArtworkKind.Thumbnail }],
|
||||
Genres = [],
|
||||
Tags = [],
|
||||
Studios = [],
|
||||
Actors = [],
|
||||
Guids = [],
|
||||
Subtitles = [],
|
||||
Styles = [],
|
||||
Moods = []
|
||||
};
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ public class LibraryBrowseController(IMediator mediator) : ControllerBase
|
||||
private const int MaxPageSize = 100;
|
||||
|
||||
[HttpGet("/api/library/browse", Name = "BrowseLibrary")]
|
||||
[Tags("Library")]
|
||||
[Tags("Libraries")]
|
||||
[EndpointSummary("Browse and search library items")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(PagedLibraryBrowseItemsResponseModel), StatusCodes.Status200OK)]
|
||||
|
||||
@@ -1998,7 +1998,7 @@
|
||||
"/api/library/browse": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Library"
|
||||
"Libraries"
|
||||
],
|
||||
"summary": "Browse and search library items",
|
||||
"operationId": "BrowseLibrary",
|
||||
@@ -7436,9 +7436,6 @@
|
||||
{
|
||||
"name": "Libraries"
|
||||
},
|
||||
{
|
||||
"name": "Library"
|
||||
},
|
||||
{
|
||||
"name": "Maintenance"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user