Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m23s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 13s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 10m31s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Episode cards in the SPA search and media-browse screens were inert (mediaDetailPath had no
Episode case, and LibraryBrowseItemResponseModel carried no parent-season id to route with).
- API: add nullable SeasonId to LibraryBrowseItemResponseModel; populate it in
LibraryBrowseItemMapper.GetEpisodes (the single shared hydration site used by both the
library-browse search/browse handler and the season episode drill-in), leave it null for
every other kind. Regenerated v1.json + v1.d.ts per docs/api-conventions.md §5.
- SPA: mediaDetailPath now routes Episode items with a seasonId to
/app/media/seasons/{seasonId}#episode-{id} (matching Blazor's Search.razor:241 link), null
otherwise. MediaPosterCard accepts an id/highlighted pair; SeasonDetailScreen's episode grid
gives each card a stable `episode-{id}` anchor and scrolls/highlights it on mount and on
hashchange (deep-link support).
- Tests: GetLibraryBrowseItemsHandlerTests asserts SeasonId is populated for episode drill-in
results and null for other kinds; web tests cover mediaDetailPath's episode cases and the
anchor/scroll/highlight behavior (jsdom scrollIntoView stub).
- Docs: blazor-route-parity.md's episode-browse row and the Search cluster verdict updated —
the standalone SPA episode browse exists and episode cards now navigate, closing the
adversarial-reviewer#18 finding.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
954 lines
35 KiB
C#
954 lines
35 KiB
C#
using ErsatzTV.Application.LibraryBrowse;
|
|
using ErsatzTV.Core.Api.LibraryBrowse;
|
|
using ErsatzTV.Core.Domain;
|
|
using ErsatzTV.Core.Interfaces.Metadata;
|
|
using ErsatzTV.Core.Interfaces.Repositories;
|
|
using ErsatzTV.Core.Interfaces.Search;
|
|
using ErsatzTV.Core.Search;
|
|
using ErsatzTV.Infrastructure.Data;
|
|
using ErsatzTV.Infrastructure.Search;
|
|
using ErsatzTV.Tests.Support;
|
|
using NSubstitute;
|
|
using NUnit.Framework;
|
|
using Shouldly;
|
|
|
|
namespace ErsatzTV.Tests.Application.LibraryBrowse;
|
|
|
|
[TestFixture]
|
|
public class GetLibraryBrowseItemsHandlerTests
|
|
{
|
|
private InMemoryTvContext _db = null!;
|
|
private ISearchIndex _searchIndex = null!;
|
|
|
|
[SetUp]
|
|
public async Task SetUp()
|
|
{
|
|
_db = await InMemoryTvContext.CreateAsync();
|
|
_searchIndex = Substitute.For<ISearchIndex>();
|
|
}
|
|
|
|
[TearDown]
|
|
public async Task TearDown() => await _db.DisposeAsync();
|
|
|
|
[Test]
|
|
public async Task Handle_Should_Search_Index_With_Type_Library_And_Title_Filters()
|
|
{
|
|
_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);
|
|
|
|
await handler.Handle(
|
|
new GetLibraryBrowseItems("star", 5, LibraryBrowseMediaType.Movie, 2, 25),
|
|
CancellationToken.None);
|
|
|
|
await _searchIndex.Received(1).Search(
|
|
Arg.Is<string>(q => q.Contains("type:movie") && q.Contains("library_id:5") && q.Contains("star")),
|
|
string.Empty,
|
|
50,
|
|
25,
|
|
Arg.Is<List<string>>(fields => fields.SequenceEqual(new[] { LuceneSearchIndex.TitleAndYearSearchField })),
|
|
Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[Test]
|
|
public async Task Handle_Should_Hydrate_Media_Items_In_Search_Order_With_Artwork_Duration_And_Counts()
|
|
{
|
|
await SeedLibraryGraph();
|
|
_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.ShowType, 20),
|
|
new SearchItem(LuceneSearchIndex.MovieType, 10)
|
|
],
|
|
2));
|
|
var handler = new GetLibraryBrowseItemsHandler(_searchIndex, _db.Factory);
|
|
|
|
PagedLibraryBrowseItemsResponseModel result = await handler.Handle(
|
|
new GetLibraryBrowseItems("", null, null, 0, 10),
|
|
CancellationToken.None);
|
|
|
|
result.TotalCount.ShouldBe(2);
|
|
result.Page[0].MediaType.ShouldBe(LibraryBrowseMediaType.TelevisionShow);
|
|
result.Page[0].Title.ShouldBe("Collision Show");
|
|
result.Page[0].ItemCount.ShouldBe(1);
|
|
result.Page[0].MediaItemId.ShouldBe(20);
|
|
result.Page[1].MediaType.ShouldBe(LibraryBrowseMediaType.Movie);
|
|
result.Page[1].Title.ShouldBe("Collision Movie");
|
|
result.Page[1].Artwork.ShouldBe("/artwork/posters/movie-poster.jpg");
|
|
result.Page[1].Duration.ShouldBe(TimeSpan.FromMinutes(95));
|
|
result.Page[1].MediaItemId.ShouldBe(10);
|
|
}
|
|
|
|
[Test]
|
|
public async Task Handle_Should_Return_Typed_Collection_And_Playlist_Picker_Targets()
|
|
{
|
|
await SeedCollectionGraph();
|
|
_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, null, 0, 10),
|
|
CancellationToken.None);
|
|
|
|
result.TotalCount.ShouldBe(5);
|
|
result.Page.ShouldContain(i =>
|
|
i.MediaType == LibraryBrowseMediaType.Collection &&
|
|
i.CollectionKind == "Manual" &&
|
|
i.CollectionType == CollectionType.Collection &&
|
|
i.CollectionId == 20 &&
|
|
i.ItemCount == 1);
|
|
result.Page.ShouldContain(i =>
|
|
i.MediaType == LibraryBrowseMediaType.SmartCollection &&
|
|
i.CollectionKind == "Smart" &&
|
|
i.CollectionType == CollectionType.SmartCollection &&
|
|
i.SmartCollectionId == 30);
|
|
result.Page.ShouldContain(i =>
|
|
i.MediaType == LibraryBrowseMediaType.MultiCollection &&
|
|
i.CollectionKind == "Multi" &&
|
|
i.CollectionType == CollectionType.MultiCollection &&
|
|
i.MultiCollectionId == 40);
|
|
result.Page.ShouldContain(i =>
|
|
i.MediaType == LibraryBrowseMediaType.RerunCollection &&
|
|
i.CollectionKind == "Rerun" &&
|
|
i.CollectionType == CollectionType.RerunFirstRun &&
|
|
i.RerunCollectionId == 50);
|
|
result.Page.ShouldContain(i =>
|
|
i.MediaType == LibraryBrowseMediaType.Playlist &&
|
|
i.CollectionType == CollectionType.Playlist &&
|
|
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("/artwork/posters/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);
|
|
// Season 301 has its own poster.
|
|
result.Page[0].Artwork.ShouldBe("/artwork/posters/season-2.jpg");
|
|
result.Page[1].Title.ShouldBe("Season Show - Specials");
|
|
result.Page[1].ItemCount.ShouldBe(1);
|
|
result.Page[1].MediaItemId.ShouldBe(302);
|
|
// Specials has no poster of its own, so it falls back to the show's poster.
|
|
result.Page[1].Artwork.ShouldBe("/artwork/posters/show.jpg");
|
|
}
|
|
|
|
[Test]
|
|
public async Task Handle_Should_Browse_Seasons_For_A_Specific_Show_By_ParentId()
|
|
{
|
|
await SeedSeasonGraph();
|
|
// ParentId drill-in bypasses the search index entirely.
|
|
var handler = new GetLibraryBrowseItemsHandler(_searchIndex, _db.Factory);
|
|
|
|
PagedLibraryBrowseItemsResponseModel result = await handler.Handle(
|
|
new GetLibraryBrowseItems("", null, LibraryBrowseMediaType.TelevisionSeason, 0, 10, 300),
|
|
CancellationToken.None);
|
|
|
|
result.TotalCount.ShouldBe(2);
|
|
// Ordered by season number: Specials (0) then Season 2.
|
|
result.Page[0].Title.ShouldBe("Season Show - Specials");
|
|
result.Page[0].MediaItemId.ShouldBe(302);
|
|
result.Page[1].Title.ShouldBe("Season Show - Season 2");
|
|
result.Page[1].MediaItemId.ShouldBe(301);
|
|
|
|
await _searchIndex.DidNotReceive().Search(
|
|
Arg.Any<string>(),
|
|
Arg.Any<string>(),
|
|
Arg.Any<int>(),
|
|
Arg.Any<int>(),
|
|
Arg.Any<List<string>>(),
|
|
Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[Test]
|
|
public async Task Handle_Should_Browse_Episodes_For_A_Specific_Season_By_ParentId()
|
|
{
|
|
await SeedEpisodeDrillInGraph();
|
|
var handler = new GetLibraryBrowseItemsHandler(_searchIndex, _db.Factory);
|
|
|
|
PagedLibraryBrowseItemsResponseModel result = await handler.Handle(
|
|
new GetLibraryBrowseItems("", null, LibraryBrowseMediaType.Episode, 0, 10, 701),
|
|
CancellationToken.None);
|
|
|
|
result.TotalCount.ShouldBe(3);
|
|
// Ordered by episode number regardless of insertion order.
|
|
result.Page[0].MediaItemId.ShouldBe(712);
|
|
result.Page[1].MediaItemId.ShouldBe(711);
|
|
result.Page[2].MediaItemId.ShouldBe(713);
|
|
|
|
// #220: every episode carries its parent season id so the SPA can route to the season
|
|
// detail page and anchor to the episode (`/app/media/seasons/{seasonId}#episode-{id}`).
|
|
result.Page.ShouldAllBe(p => p.SeasonId == 701);
|
|
|
|
await _searchIndex.DidNotReceive().Search(
|
|
Arg.Any<string>(),
|
|
Arg.Any<string>(),
|
|
Arg.Any<int>(),
|
|
Arg.Any<int>(),
|
|
Arg.Any<List<string>>(),
|
|
Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[Test]
|
|
public async Task Handle_Should_Leave_SeasonId_Null_For_Non_Episode_Kinds()
|
|
{
|
|
await SeedLibraryGraph();
|
|
_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.ShowType, 20),
|
|
new SearchItem(LuceneSearchIndex.MovieType, 10)
|
|
],
|
|
2));
|
|
var handler = new GetLibraryBrowseItemsHandler(_searchIndex, _db.Factory);
|
|
|
|
PagedLibraryBrowseItemsResponseModel result = await handler.Handle(
|
|
new GetLibraryBrowseItems("", null, null, 0, 10),
|
|
CancellationToken.None);
|
|
|
|
result.Page.ShouldAllBe(p => p.SeasonId == null);
|
|
}
|
|
|
|
[Test]
|
|
public async Task Handle_Should_Browse_Music_Videos_For_A_Specific_Artist_By_ParentId()
|
|
{
|
|
await SeedMusicVideoDrillInGraph();
|
|
var handler = new GetLibraryBrowseItemsHandler(_searchIndex, _db.Factory);
|
|
|
|
PagedLibraryBrowseItemsResponseModel result = await handler.Handle(
|
|
new GetLibraryBrowseItems("", null, LibraryBrowseMediaType.MusicVideo, 0, 10, 801),
|
|
CancellationToken.None);
|
|
|
|
result.TotalCount.ShouldBe(2);
|
|
// Ordered by album then track: "Album A"/track 1 before "Album B"/track 1.
|
|
result.Page[0].MediaItemId.ShouldBe(812);
|
|
result.Page[1].MediaItemId.ShouldBe(811);
|
|
|
|
await _searchIndex.DidNotReceive().Search(
|
|
Arg.Any<string>(),
|
|
Arg.Any<string>(),
|
|
Arg.Any<int>(),
|
|
Arg.Any<int>(),
|
|
Arg.Any<List<string>>(),
|
|
Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[Test]
|
|
public async Task Handle_Should_Root_Jellyfin_Artwork_Urls_With_FillHeight()
|
|
{
|
|
await SeedJellyfinMovieGraph();
|
|
_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.MovieType, 601)], 1));
|
|
var handler = new GetLibraryBrowseItemsHandler(_searchIndex, _db.Factory);
|
|
|
|
PagedLibraryBrowseItemsResponseModel result = await handler.Handle(
|
|
new GetLibraryBrowseItems("", null, LibraryBrowseMediaType.Movie, 0, 10),
|
|
CancellationToken.None);
|
|
|
|
result.TotalCount.ShouldBe(1);
|
|
result.Page[0].Artwork.ShouldBe("/artwork/posters/jellyfin/Items/abc/Images/Primary?tag=xyz&fillHeight=440");
|
|
}
|
|
|
|
[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("/artwork/thumbnails/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();
|
|
var library = new LocalLibrary
|
|
{
|
|
Id = 1,
|
|
Name = "Local Movies",
|
|
MediaKind = LibraryMediaKind.Movies,
|
|
Paths = []
|
|
};
|
|
var path = new LibraryPath
|
|
{
|
|
Id = 1,
|
|
Path = "/media",
|
|
Library = library,
|
|
LibraryFolders = [],
|
|
MediaItems = []
|
|
};
|
|
library.Paths.Add(path);
|
|
|
|
var movie = new Movie
|
|
{
|
|
Id = 10,
|
|
LibraryPath = path,
|
|
Collections = [],
|
|
CollectionItems = [],
|
|
TraktListItems = [],
|
|
MediaVersions = [new MediaVersion { Duration = TimeSpan.FromMinutes(95) }],
|
|
MovieMetadata =
|
|
[
|
|
MakeMovieMetadata("Collision Movie", "movie-poster.jpg")
|
|
]
|
|
};
|
|
var show = new Show
|
|
{
|
|
Id = 20,
|
|
LibraryPath = path,
|
|
Collections = [],
|
|
CollectionItems = [],
|
|
TraktListItems = [],
|
|
Seasons = [],
|
|
ShowMetadata = [MakeShowMetadata("Collision Show", "show-poster.jpg")]
|
|
};
|
|
var season = new Season
|
|
{
|
|
Id = 11,
|
|
LibraryPath = path,
|
|
Show = show,
|
|
Collections = [],
|
|
CollectionItems = [],
|
|
TraktListItems = [],
|
|
Episodes = [],
|
|
SeasonMetadata = []
|
|
};
|
|
var episode = new Episode
|
|
{
|
|
Id = 12,
|
|
LibraryPath = path,
|
|
Season = season,
|
|
Collections = [],
|
|
CollectionItems = [],
|
|
TraktListItems = [],
|
|
EpisodeMetadata = [],
|
|
MediaVersions = [new MediaVersion { Duration = TimeSpan.FromMinutes(25) }]
|
|
};
|
|
season.Episodes.Add(episode);
|
|
show.Seasons.Add(season);
|
|
path.MediaItems.AddRange([movie, show, season, episode]);
|
|
context.LocalLibraries.Add(library);
|
|
context.Movies.Add(movie);
|
|
context.Shows.Add(show);
|
|
context.Seasons.Add(season);
|
|
context.Episodes.Add(episode);
|
|
await context.SaveChangesAsync();
|
|
}
|
|
|
|
private async Task SeedCollectionGraph()
|
|
{
|
|
await using TvContext context = _db.CreateContext();
|
|
var collection = new Collection { Id = 20, Name = "Manual", MediaItems = [], CollectionItems = [] };
|
|
var movie = new Movie
|
|
{
|
|
Id = 21,
|
|
Collections = [collection],
|
|
CollectionItems = [],
|
|
TraktListItems = [],
|
|
MovieMetadata = [],
|
|
MediaVersions = []
|
|
};
|
|
collection.MediaItems.Add(movie);
|
|
var smart = new SmartCollection { Id = 30, Name = "Smart", Query = "tag:kids" };
|
|
var multi = new MultiCollection
|
|
{
|
|
Id = 40,
|
|
Name = "Multi",
|
|
Collections = [collection],
|
|
SmartCollections = [smart]
|
|
};
|
|
var rerun = new RerunCollection { Id = 50, Name = "Rerun", CollectionType = CollectionType.Collection };
|
|
var playlist = new Playlist
|
|
{
|
|
Id = 60,
|
|
Name = "Playlist",
|
|
Items = [new PlaylistItem { CollectionType = CollectionType.Collection, CollectionId = 20 }]
|
|
};
|
|
|
|
context.Collections.Add(collection);
|
|
context.Movies.Add(movie);
|
|
context.SmartCollections.Add(smart);
|
|
context.MultiCollections.Add(multi);
|
|
context.RerunCollections.Add(rerun);
|
|
context.Playlists.Add(playlist);
|
|
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, "season-2.jpg");
|
|
var specials = MakeSeason(302, path, show, 0, string.Empty);
|
|
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 SeedEpisodeDrillInGraph()
|
|
{
|
|
await using TvContext context = _db.CreateContext();
|
|
(LocalLibrary library, LibraryPath path) = MakeLibrary(700, "Episode Library");
|
|
var show = new Show
|
|
{
|
|
Id = 700,
|
|
LibraryPath = path,
|
|
Collections = [],
|
|
CollectionItems = [],
|
|
TraktListItems = [],
|
|
Seasons = [],
|
|
ShowMetadata = [MakeShowMetadata("Drill Show", "show.jpg")]
|
|
};
|
|
var season = MakeSeason(701, path, show, 1, "season.jpg");
|
|
|
|
// Inserted out of episode-number order to prove the handler sorts by number.
|
|
Episode e2 = MakeNumberedEpisode(711, path, season, 2);
|
|
Episode e1 = MakeNumberedEpisode(712, path, season, 1);
|
|
Episode e3 = MakeNumberedEpisode(713, path, season, 3);
|
|
season.Episodes.AddRange([e2, e1, e3]);
|
|
show.Seasons.Add(season);
|
|
path.MediaItems.AddRange([show, season, e2, e1, e3]);
|
|
|
|
context.LocalLibraries.Add(library);
|
|
context.Shows.Add(show);
|
|
context.Seasons.Add(season);
|
|
context.Episodes.AddRange(e2, e1, e3);
|
|
await context.SaveChangesAsync();
|
|
}
|
|
|
|
private async Task SeedMusicVideoDrillInGraph()
|
|
{
|
|
await using TvContext context = _db.CreateContext();
|
|
(LocalLibrary library, LibraryPath path) = MakeLibrary(800, "Music Video Library");
|
|
var artist = new Artist
|
|
{
|
|
Id = 801,
|
|
LibraryPath = path,
|
|
Collections = [],
|
|
CollectionItems = [],
|
|
TraktListItems = [],
|
|
MusicVideos = [],
|
|
ArtistMetadata = [MakeArtistMetadata("Drill Artist", "artist.jpg")]
|
|
};
|
|
|
|
MusicVideo mvB = MakeTitledMusicVideo(811, path, artist, "Track On B", "Album B", 1);
|
|
MusicVideo mvA = MakeTitledMusicVideo(812, path, artist, "Track On A", "Album A", 1);
|
|
artist.MusicVideos.AddRange([mvB, mvA]);
|
|
path.MediaItems.AddRange([artist, mvB, mvA]);
|
|
|
|
context.LocalLibraries.Add(library);
|
|
context.Artists.Add(artist);
|
|
context.MusicVideos.AddRange(mvB, mvA);
|
|
await context.SaveChangesAsync();
|
|
}
|
|
|
|
private static Episode MakeNumberedEpisode(int id, LibraryPath path, Season season, int episodeNumber)
|
|
{
|
|
Episode episode = MakeEpisode(id, path, season);
|
|
episode.EpisodeMetadata =
|
|
[
|
|
new EpisodeMetadata
|
|
{
|
|
Title = $"Episode {episodeNumber}",
|
|
SortTitle = $"Episode {episodeNumber}",
|
|
EpisodeNumber = episodeNumber,
|
|
Artwork = [],
|
|
Genres = [],
|
|
Tags = [],
|
|
Studios = [],
|
|
Actors = [],
|
|
Guids = [],
|
|
Subtitles = [],
|
|
Directors = [],
|
|
Writers = []
|
|
}
|
|
];
|
|
return episode;
|
|
}
|
|
|
|
private static MusicVideo MakeTitledMusicVideo(
|
|
int id,
|
|
LibraryPath path,
|
|
Artist artist,
|
|
string title,
|
|
string album,
|
|
int track)
|
|
{
|
|
MusicVideo musicVideo = MakeMusicVideo(id, path, artist);
|
|
musicVideo.MusicVideoMetadata =
|
|
[
|
|
new MusicVideoMetadata
|
|
{
|
|
Title = title,
|
|
SortTitle = title,
|
|
Album = album,
|
|
Track = track,
|
|
Artwork = [],
|
|
Genres = [],
|
|
Tags = [],
|
|
Studios = [],
|
|
Actors = [],
|
|
Guids = [],
|
|
Subtitles = []
|
|
}
|
|
];
|
|
return musicVideo;
|
|
}
|
|
|
|
private async Task SeedArtistGraph()
|
|
{
|
|
await using TvContext context = _db.CreateContext();
|
|
(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 SeedJellyfinMovieGraph()
|
|
{
|
|
await using TvContext context = _db.CreateContext();
|
|
(LocalLibrary library, LibraryPath path) = MakeLibrary(600, "Jellyfin Library");
|
|
var movie = new Movie
|
|
{
|
|
Id = 601,
|
|
LibraryPath = path,
|
|
Collections = [],
|
|
CollectionItems = [],
|
|
TraktListItems = [],
|
|
MediaVersions = [new MediaVersion { Duration = TimeSpan.FromMinutes(100) }],
|
|
MovieMetadata =
|
|
[
|
|
new MovieMetadata
|
|
{
|
|
Title = "Jellyfin Movie",
|
|
SortTitle = "Jellyfin Movie",
|
|
Artwork =
|
|
[
|
|
new Artwork
|
|
{
|
|
Path = "jellyfin://Items/abc/Images/Primary?tag=xyz",
|
|
ArtworkKind = ArtworkKind.Poster
|
|
}
|
|
],
|
|
Genres = [],
|
|
Tags = [],
|
|
Studios = [],
|
|
Actors = [],
|
|
Guids = [],
|
|
Subtitles = [],
|
|
Directors = [],
|
|
Writers = []
|
|
}
|
|
]
|
|
};
|
|
path.MediaItems.Add(movie);
|
|
|
|
context.LocalLibraries.Add(library);
|
|
context.Movies.Add(movie);
|
|
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, string poster) =>
|
|
new()
|
|
{
|
|
Id = id,
|
|
LibraryPath = path,
|
|
Show = show,
|
|
SeasonNumber = seasonNumber,
|
|
Collections = [],
|
|
CollectionItems = [],
|
|
TraktListItems = [],
|
|
Episodes = [],
|
|
SeasonMetadata = [MakeSeasonMetadata(poster)]
|
|
};
|
|
|
|
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()
|
|
{
|
|
Title = title,
|
|
SortTitle = title,
|
|
Artwork = [new Artwork { Path = poster, ArtworkKind = ArtworkKind.Poster }],
|
|
Genres = [],
|
|
Tags = [],
|
|
Studios = [],
|
|
Actors = [],
|
|
Guids = [],
|
|
Subtitles = [],
|
|
Directors = [],
|
|
Writers = []
|
|
};
|
|
|
|
private static ShowMetadata MakeShowMetadata(string title, string poster) =>
|
|
new()
|
|
{
|
|
Title = title,
|
|
SortTitle = title,
|
|
Artwork = [new Artwork { Path = poster, ArtworkKind = ArtworkKind.Poster }],
|
|
Genres = [],
|
|
Tags = [],
|
|
Studios = [],
|
|
Actors = [],
|
|
Guids = [],
|
|
Subtitles = []
|
|
};
|
|
|
|
private static SeasonMetadata MakeSeasonMetadata(string poster = "") =>
|
|
new()
|
|
{
|
|
Title = string.Empty,
|
|
SortTitle = string.Empty,
|
|
Artwork = string.IsNullOrEmpty(poster)
|
|
? []
|
|
: [new Artwork { Path = poster, ArtworkKind = ArtworkKind.Poster }],
|
|
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 = []
|
|
};
|
|
}
|