diff options
Diffstat (limited to 'tests/Jellyfin.Server.Implementations.Tests/Item/ItemCountServiceTests.cs')
| -rw-r--r-- | tests/Jellyfin.Server.Implementations.Tests/Item/ItemCountServiceTests.cs | 728 |
1 files changed, 726 insertions, 2 deletions
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/ItemCountServiceTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/ItemCountServiceTests.cs index fea743f08e..ff683dc57a 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Item/ItemCountServiceTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Item/ItemCountServiceTests.cs @@ -1,8 +1,11 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.Linq; +using Jellyfin.Data.Enums; using Jellyfin.Database.Implementations; using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.Enums; using Jellyfin.Database.Implementations.Locking; using Jellyfin.Database.Providers.Sqlite; using Jellyfin.Server.Implementations.Item; @@ -12,6 +15,7 @@ using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Persistence; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Moq; using Xunit; @@ -25,6 +29,8 @@ public sealed class ItemCountServiceTests : IDisposable private readonly DbContextOptions<JellyfinDbContext> _dbOptions; private readonly IApplicationPaths _applicationPaths; private readonly ItemCountService _service; + private int _contextsCreated; + private List<string>? _capturedSql; public ItemCountServiceTests() { @@ -35,6 +41,7 @@ public sealed class ItemCountServiceTests : IDisposable _dbOptions = new DbContextOptionsBuilder<JellyfinDbContext>() .UseSqlite(_connection) + .LogTo(CaptureStatement, LogLevel.Information) .Options; using (var context = CreateDbContext()) @@ -43,7 +50,11 @@ public sealed class ItemCountServiceTests : IDisposable } var factory = new Mock<IDbContextFactory<JellyfinDbContext>>(); - factory.Setup(f => f.CreateDbContext()).Returns(CreateDbContext); + factory.Setup(f => f.CreateDbContext()).Returns(() => + { + _contextsCreated++; + return CreateDbContext(); + }); var queryHelpers = new Mock<IItemQueryHelpers>(); queryHelpers @@ -53,9 +64,25 @@ public sealed class ItemCountServiceTests : IDisposable It.IsAny<InternalItemsQuery>())) .Returns((JellyfinDbContext _, IQueryable<BaseItemEntity> query, InternalItemsQuery _) => query); + var typeLookup = new Mock<IItemTypeLookup>(); + typeLookup.Setup(l => l.BaseItemKindNames).Returns(new Dictionary<BaseItemKind, string> + { + [BaseItemKind.Movie] = "Movie", + [BaseItemKind.Series] = "Series", + [BaseItemKind.Episode] = "Episode", + [BaseItemKind.MusicAlbum] = "MusicAlbum", + [BaseItemKind.MusicArtist] = "MusicArtist", + [BaseItemKind.MusicVideo] = "MusicVideo", + [BaseItemKind.Audio] = "Audio", + [BaseItemKind.Trailer] = "Trailer", + [BaseItemKind.BoxSet] = "BoxSet", + [BaseItemKind.Book] = "Book", + [BaseItemKind.LiveTvProgram] = "LiveTvProgram" + }); + _service = new ItemCountService( factory.Object, - new Mock<IItemTypeLookup>().Object, + typeLookup.Object, queryHelpers.Object); } @@ -64,6 +91,14 @@ public sealed class ItemCountServiceTests : IDisposable _connection.Dispose(); } + private void CaptureStatement(string message) + { + if (_capturedSql is not null && message.Contains("SELECT", StringComparison.Ordinal)) + { + _capturedSql.Add(message[message.IndexOf("SELECT", StringComparison.Ordinal)..]); + } + } + [Fact] public void GetChildCountBatch_LargeParentIdSet_DoesNotExceedSqliteVariableLimit() { @@ -335,6 +370,695 @@ public sealed class ItemCountServiceTests : IDisposable }; } + [Fact] + public void GetItemCountsForNameItems_MatchesCountingEachNameItemOnItsOwn() + { + // Three genres tagging a different number of movies each, plus one tagging nothing. + var genres = SeedGenres(); + + var filter = new InternalItemsQuery(); + BaseItemKind[] related = [BaseItemKind.Movie, BaseItemKind.Series]; + + var batch = _service.GetItemCountsForNameItems(BaseItemKind.Genre, genres, related, filter); + + // Every requested id is answered, so a caller can index the result without checking. + Assert.Equal(genres.Count, batch.Count); + + foreach (var genreId in genres) + { + var single = _service.GetItemCountsForNameItem(BaseItemKind.Genre, genreId, related, filter); + + Assert.Equal(single.MovieCount, batch[genreId].MovieCount); + Assert.Equal(single.SeriesCount, batch[genreId].SeriesCount); + Assert.Equal(single.ItemCount, batch[genreId].ItemCount); + } + + // And the counts are the seeded ones rather than all zero, which would match trivially. + Assert.Equal([3, 2, 1, 0], genres.Select(g => batch[g].MovieCount).ToArray()); + } + + [Fact] + public void GetItemCountsForNameItems_UnknownId_CountsZero() + { + var unknown = Guid.NewGuid(); + + var batch = _service.GetItemCountsForNameItems( + BaseItemKind.Genre, + [unknown], + [BaseItemKind.Movie], + new InternalItemsQuery()); + + Assert.Equal(0, batch[unknown].ItemCount); + } + + [Fact] + public void GetItemCountsForNameItems_ArtistTaggedTwiceOnOneAlbum_CountsTheAlbumOnce() + { + // An album whose artist is also its album artist maps to the same artist twice. + var artistId = SeedArtistWithAlbum(); + + var filter = new InternalItemsQuery(); + BaseItemKind[] related = [BaseItemKind.MusicAlbum]; + + var batch = _service.GetItemCountsForNameItems(BaseItemKind.MusicArtist, [artistId], related, filter); + var single = _service.GetItemCountsForNameItem(BaseItemKind.MusicArtist, artistId, related, filter); + + Assert.Equal(1, batch[artistId].AlbumCount); + Assert.Equal(single.AlbumCount, batch[artistId].AlbumCount); + Assert.Equal(single.ItemCount, batch[artistId].ItemCount); + } + + /// <summary> + /// Seeds one artist and a single album tagged with it as both artist and album artist. + /// </summary> + /// <returns>The id of the seeded artist.</returns> + private Guid SeedArtistWithAlbum() + { + const string Name = "artist-0"; + var artistId = Guid.NewGuid(); + var albumId = Guid.NewGuid(); + + using var context = CreateDbContext(); + + var artist = CreateItem(artistId); + artist.Type = "MusicArtist"; + artist.Name = Name; + artist.CleanName = Name; + context.BaseItems.Add(artist); + + var album = CreateItem(albumId); + album.Type = "MusicAlbum"; + context.BaseItems.Add(album); + context.SaveChanges(); + + foreach (var type in new[] { ItemValueType.Artist, ItemValueType.AlbumArtist }) + { + var itemValue = new ItemValue + { + ItemValueId = Guid.NewGuid(), + Type = type, + Value = Name, + CleanValue = Name + }; + context.ItemValues.Add(itemValue); + context.SaveChanges(); + + context.ItemValuesMap.Add(new ItemValueMap + { + ItemId = albumId, + ItemValueId = itemValue.ItemValueId, + Item = null!, + ItemValue = null! + }); + } + + context.SaveChanges(); + + return artistId; + } + + [Fact] + public void GetItemCountsForNameItems_LargeIdSet_DoesNotExceedSqliteVariableLimit() + { + // Seeded rather than random, so the clean names of every one of them reach the second + // query's IN list and the join behind it, instead of stopping at the empty-name return. + var seeded = SeedArtists(50, out var taggedArtistId); + + var ids = seeded.Concat(Enumerable.Range(0, 40_000).Select(_ => Guid.NewGuid())).ToList(); + + var batch = _service.GetItemCountsForNameItems( + BaseItemKind.MusicArtist, + ids, + [BaseItemKind.MusicAlbum], + new InternalItemsQuery()); + + Assert.Equal(ids.Count, batch.Count); + + // And the grouped query really ran, rather than every id coming back zeroed. + Assert.Equal(1, batch[taggedArtistId].AlbumCount); + } + + [Fact] + public void GetItemCountsForNameItems_QueryShape_DoesNotVaryWithBatchSize() + { + // Every id list has to be bound as one parameter rather than one placeholder each: that is + // what keeps the statement off the SQLite variable ceiling and out of a per-size entry in + // EF's compiled query cache. Identical SQL for two batch sizes is exactly that property. + var seeded = SeedArtists(6, out _); + + var small = CaptureSql(() => _service.GetItemCountsForNameItems( + BaseItemKind.MusicArtist, seeded.Take(2).ToList(), [BaseItemKind.MusicAlbum], new InternalItemsQuery())); + + var large = CaptureSql(() => _service.GetItemCountsForNameItems( + BaseItemKind.MusicArtist, seeded, [BaseItemKind.MusicAlbum], new InternalItemsQuery())); + + Assert.NotEmpty(small); + Assert.Equal(small, large); + } + + private List<string> CaptureSql(Action action) + { + _capturedSql = []; + try + { + action(); + return _capturedSql; + } + finally + { + _capturedSql = null; + } + } + + /// <summary> + /// Seeds the requested number of artists, each with a clean name of its own, one of which is + /// credited on a single album. + /// </summary> + /// <param name="count">The number of artists to seed.</param> + /// <param name="taggedArtistId">The id of the artist credited on an album.</param> + /// <returns>The ids of the seeded artists.</returns> + private List<Guid> SeedArtists(int count, out Guid taggedArtistId) + { + var ids = new List<Guid>(count); + using var context = CreateDbContext(); + + ItemValue? taggedValue = null; + taggedArtistId = Guid.Empty; + + for (var i = 0; i < count; i++) + { + var name = "bulk-artist-" + i.ToString(CultureInfo.InvariantCulture); + var artistId = Guid.NewGuid(); + ids.Add(artistId); + + var artist = CreateItem(artistId); + artist.Type = "MusicArtist"; + artist.Name = name; + artist.CleanName = name; + context.BaseItems.Add(artist); + + if (i == 0) + { + taggedArtistId = artistId; + taggedValue = new ItemValue + { + ItemValueId = Guid.NewGuid(), + Type = ItemValueType.Artist, + Value = name, + CleanValue = name + }; + context.ItemValues.Add(taggedValue); + } + } + + context.SaveChanges(); + + var albumId = Guid.NewGuid(); + var album = CreateItem(albumId); + album.Type = "MusicAlbum"; + context.BaseItems.Add(album); + context.SaveChanges(); + + Tag(context, albumId, taggedValue!.ItemValueId); + context.SaveChanges(); + + return ids; + } + + [Fact] + public void GetItemCountsForNameItems_KindWithoutItemValues_FallsBackToTheSingleItemPath() + { + // Year is keyed by ProductionYear rather than a cleaned item value, so it cannot be grouped. + var yearId = Guid.NewGuid(); + + using (var context = CreateDbContext()) + { + var year = CreateItem(yearId); + year.Type = "Year"; + year.Name = "2001"; + year.CleanName = "2001"; + context.BaseItems.Add(year); + + for (var i = 0; i < 2; i++) + { + var movie = CreateItem(Guid.NewGuid()); + movie.Type = "Movie"; + movie.IsFolder = false; + movie.ProductionYear = 2001; + context.BaseItems.Add(movie); + } + + context.SaveChanges(); + } + + var filter = new InternalItemsQuery(); + BaseItemKind[] related = [BaseItemKind.Movie]; + + var batch = _service.GetItemCountsForNameItems(BaseItemKind.Year, [yearId], related, filter); + var single = _service.GetItemCountsForNameItem(BaseItemKind.Year, yearId, related, filter); + + Assert.Equal(2, batch[yearId].MovieCount); + Assert.Equal(single.MovieCount, batch[yearId].MovieCount); + } + + [Fact] + public void GetItemCountsForNameItems_PeopleAndYears_AreBatchedToo() + { + var (personIds, yearIds) = SeedPeopleAndYears(); + + var filter = new InternalItemsQuery(); + BaseItemKind[] related = [BaseItemKind.Movie]; + + foreach (var (kind, ids) in new[] { (BaseItemKind.Person, personIds), (BaseItemKind.Year, yearIds) }) + { + var contextsBefore = _contextsCreated; + var batch = _service.GetItemCountsForNameItems(kind, ids, related, filter); + + // These two used to be answered one query per id; only the value keyed kinds batched. + Assert.Equal(1, _contextsCreated - contextsBefore); + + Assert.Equal(ids.Count, batch.Count); + Assert.Equal(2, batch[ids[0]].MovieCount); + Assert.Equal(1, batch[ids[1]].MovieCount); + + foreach (var id in ids) + { + var single = _service.GetItemCountsForNameItem(kind, id, related, filter); + Assert.Equal(single.MovieCount, batch[id].MovieCount); + Assert.Equal(single.ItemCount, batch[id].ItemCount); + } + } + } + + /// <summary> + /// Seeds two people and two years, the first of each on two movies and the second on one. + /// </summary> + /// <returns>The ids of the seeded people and years.</returns> + private (List<Guid> PersonIds, List<Guid> YearIds) SeedPeopleAndYears() + { + var personIds = new List<Guid>(); + var yearIds = new List<Guid>(); + + using var context = CreateDbContext(); + + for (var i = 0; i < 2; i++) + { + var personName = "person-" + i.ToString(CultureInfo.InvariantCulture); + var personId = Guid.NewGuid(); + personIds.Add(personId); + + var person = CreateItem(personId); + person.Type = "Person"; + person.Name = personName; + person.CleanName = personName; + context.BaseItems.Add(person); + + var people = new People { Id = Guid.NewGuid(), Name = personName }; + context.Peoples.Add(people); + + var year = 2000 + i; + var yearId = Guid.NewGuid(); + yearIds.Add(yearId); + + var yearItem = CreateItem(yearId); + yearItem.Type = "Year"; + yearItem.Name = year.ToString(CultureInfo.InvariantCulture); + yearItem.CleanName = yearItem.Name; + context.BaseItems.Add(yearItem); + context.SaveChanges(); + + // Two movies for the first of each, one for the second. + for (var m = 0; m < 2 - i; m++) + { + var movieId = Guid.NewGuid(); + var movie = CreateItem(movieId); + movie.Type = "Movie"; + movie.IsFolder = false; + movie.ProductionYear = year; + context.BaseItems.Add(movie); + context.SaveChanges(); + + context.PeopleBaseItemMap.Add(new PeopleBaseItemMap + { + ItemId = movieId, + PeopleId = people.Id, + Item = null!, + People = null!, + Role = "Actor", + ListOrder = m, + SortOrder = m + }); + } + + context.SaveChanges(); + } + + return (personIds, yearIds); + } + + [Theory] + // The set the by-name listing actually asks for: it rolls the episodes of a tagged series up + // into the genre, which is the case the batch has to reproduce query for query. + [InlineData(BaseItemKind.Episode, BaseItemKind.Series, BaseItemKind.Movie)] + // And the same seeded data without the roll-up, which takes the plain grouped path. + [InlineData(BaseItemKind.Movie, BaseItemKind.Series, BaseItemKind.MusicAlbum)] + public void GetItemCountsForNameItems_TaggedSeriesAndEpisodes_MatchesCountingEachNameItemOnItsOwn( + BaseItemKind first, + BaseItemKind second, + BaseItemKind third) + { + var genres = SeedGenresTaggingSeriesAndEpisodes(); + + var filter = new InternalItemsQuery(); + BaseItemKind[] related = [first, second, third]; + + var batch = _service.GetItemCountsForNameItems(BaseItemKind.Genre, genres, related, filter); + + Assert.Equal(genres.Count, batch.Count); + + foreach (var genreId in genres) + { + var single = _service.GetItemCountsForNameItem(BaseItemKind.Genre, genreId, related, filter); + + Assert.Equal(single.EpisodeCount, batch[genreId].EpisodeCount); + Assert.Equal(single.SeriesCount, batch[genreId].SeriesCount); + Assert.Equal(single.MovieCount, batch[genreId].MovieCount); + Assert.Equal(single.ItemCount, batch[genreId].ItemCount); + } + } + + [Fact] + public void GetItemCountsForNameItems_TaggedSeries_RollsEpisodesUpIntoTheGenre() + { + var genres = SeedGenresTaggingSeriesAndEpisodes(); + + var contextsBefore = _contextsCreated; + + var batch = _service.GetItemCountsForNameItems( + BaseItemKind.Genre, + genres, + [BaseItemKind.Episode, BaseItemKind.Series, BaseItemKind.Movie], + new InternalItemsQuery()); + + // The whole point of the batch: one context for every genre on the page, not one each. + // The roll-up used to force this shape back onto the single item path. + Assert.Equal(1, _contextsCreated - contextsBefore); + + // "rolled": one tagged series of two episodes, one of which carries the genre itself, plus + // a loose tagged episode of an untagged series. The tagged episode of the tagged series + // must not be counted twice. + Assert.Equal(3, batch[genres[0]].EpisodeCount); + Assert.Equal(1, batch[genres[0]].SeriesCount); + + // "loose": a tagged episode whose series carries no genre at all. + Assert.Equal(1, batch[genres[1]].EpisodeCount); + Assert.Equal(0, batch[genres[1]].SeriesCount); + + // "empty": tags nothing. + Assert.Equal(0, batch[genres[2]].EpisodeCount); + } + + [Fact] + public void GetItemCountsForNameItems_EpisodeAndItsSeriesTaggedDifferently_KeepsTheGenresApart() + { + var seriesId = Guid.NewGuid(); + var episodeId = Guid.NewGuid(); + var genreIds = new List<Guid>(); + + using (var context = CreateDbContext()) + { + var values = new Dictionary<string, Guid>(StringComparer.Ordinal); + foreach (var name in new[] { "on-series", "on-episode" }) + { + var genreId = Guid.NewGuid(); + genreIds.Add(genreId); + + var genre = CreateItem(genreId); + genre.Type = "Genre"; + genre.Name = name; + genre.CleanName = name; + context.BaseItems.Add(genre); + + var itemValue = new ItemValue + { + ItemValueId = Guid.NewGuid(), + Type = ItemValueType.Genre, + Value = name, + CleanValue = name + }; + context.ItemValues.Add(itemValue); + values[name] = itemValue.ItemValueId; + } + + var series = CreateItem(seriesId); + series.Type = "Series"; + context.BaseItems.Add(series); + context.BaseItems.Add(CreateEpisode(episodeId, seriesId)); + context.SaveChanges(); + + Tag(context, seriesId, values["on-series"]); + Tag(context, episodeId, values["on-episode"]); + context.SaveChanges(); + } + + var filter = new InternalItemsQuery(); + BaseItemKind[] related = [BaseItemKind.Episode, BaseItemKind.Series, BaseItemKind.Movie]; + + var batch = _service.GetItemCountsForNameItems(BaseItemKind.Genre, genreIds, related, filter); + + // The episode rolls up into the genre on its series. + Assert.Equal(1, batch[genreIds[0]].EpisodeCount); + + // Its own genre is carried by no series, so the episode stays a direct count there. Keyed + // on the series id alone the episode would be subtracted here and this would read 0. + Assert.Equal(1, batch[genreIds[1]].EpisodeCount); + Assert.Equal(0, batch[genreIds[1]].SeriesCount); + + foreach (var genreId in genreIds) + { + var single = _service.GetItemCountsForNameItem(BaseItemKind.Genre, genreId, related, filter); + Assert.Equal(single.EpisodeCount, batch[genreId].EpisodeCount); + Assert.Equal(single.ItemCount, batch[genreId].ItemCount); + } + } + + [Fact] + public void GetItemCountsForNameItems_TwoNameItemsSharingACleanName_BothGetTheCounts() + { + // Distinct rows cleaning down to one name are what the batch keys on; the unique index + // permits them, so two genre items can legitimately share a clean name. + var firstId = Guid.NewGuid(); + var secondId = Guid.NewGuid(); + var movieId = Guid.NewGuid(); + + using (var context = CreateDbContext()) + { + foreach (var (id, name) in new[] { (firstId, "Sci-Fi"), (secondId, "SCI-FI") }) + { + var genre = CreateItem(id); + genre.Type = "Genre"; + genre.Name = name; + genre.CleanName = "sci-fi"; + context.BaseItems.Add(genre); + } + + var movie = CreateItem(movieId); + movie.Type = "Movie"; + movie.IsFolder = false; + context.BaseItems.Add(movie); + context.SaveChanges(); + + foreach (var name in new[] { "Sci-Fi", "SCI-FI" }) + { + var itemValue = new ItemValue + { + ItemValueId = Guid.NewGuid(), + Type = ItemValueType.Genre, + Value = name, + CleanValue = "sci-fi" + }; + context.ItemValues.Add(itemValue); + context.SaveChanges(); + Tag(context, movieId, itemValue.ItemValueId); + } + + context.SaveChanges(); + } + + var filter = new InternalItemsQuery(); + BaseItemKind[] related = [BaseItemKind.Movie]; + + var batch = _service.GetItemCountsForNameItems(BaseItemKind.Genre, [firstId, secondId], related, filter); + + // One movie, reached through two value rows: counted once for each genre item, not twice. + Assert.Equal(1, batch[firstId].MovieCount); + Assert.Equal(1, batch[secondId].MovieCount); + + foreach (var genreId in new[] { firstId, secondId }) + { + var single = _service.GetItemCountsForNameItem(BaseItemKind.Genre, genreId, related, filter); + Assert.Equal(single.MovieCount, batch[genreId].MovieCount); + } + } + + /// <summary> + /// Seeds three genres: one tagging a series whose episodes roll up (one of them tagged too) + /// plus a loose episode, one tagging only an episode of an untagged series, and one tagging + /// nothing. + /// </summary> + /// <returns>The ids of the seeded genres, in that order.</returns> + private List<Guid> SeedGenresTaggingSeriesAndEpisodes() + { + var genreIds = new List<Guid>(); + + using var context = CreateDbContext(); + + var values = new Dictionary<string, Guid>(StringComparer.Ordinal); + foreach (var name in new[] { "rolled", "loose", "empty" }) + { + var genreId = Guid.NewGuid(); + genreIds.Add(genreId); + + var genre = CreateItem(genreId); + genre.Type = "Genre"; + genre.Name = name; + genre.CleanName = name; + context.BaseItems.Add(genre); + + var itemValue = new ItemValue + { + ItemValueId = Guid.NewGuid(), + Type = ItemValueType.Genre, + Value = name, + CleanValue = name + }; + context.ItemValues.Add(itemValue); + values[name] = itemValue.ItemValueId; + } + + context.SaveChanges(); + + // A series tagged "rolled" holding two episodes; the second carries "rolled" itself, so the + // roll-up and the direct tag both see it. + var taggedSeriesId = Guid.NewGuid(); + var taggedSeries = CreateItem(taggedSeriesId); + taggedSeries.Type = "Series"; + context.BaseItems.Add(taggedSeries); + + var episodeOfTaggedSeries = CreateEpisode(Guid.NewGuid(), taggedSeriesId); + var taggedEpisodeOfTaggedSeries = CreateEpisode(Guid.NewGuid(), taggedSeriesId); + context.BaseItems.AddRange(episodeOfTaggedSeries, taggedEpisodeOfTaggedSeries); + + // An untagged series whose episode carries a genre on its own. + var untaggedSeriesId = Guid.NewGuid(); + var untaggedSeries = CreateItem(untaggedSeriesId); + untaggedSeries.Type = "Series"; + context.BaseItems.Add(untaggedSeries); + + var looseEpisode = CreateEpisode(Guid.NewGuid(), untaggedSeriesId); + var rolledLooseEpisode = CreateEpisode(Guid.NewGuid(), untaggedSeriesId); + context.BaseItems.AddRange(looseEpisode, rolledLooseEpisode); + + var movieId = Guid.NewGuid(); + var movie = CreateItem(movieId); + movie.Type = "Movie"; + movie.IsFolder = false; + context.BaseItems.Add(movie); + + context.SaveChanges(); + + Tag(context, taggedSeriesId, values["rolled"]); + Tag(context, taggedEpisodeOfTaggedSeries.Id, values["rolled"]); + Tag(context, rolledLooseEpisode.Id, values["rolled"]); + Tag(context, looseEpisode.Id, values["loose"]); + Tag(context, movieId, values["rolled"]); + + context.SaveChanges(); + + return genreIds; + } + + private static void Tag(JellyfinDbContext context, Guid itemId, Guid itemValueId) + { + context.ItemValuesMap.Add(new ItemValueMap + { + ItemId = itemId, + ItemValueId = itemValueId, + Item = null!, + ItemValue = null! + }); + } + + private static BaseItemEntity CreateEpisode(Guid id, Guid seriesId) + { + return new BaseItemEntity + { + Id = id, + Type = "Episode", + IsFolder = false, + IsVirtualItem = false, + ParentId = seriesId, + SeriesId = seriesId + }; + } + + /// <summary> + /// Seeds four genres tagging three, two, one and no movies, in that order. + /// </summary> + /// <returns>The ids of the seeded genres.</returns> + private List<Guid> SeedGenres() + { + var genreIds = new List<Guid>(); + + using var context = CreateDbContext(); + + for (var i = 0; i < 4; i++) + { + var name = "genre-" + i.ToString(CultureInfo.InvariantCulture); + var genreId = Guid.NewGuid(); + genreIds.Add(genreId); + + var genre = CreateItem(genreId); + genre.Type = "Genre"; + genre.Name = name; + genre.CleanName = name; + context.BaseItems.Add(genre); + + var itemValue = new ItemValue + { + ItemValueId = Guid.NewGuid(), + Type = ItemValueType.Genre, + Value = name, + CleanValue = name + }; + context.ItemValues.Add(itemValue); + context.SaveChanges(); + + // 3 movies for the first genre, 2 for the second, 1 for the third, none for the last. + for (var m = 0; m < 3 - i; m++) + { + var movieId = Guid.NewGuid(); + var movie = CreateItem(movieId); + movie.Type = "Movie"; + movie.IsFolder = false; + context.BaseItems.Add(movie); + context.SaveChanges(); + + context.ItemValuesMap.Add(new ItemValueMap + { + ItemId = movieId, + ItemValueId = itemValue.ItemValueId, + Item = null!, + ItemValue = null! + }); + } + + context.SaveChanges(); + } + + return genreIds; + } + private static BaseItemEntity CreateItem(Guid id, Guid? parentId = null) { return new BaseItemEntity |
