fix(api): address library browse review findings
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m3s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m36s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped

refs #65

refs PR #130
This commit is contained in:
2026-07-06 07:45:39 +02:00
parent 127a130737
commit 630119f382
4 changed files with 656 additions and 13 deletions
@@ -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)]
+1 -4
View File
@@ -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"
},