Merge remote-tracking branch 'upstream/master' into fix-series-merging

This commit is contained in:
Shadowghost
2026-08-22 17:18:01 +02:00
52 changed files with 4285 additions and 515 deletions
@@ -75,5 +75,28 @@ namespace Jellyfin.Extensions.Tests
var result = str.AsSpan().RightPart(needle).ToString();
Assert.Equal(expectedResult, result);
}
[Theory]
[InlineData("", "")]
[InlineData("/media/movies/Film.mkv", "/media/movies/Film.mkv")]
[InlineData(@"C:\media\movies\Film.mkv", @"C:\media\movies\Film.mkv")]
[InlineData(@"/media/a""b.mkv", @"/media/a\""b.mkv")]
[InlineData(@"/media/a\""b.mkv", @"/media/a\\\""b.mkv")]
[InlineData(@"/media/a\\""b.mkv", @"/media/a\\\\\""b.mkv")]
[InlineData(@"/media/a\b""c.mkv", @"/media/a\b\""c.mkv")]
[InlineData(@"/media/trailing\", @"/media/trailing\\")]
[InlineData(@"/media/evil\"" -f lavfi -i sine .mkv", @"/media/evil\\\"" -f lavfi -i sine .mkv")]
public void EscapeProcessArgument_ValidInput_Corrects(string input, string expectedResult)
{
Assert.Equal(expectedResult, input.EscapeProcessArgument());
}
[Theory]
[InlineData("/media/movies/Film with spaces.mkv")]
[InlineData(@"C:\media\movies\Film.mkv")]
public void EscapeProcessArgument_NothingToEscape_ReturnsSameInstance(string input)
{
Assert.Same(input, input.EscapeProcessArgument());
}
}
}
@@ -186,6 +186,109 @@ namespace Jellyfin.Model.Tests.Entities
Assert.Null(nullProvider.ProviderIds);
}
[Theory]
[InlineData(nameof(MetadataProvider.Imdb), "tt0113375", true)]
[InlineData(nameof(MetadataProvider.Imdb), "nm0000123", true)]
[InlineData(nameof(MetadataProvider.Imdb), "0113375", true)]
[InlineData(nameof(MetadataProvider.Imdb), "https://www.imdb.com/title/tt0113375", false)]
[InlineData(nameof(MetadataProvider.Tmdb), "11", true)]
[InlineData(nameof(MetadataProvider.Tmdb), "nm0000123", false)]
[InlineData(nameof(MetadataProvider.Tmdb), "0", false)]
[InlineData(nameof(MetadataProvider.Tmdb), "-11", false)]
[InlineData(nameof(MetadataProvider.TmdbCollection), "nm0000123", false)]
[InlineData(nameof(MetadataProvider.AudioDbArtist), "111239", true)]
[InlineData(nameof(MetadataProvider.AudioDbArtist), "a3cb23fc-acd3-4ce0-8f36-1e5aa6a18432", false)]
[InlineData(nameof(MetadataProvider.MusicBrainzArtist), "a3cb23fc-acd3-4ce0-8f36-1e5aa6a18432", true)]
[InlineData(nameof(MetadataProvider.MusicBrainzArtist), "111239", false)]
[InlineData(nameof(MetadataProvider.MusicBrainzAlbum), "not-an-mbid", false)]
[InlineData(nameof(MetadataProvider.Tvdb), "anything-goes", true)]
[InlineData("SomePlugin", "anything-goes", true)]
[InlineData(nameof(MetadataProvider.Tmdb), null, false)]
[InlineData(null, "11", false)]
public void IsValidProviderId_ChecksKnownFormats(string? name, string? value, bool expected)
{
Assert.Equal(expected, ProviderIdsExtensions.IsValidProviderId(name, value));
}
[Fact]
public void TrySetProviderId_ForeignId_False()
{
var provider = new ProviderIdsExtensionsTestsObject();
Assert.False(provider.TrySetProviderId(MetadataProvider.Tmdb, "nm0000123"));
Assert.Empty(provider.ProviderIds);
}
[Fact]
public void TrySetProviderId_ForeignId_KeepsExisting()
{
var provider = new ProviderIdsExtensionsTestsObject();
provider.ProviderIds[MetadataProvider.Tmdb.ToString()] = "11";
Assert.False(provider.TrySetProviderId(MetadataProvider.Tmdb, "nm0000123"));
Assert.Equal("11", provider.GetProviderId(MetadataProvider.Tmdb));
}
[Theory]
[InlineData(nameof(MetadataProvider.Imdb), " tt0113375 ")]
[InlineData(" Imdb", ExampleImdbId)]
public void TrySetProviderId_SurroundingWhitespace_Trimmed(string name, string value)
{
var provider = new ProviderIdsExtensionsTestsObject();
Assert.True(provider.TrySetProviderId(name, value));
Assert.Equal(ExampleImdbId, provider.GetProviderId(MetadataProvider.Imdb));
}
[Fact]
public void SetProviderIds_ReplacesAll()
{
var provider = new ProviderIdsExtensionsTestsObject();
provider.ProviderIds[MetadataProvider.Tvdb.ToString()] = "12345";
provider.SetProviderIds(new Dictionary<string, string>
{
[MetadataProvider.Imdb.ToString()] = ExampleImdbId
});
Assert.Equal(ExampleImdbId, provider.GetProviderId(MetadataProvider.Imdb));
Assert.False(provider.HasProviderId(MetadataProvider.Tvdb));
}
[Fact]
public void SetProviderIds_ForeignId_Dropped()
{
var provider = new ProviderIdsExtensionsTestsObject();
provider.SetProviderIds(new Dictionary<string, string>
{
[MetadataProvider.Tmdb.ToString()] = "nm0000123",
[MetadataProvider.Imdb.ToString()] = ExampleImdbId,
[MetadataProvider.Tvdb.ToString()] = string.Empty
});
Assert.False(provider.HasProviderId(MetadataProvider.Tmdb));
Assert.False(provider.HasProviderId(MetadataProvider.Tvdb));
Assert.Equal(ExampleImdbId, provider.GetProviderId(MetadataProvider.Imdb));
}
[Fact]
public void SetProviderIds_Null_Clears()
{
var provider = new ProviderIdsExtensionsTestsObject();
provider.ProviderIds[MetadataProvider.Imdb.ToString()] = ExampleImdbId;
provider.SetProviderIds(null);
Assert.Empty(provider.ProviderIds);
}
[Fact]
public void SetProviderIds_NullInstance_ThrowsArgumentNullException()
{
Assert.Throws<ArgumentNullException>(() => ProviderIdsExtensions.SetProviderIds(null!, new Dictionary<string, string>()));
}
[Fact]
public void RemoveProviderId_Null_Remove()
{
@@ -0,0 +1,253 @@
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Data.Enums;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Movies;
using MediaBrowser.Controller.IO;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Persistence;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.IO;
using MediaBrowser.Providers.Manager;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
namespace Jellyfin.Providers.Tests.Manager
{
public class MetadataServiceRefreshTests
{
[Theory]
// RemoveOldMetadata is only ever set by an explicit user action - a refresh with "replace all
// metadata", or Identify. A provider failing must not silently downgrade that to a merge: the
// providers that did answer supplied the replacement, and the old values are the wrong match
// the user asked to get rid of.
[InlineData(false)]
[InlineData(true)]
public async Task RefreshWithProviders_ReplaceAllMetadata_ErasesOldDataWhenAProviderAnswers(bool allProvidersSucceed)
{
var item = new Movie
{
Name = "Test Movie",
Overview = "existing overview"
};
// The provider owning the overview fails, so it contributes nothing to the replacement.
var failing = new Mock<IRemoteMetadataProvider<Movie, MovieInfo>>(MockBehavior.Loose);
failing.Setup(p => p.Name).Returns("Failing");
failing.Setup(p => p.GetMetadata(It.IsAny<MovieInfo>(), It.IsAny<CancellationToken>()))
.Returns(allProvidersSucceed
? Task.FromResult(new MetadataResult<Movie> { HasMetadata = true, Item = new Movie() })
: Task.FromException<MetadataResult<Movie>>(new FormatException("bad id")));
var succeeding = new Mock<IRemoteMetadataProvider<Movie, MovieInfo>>(MockBehavior.Loose);
succeeding.Setup(p => p.Name).Returns("Succeeding");
succeeding.Setup(p => p.GetMetadata(It.IsAny<MovieInfo>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new MetadataResult<Movie>
{
HasMetadata = true,
Item = new Movie { Name = "Test Movie", Tagline = "new tagline" }
});
var service = new TestMetadataService();
var result = await service.RefreshWithProvidersInternal(
new MetadataResult<Movie> { Item = item },
new MovieInfo { Name = item.Name },
new MetadataRefreshOptions(Mock.Of<IDirectoryService>())
{
MetadataRefreshMode = MetadataRefreshMode.FullRefresh,
ReplaceAllMetadata = true,
RemoveOldMetadata = true
},
[failing.Object, succeeding.Object]).ConfigureAwait(true);
Assert.Equal(allProvidersSucceed ? 0 : 1, result.Failures);
Assert.Equal("new tagline", item.Tagline);
Assert.Null(item.Overview);
}
[Fact]
public async Task RefreshWithProviders_ReplaceAllMetadata_KeepsExistingDataWhenEveryRemoteProviderFails()
{
var item = new Movie
{
Name = "Test Movie",
Overview = "existing overview"
};
// Something has to contribute for the merge to run at all, otherwise the item is never touched
// and the case is moot. The local provider is the replacement the remote ones did not deliver.
var local = new Mock<ILocalMetadataProvider<Movie>>(MockBehavior.Loose);
local.Setup(p => p.Name).Returns("Local");
local.Setup(p => p.GetMetadata(It.IsAny<ItemInfo>(), It.IsAny<IDirectoryService>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new MetadataResult<Movie>
{
HasMetadata = true,
Item = new Movie { Name = "Test Movie", Tagline = "new tagline" }
});
var remote = new Mock<IRemoteMetadataProvider<Movie, MovieInfo>>(MockBehavior.Loose);
remote.Setup(p => p.Name).Returns("Failing");
remote.Setup(p => p.GetMetadata(It.IsAny<MovieInfo>(), It.IsAny<CancellationToken>()))
.Returns(Task.FromException<MetadataResult<Movie>>(new HttpRequestException("unreachable")));
var service = new TestMetadataService();
var result = await service.RefreshWithProvidersInternal(
new MetadataResult<Movie> { Item = item },
new MovieInfo { Name = item.Name },
new MetadataRefreshOptions(Mock.Of<IDirectoryService>())
{
MetadataRefreshMode = MetadataRefreshMode.FullRefresh,
ReplaceAllMetadata = true,
RemoveOldMetadata = true
},
[local.Object, remote.Object]).ConfigureAwait(true);
Assert.Equal(1, result.Failures);
Assert.Equal("new tagline", item.Tagline);
// No remote provider answered, so erasing the overview would lose it for good.
Assert.Equal("existing overview", item.Overview);
}
[Fact]
public async Task RefreshWithProviders_ForeignProviderId_NotStored()
{
var item = new Movie { Name = "Test Movie" };
var provider = new Mock<IRemoteMetadataProvider<Movie, MovieInfo>>(MockBehavior.Loose);
provider.Setup(p => p.Name).Returns("Provider");
provider.Setup(p => p.GetMetadata(It.IsAny<MovieInfo>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(() =>
{
var found = new Movie { Name = "Test Movie" };
found.ProviderIds[MetadataProvider.Tmdb.ToString()] = "nm0000123";
found.ProviderIds[MetadataProvider.Imdb.ToString()] = "tt0113375";
return new MetadataResult<Movie> { HasMetadata = true, Item = found };
});
var service = new TestMetadataService();
await service.RefreshWithProvidersInternal(
new MetadataResult<Movie> { Item = item },
new MovieInfo { Name = item.Name },
new MetadataRefreshOptions(Mock.Of<IDirectoryService>())
{
MetadataRefreshMode = MetadataRefreshMode.FullRefresh,
ReplaceAllMetadata = true
},
[provider.Object]).ConfigureAwait(true);
Assert.False(item.HasProviderId(MetadataProvider.Tmdb));
Assert.Equal("tt0113375", item.GetProviderId(MetadataProvider.Imdb));
}
[Fact]
public async Task RefreshWithProviders_ForeignProviderId_ReplacedInLookupInfo()
{
var item = new Movie { Name = "Test Movie" };
var lookupInfo = new MovieInfo { Name = item.Name };
lookupInfo.ProviderIds[MetadataProvider.Tmdb.ToString()] = "nm0000123";
var answering = new Mock<IRemoteMetadataProvider<Movie, MovieInfo>>(MockBehavior.Loose);
answering.Setup(p => p.Name).Returns("Answering");
answering.Setup(p => p.GetMetadata(It.IsAny<MovieInfo>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(() =>
{
var found = new Movie { Name = "Test Movie" };
found.ProviderIds[MetadataProvider.Tmdb.ToString()] = "12345";
return new MetadataResult<Movie> { HasMetadata = true, Item = found };
});
string? tmdbIdSeenBySecondProvider = null;
var following = new Mock<IRemoteMetadataProvider<Movie, MovieInfo>>(MockBehavior.Loose);
following.Setup(p => p.Name).Returns("Following");
following.Setup(p => p.GetMetadata(It.IsAny<MovieInfo>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((MovieInfo info, CancellationToken _) =>
{
tmdbIdSeenBySecondProvider = info.GetProviderId(MetadataProvider.Tmdb);
return new MetadataResult<Movie> { HasMetadata = false };
});
var service = new TestMetadataService();
await service.RefreshWithProvidersInternal(
new MetadataResult<Movie> { Item = item },
lookupInfo,
new MetadataRefreshOptions(Mock.Of<IDirectoryService>())
{
MetadataRefreshMode = MetadataRefreshMode.FullRefresh,
ReplaceAllMetadata = true
},
[answering.Object, following.Object]).ConfigureAwait(true);
// The stored id cannot be a TMDb one, so the provider that still has to run must get the id
// that was just found instead of failing on the same bad one.
Assert.Equal("12345", tmdbIdSeenBySecondProvider);
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task RefreshWithProviders_ForeignPersonProviderId_NotStored(bool replaceAllMetadata)
{
var item = new Movie { Name = "Test Movie" };
var existing = new MetadataResult<Movie> { Item = item };
existing.AddPerson(new PersonInfo { Name = "Some Actor", Type = PersonKind.Actor });
var provider = new Mock<IRemoteMetadataProvider<Movie, MovieInfo>>(MockBehavior.Loose);
provider.Setup(p => p.Name).Returns("Provider");
provider.Setup(p => p.GetMetadata(It.IsAny<MovieInfo>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(() =>
{
var person = new PersonInfo { Name = "Some Actor", Type = PersonKind.Actor };
person.ProviderIds[MetadataProvider.Tmdb.ToString()] = "nm0000123";
person.ProviderIds[MetadataProvider.Imdb.ToString()] = "nm0000123";
var found = new MetadataResult<Movie> { HasMetadata = true, Item = new Movie { Name = "Test Movie" } };
found.AddPerson(person);
return found;
});
var service = new TestMetadataService();
await service.RefreshWithProvidersInternal(
existing,
new MovieInfo { Name = item.Name },
new MetadataRefreshOptions(Mock.Of<IDirectoryService>())
{
MetadataRefreshMode = MetadataRefreshMode.FullRefresh,
ReplaceAllMetadata = replaceAllMetadata
},
[provider.Object]).ConfigureAwait(true);
var mergedPerson = Assert.Single(existing.People);
Assert.False(mergedPerson.HasProviderId(MetadataProvider.Tmdb));
Assert.Equal("nm0000123", mergedPerson.GetProviderId(MetadataProvider.Imdb));
}
private sealed class TestMetadataService : MetadataService<Movie, MovieInfo>
{
public TestMetadataService()
: base(
Mock.Of<IServerConfigurationManager>(),
NullLogger<MetadataService<Movie, MovieInfo>>.Instance,
Mock.Of<IProviderManager>(),
Mock.Of<IFileSystem>(),
Mock.Of<ILibraryManager>(),
Mock.Of<IExternalDataManager>(),
Mock.Of<IItemRepository>())
{
}
public Task<RefreshResult> RefreshWithProvidersInternal(
MetadataResult<Movie> metadata,
MovieInfo id,
MetadataRefreshOptions options,
ICollection<IMetadataProvider> providers)
=> RefreshWithProviders(metadata, id, options, providers, ImageProvider, false, CancellationToken.None);
}
}
}
@@ -0,0 +1,59 @@
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
using MediaBrowser.Providers.Music;
using Xunit;
namespace Jellyfin.Providers.Tests.Music;
public static class AlbumInfoExtensionsTests
{
private const string ExampleMbid = "59b5a40b-e2fd-3f18-a218-e8c9aae12ab5";
private const string SongMbid = "6c301dbd-6ccb-3403-a6c4-6a22240a0297";
[Theory]
[InlineData(ExampleMbid, ExampleMbid)]
// Another provider's id under a MusicBrainz key reads as no id, so the caller searches instead of
// handing a value the MusicBrainz client throws on.
[InlineData("111239", null)]
[InlineData("", null)]
public static void GetReleaseId_OnlyReturnsMbids(string id, string? expected)
{
var info = new AlbumInfo();
info.ProviderIds[MetadataProvider.MusicBrainzAlbum.ToString()] = id;
Assert.Equal(expected, info.GetReleaseId());
}
[Fact]
public static void GetReleaseId_ForeignId_FallsBackToSongs()
{
var song = new SongInfo();
song.ProviderIds[MetadataProvider.MusicBrainzAlbum.ToString()] = SongMbid;
var info = new AlbumInfo { SongInfos = [song] };
info.ProviderIds[MetadataProvider.MusicBrainzAlbum.ToString()] = "111239";
Assert.Equal(SongMbid, info.GetReleaseId());
}
[Fact]
public static void GetMusicBrainzArtistId_ForeignId_FallsBackToArtistIds()
{
var info = new AlbumInfo();
info.ProviderIds[MetadataProvider.MusicBrainzAlbumArtist.ToString()] = "111239";
info.ArtistProviderIds[MetadataProvider.MusicBrainzArtist.ToString()] = ExampleMbid;
Assert.Equal(ExampleMbid, info.GetMusicBrainzArtistId());
}
[Theory]
[InlineData(ExampleMbid, ExampleMbid)]
[InlineData("111239", null)]
public static void GetMusicBrainzArtistId_ArtistInfo_OnlyReturnsMbids(string id, string? expected)
{
var info = new ArtistInfo();
info.ProviderIds[MetadataProvider.MusicBrainzArtist.ToString()] = id;
Assert.Equal(expected, info.GetMusicBrainzArtistId());
}
}
@@ -1,3 +1,5 @@
using MediaBrowser.Controller.Entities.Movies;
using MediaBrowser.Model.Entities;
using MediaBrowser.Providers.Plugins.Tmdb;
using Xunit;
@@ -34,5 +36,40 @@ namespace Jellyfin.Providers.Tests.Tmdb
{
Assert.Equal(expected, TmdbUtils.AdjustImageLanguage(imageLanguage, requestLanguage));
}
[Theory]
[InlineData("11", true, 11)]
// An id another provider filed under the TMDb key must not throw, it is simply not a TMDb id.
[InlineData("nm0000123", false, 0)]
[InlineData("tt0113375", false, 0)]
[InlineData("11.0", false, 0)]
[InlineData("-11", false, 0)]
[InlineData("0", false, 0)]
[InlineData("", false, 0)]
[InlineData(null, false, 0)]
public static void TryParseTmdbId_OnlyAcceptsTmdbIds(string? value, bool expected, int expectedId)
{
Assert.Equal(expected, TmdbUtils.TryParseTmdbId(value, out var tmdbId));
Assert.Equal(expectedId, tmdbId);
}
[Theory]
[InlineData("11", true, 11)]
[InlineData("nm0000123", false, 0)]
public static void TryGetTmdbId_OnlyAcceptsTmdbIds(string value, bool expected, int expectedId)
{
var item = new Movie();
item.ProviderIds[MetadataProvider.Tmdb.ToString()] = value;
Assert.Equal(expected, item.TryGetTmdbId(out var tmdbId));
Assert.Equal(expectedId, tmdbId);
}
[Fact]
public static void TryGetTmdbId_NoId_False()
{
Assert.False(new Movie().TryGetTmdbId(out var tmdbId));
Assert.Equal(0, tmdbId);
}
}
}
@@ -4,11 +4,6 @@ using System;
using System.Linq;
using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Database.Implementations.Locking;
using Jellyfin.Database.Providers.Sqlite;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using Xunit;
namespace Jellyfin.Server.Implementations.Tests.Item;
@@ -18,22 +13,10 @@ namespace Jellyfin.Server.Implementations.Tests.Item;
/// (BaseItemRepository.TranslateQuery) and the DatePlayed ordering (OrderMapper) translate
/// and evaluate correctly on the SQLite provider.
/// </summary>
public sealed class AlternateVersionQueryTranslationTests : IDisposable
public sealed class AlternateVersionQueryTranslationTests : SqliteDbTestFixture
{
private readonly SqliteConnection _connection;
private readonly DbContextOptions<JellyfinDbContext> _dbOptions;
public AlternateVersionQueryTranslationTests()
{
_connection = new SqliteConnection("Data Source=:memory:");
_connection.Open();
_dbOptions = new DbContextOptionsBuilder<JellyfinDbContext>()
.UseSqlite(_connection)
.Options;
using var ctx = CreateDbContext();
ctx.Database.EnsureCreated();
}
[Fact]
@@ -220,18 +203,4 @@ public sealed class AlternateVersionQueryTranslationTests : IDisposable
ctx.SaveChanges();
return (user.Id, primary.Id, versionA.Id, versionB.Id);
}
private JellyfinDbContext CreateDbContext()
{
return new JellyfinDbContext(
_dbOptions,
NullLogger<JellyfinDbContext>.Instance,
new SqliteDatabaseProvider(null!, NullLogger<SqliteDatabaseProvider>.Instance),
new NoLockBehavior(NullLogger<NoLockBehavior>.Instance));
}
public void Dispose()
{
_connection.Dispose();
}
}
@@ -3,17 +3,8 @@ using System.Linq;
using Emby.Server.Implementations.Data;
using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Database.Implementations.Locking;
using Jellyfin.Database.Providers.Sqlite;
using Jellyfin.Server.Implementations.Item;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Model.Configuration;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
using BaseItemKind = Jellyfin.Data.Enums.BaseItemKind;
@@ -24,46 +15,16 @@ namespace Jellyfin.Server.Implementations.Tests.Item;
/// <c>GetItemValues</c>. A query without a <c>Limit</c> used to have its total record count
/// silently disabled, so callers got a populated <c>Items</c> array next to a zero total.
/// </summary>
public sealed class BaseItemRepositoryByNameTotalCountTests : IDisposable
public sealed class BaseItemRepositoryByNameTotalCountTests : SqliteDbTestFixture
{
private readonly SqliteConnection _connection;
private readonly DbContextOptions<JellyfinDbContext> _dbOptions;
private readonly BaseItemRepository _repository;
private readonly ItemTypeLookup _itemTypeLookup;
public BaseItemRepositoryByNameTotalCountTests()
{
_connection = new SqliteConnection("Data Source=:memory:");
_connection.Open();
_dbOptions = new DbContextOptionsBuilder<JellyfinDbContext>()
.UseSqlite(_connection)
.Options;
using (var ctx = CreateDbContext())
{
ctx.Database.EnsureCreated();
}
var factory = new Mock<IDbContextFactory<JellyfinDbContext>>();
factory.Setup(f => f.CreateDbContext()).Returns(CreateDbContext);
_itemTypeLookup = new ItemTypeLookup();
var serverConfigurationManager = new Mock<IServerConfigurationManager>();
serverConfigurationManager.Setup(c => c.Configuration).Returns(new ServerConfiguration());
_repository = new BaseItemRepository(
factory.Object,
new Mock<IServerApplicationHost>().Object,
_itemTypeLookup,
serverConfigurationManager.Object,
NullLogger<BaseItemRepository>.Instance);
}
public void Dispose()
{
_connection.Dispose();
_repository = CreateBaseItemRepository(_itemTypeLookup);
}
[Fact]
@@ -187,13 +148,4 @@ public sealed class BaseItemRepositoryByNameTotalCountTests : IDisposable
ctx.SaveChanges();
}
private JellyfinDbContext CreateDbContext()
{
return new JellyfinDbContext(
_dbOptions,
NullLogger<JellyfinDbContext>.Instance,
new SqliteDatabaseProvider(null!, NullLogger<SqliteDatabaseProvider>.Instance),
new NoLockBehavior(NullLogger<NoLockBehavior>.Instance));
}
}
@@ -3,64 +3,25 @@ using System.Linq;
using Emby.Server.Implementations.Data;
using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Database.Implementations.Locking;
using Jellyfin.Database.Providers.Sqlite;
using Jellyfin.Server.Implementations.Item;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Model.Configuration;
using MediaBrowser.Model.Entities;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
using BaseItemKind = Jellyfin.Data.Enums.BaseItemKind;
namespace Jellyfin.Server.Implementations.Tests.Item;
public sealed class BaseItemRepositoryGroupingTests : IDisposable
public sealed class BaseItemRepositoryGroupingTests : SqliteDbTestFixture
{
private readonly SqliteConnection _connection;
private readonly DbContextOptions<JellyfinDbContext> _dbOptions;
private readonly BaseItemRepository _repository;
private readonly string _movieTypeName;
public BaseItemRepositoryGroupingTests()
{
_connection = new SqliteConnection("Data Source=:memory:");
_connection.Open();
_dbOptions = new DbContextOptionsBuilder<JellyfinDbContext>()
.UseSqlite(_connection)
.Options;
using (var ctx = CreateDbContext())
{
ctx.Database.EnsureCreated();
}
var factory = new Mock<IDbContextFactory<JellyfinDbContext>>();
factory.Setup(f => f.CreateDbContext()).Returns(CreateDbContext);
var itemTypeLookup = new ItemTypeLookup();
_movieTypeName = itemTypeLookup.BaseItemKindNames[BaseItemKind.Movie];
var serverConfigurationManager = new Mock<IServerConfigurationManager>();
serverConfigurationManager.Setup(c => c.Configuration).Returns(new ServerConfiguration());
_repository = new BaseItemRepository(
factory.Object,
new Mock<IServerApplicationHost>().Object,
itemTypeLookup,
serverConfigurationManager.Object,
NullLogger<BaseItemRepository>.Instance);
}
public void Dispose()
{
_connection.Dispose();
_repository = CreateBaseItemRepository(itemTypeLookup);
}
[Fact]
@@ -132,13 +93,4 @@ public sealed class BaseItemRepositoryGroupingTests : IDisposable
IsVirtualItem = false
};
}
private JellyfinDbContext CreateDbContext()
{
return new JellyfinDbContext(
_dbOptions,
NullLogger<JellyfinDbContext>.Instance,
new SqliteDatabaseProvider(null!, NullLogger<SqliteDatabaseProvider>.Instance),
new NoLockBehavior(NullLogger<NoLockBehavior>.Instance));
}
}
@@ -0,0 +1,553 @@
using System;
using System.Linq;
using Emby.Server.Implementations.Data;
using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Server.Implementations.Item;
using MediaBrowser.Controller.Entities;
using Xunit;
using LinkedChildType = Jellyfin.Database.Implementations.Entities.LinkedChildType;
namespace Jellyfin.Server.Implementations.Tests.Item;
/// <summary>
/// Covers the filters resolving "folders with a matching descendant" through
/// <see cref="DescendantQueryHelper.GetFolderIdsMatching"/>, positive and negated.
/// </summary>
public sealed class BaseItemRepositoryStreamFilterTests : SqliteDbTestFixture
{
private const string FolderType = "MediaBrowser.Controller.Entities.Folder";
private const string BoxSetType = "MediaBrowser.Controller.Entities.Movies.BoxSet";
private const string MovieType = "MediaBrowser.Controller.Entities.Movies.Movie";
private readonly BaseItemRepository _repository;
private readonly Guid _library = Guid.NewGuid();
private readonly Guid _withSubtitles = Guid.NewGuid();
private readonly Guid _withoutSubtitles = Guid.NewGuid();
private readonly Guid _collection = Guid.NewGuid();
private readonly Guid _linkedSeries = Guid.NewGuid();
private readonly Guid _linkedEpisode = Guid.NewGuid();
// A version group in a library of its own, so it cannot move the assertions above: an SD primary
// that carries nothing, and a 4K second file carrying the subtitles, chapter image and audio.
private readonly Guid _versionLibrary = Guid.NewGuid();
private readonly Guid _versionedMovie = Guid.NewGuid();
private readonly Guid _alternateVersion = Guid.NewGuid();
// A series in the same library, so the folder branch of the resolution filter has a version group
// to reach through as well: an SD episode whose second file is 4K.
private readonly Guid _versionedSeries = Guid.NewGuid();
private readonly Guid _versionedEpisode = Guid.NewGuid();
private readonly Guid _episodeAlternate = Guid.NewGuid();
// An unprobed primary: only its second file carries dimensions, and they are SD.
private readonly Guid _unprobedMovie = Guid.NewGuid();
private readonly Guid _unprobedAlternate = Guid.NewGuid();
// A plain SD movie with no second file, as the control the version groups are read against.
private readonly Guid _sdMovie = Guid.NewGuid();
// An unprobed primary whose only second file is HD, so the HD bucket has to place it off nulls.
private readonly Guid _hdOnlyByVersion = Guid.NewGuid();
private readonly Guid _hdOnlyAlternate = Guid.NewGuid();
// Three files for one movie: the HD one would place it in the HD bucket on its own, the 4K one has
// to win. Only a group holding both can tell the HD bucket's upper guard from its lower one.
private readonly Guid _threeWayMovie = Guid.NewGuid();
private readonly Guid _threeWayHd = Guid.NewGuid();
private readonly Guid _threeWay4K = Guid.NewGuid();
public BaseItemRepositoryStreamFilterTests()
{
using (var ctx = CreateDbContext())
{
Seed(ctx);
}
_repository = CreateBaseItemRepository(new ItemTypeLookup());
}
[Fact]
public void HasSubtitles_MatchesTheItemAndItsParentFolder()
{
var ids = _repository.GetItemIdsList(new InternalItemsQuery { HasSubtitles = true });
Assert.Contains(_withSubtitles, ids);
// The library is a folder, and it has a descendant with subtitles.
Assert.Contains(_library, ids);
Assert.DoesNotContain(_withoutSubtitles, ids);
}
[Fact]
public void HasSubtitles_Negated_ExcludesTheItemAndItsParentFolder()
{
var ids = _repository.GetItemIdsList(new InternalItemsQuery { HasSubtitles = false });
Assert.Contains(_withoutSubtitles, ids);
Assert.DoesNotContain(_withSubtitles, ids);
Assert.DoesNotContain(_library, ids);
}
[Fact]
public void SubtitleLanguages_MatchesTheRequestedLanguageOnly()
{
Assert.Contains(_withSubtitles, _repository.GetItemIdsList(new InternalItemsQuery { SubtitleLanguages = ["ger"] }));
Assert.DoesNotContain(_withSubtitles, _repository.GetItemIdsList(new InternalItemsQuery { SubtitleLanguages = ["fre"] }));
}
[Fact]
public void HasNoSubtitleTrackWithLanguage_ExcludesTheMatchingItemAndFolder()
{
var ids = _repository.GetItemIdsList(new InternalItemsQuery { HasNoSubtitleTrackWithLanguage = "ger" });
Assert.Contains(_withoutSubtitles, ids);
Assert.DoesNotContain(_withSubtitles, ids);
Assert.DoesNotContain(_library, ids);
}
[Fact]
public void HasSubtitles_MatchesACollectionLinkingAFolder()
{
var ids = _repository.GetItemIdsList(new InternalItemsQuery { HasSubtitles = true });
Assert.Contains(_linkedSeries, ids);
Assert.Contains(_collection, ids);
}
[Fact]
public void HasSubtitles_Negated_ExcludesACollectionLinkingAFolder()
{
var ids = _repository.GetItemIdsList(new InternalItemsQuery { HasSubtitles = false });
Assert.DoesNotContain(_linkedSeries, ids);
Assert.DoesNotContain(_collection, ids);
}
[Fact]
public void HasChapterImages_MatchesTheItemAndItsParentFolder()
{
var ids = _repository.GetItemIdsList(new InternalItemsQuery { HasChapterImages = true });
Assert.Contains(_withSubtitles, ids);
Assert.Contains(_library, ids);
Assert.DoesNotContain(_withoutSubtitles, ids);
}
[Fact]
public void HasSubtitles_MatchesAnItemWhoseAlternateVersionCarriesThem()
{
var ids = _repository.GetItemIdsList(new InternalItemsQuery { HasSubtitles = true });
Assert.Contains(_versionedMovie, ids);
Assert.Contains(_versionLibrary, ids);
// The second file is never listed on its own, which is why its tracks have to count for the primary.
Assert.DoesNotContain(_alternateVersion, ids);
}
[Fact]
public void HasSubtitles_Negated_ExcludesAnItemWhoseAlternateVersionCarriesThem()
{
var ids = _repository.GetItemIdsList(new InternalItemsQuery { HasSubtitles = false });
Assert.DoesNotContain(_versionedMovie, ids);
Assert.DoesNotContain(_versionLibrary, ids);
}
[Fact]
public void SubtitleLanguages_MatchesTheLanguageOnAnAlternateVersion()
{
Assert.Contains(_versionedMovie, _repository.GetItemIdsList(new InternalItemsQuery { SubtitleLanguages = ["ger"] }));
Assert.DoesNotContain(_versionedMovie, _repository.GetItemIdsList(new InternalItemsQuery { SubtitleLanguages = ["fre"] }));
}
[Fact]
public void HasNoSubtitleTrackWithLanguage_ExcludesAnItemWhoseAlternateVersionHasIt()
{
var ids = _repository.GetItemIdsList(new InternalItemsQuery { HasNoSubtitleTrackWithLanguage = "ger" });
Assert.DoesNotContain(_versionedMovie, ids);
Assert.DoesNotContain(_versionLibrary, ids);
}
[Fact]
public void AudioLanguages_MatchesTheLanguageOnAnAlternateVersion()
{
Assert.Contains(_versionedMovie, _repository.GetItemIdsList(new InternalItemsQuery { AudioLanguages = ["fre"] }));
}
[Fact]
public void HasNoAudioTrackWithLanguage_ExcludesAnItemWhoseAlternateVersionHasIt()
{
Assert.DoesNotContain(_versionedMovie, _repository.GetItemIdsList(new InternalItemsQuery { HasNoAudioTrackWithLanguage = "fre" }));
}
[Fact]
public void HasChapterImages_MatchesAnItemWhoseAlternateVersionCarriesThem()
{
Assert.Contains(_versionedMovie, _repository.GetItemIdsList(new InternalItemsQuery { HasChapterImages = true }));
}
[Fact]
public void Is4K_MatchesAnItemWhoseAlternateVersionIs4K()
{
// The primary file is SD; the resolution a caller can actually play is the 4K second file's.
Assert.Contains(_versionedMovie, _repository.GetItemIdsList(new InternalItemsQuery { Is4K = true }));
}
[Fact]
public void MinWidth_MatchesAnItemWhoseAlternateVersionIsWideEnough()
{
Assert.Contains(_versionedMovie, _repository.GetItemIdsList(new InternalItemsQuery { MinWidth = 3000 }));
Assert.DoesNotContain(_withSubtitles, _repository.GetItemIdsList(new InternalItemsQuery { MinWidth = 3000 }));
}
[Fact]
public void MaxWidth_ExcludesAnItemWhoseAlternateVersionBreachesTheBound()
{
// The SD primary is narrow enough on its own, but the 4K second file is what a caller would play.
Assert.DoesNotContain(_versionedMovie, _repository.GetItemIdsList(new InternalItemsQuery { MaxWidth = 1920 }));
Assert.Contains(_sdMovie, _repository.GetItemIdsList(new InternalItemsQuery { MaxWidth = 1920 }));
}
[Fact]
public void MaxHeight_ExcludesAnItemWhoseAlternateVersionBreachesTheBound()
{
Assert.DoesNotContain(_versionedMovie, _repository.GetItemIdsList(new InternalItemsQuery { MaxHeight = 1080 }));
Assert.Contains(_sdMovie, _repository.GetItemIdsList(new InternalItemsQuery { MaxHeight = 1080 }));
}
[Fact]
public void IsHD_False_ExcludesAnSdPrimaryWhoseAlternateVersionIsBetter()
{
var ids = _repository.GetItemIdsList(new InternalItemsQuery { IsHD = false });
// 720x480 on its own, but the version group tops out at 4K.
Assert.DoesNotContain(_versionedMovie, ids);
Assert.Contains(_sdMovie, ids);
}
[Fact]
public void IsHD_False_MatchesAPrimaryPlacedOnlyByItsAlternateVersion()
{
// The primary carries no dimensions at all; the SD second file is the group's best.
Assert.Contains(_unprobedMovie, _repository.GetItemIdsList(new InternalItemsQuery { IsHD = false }));
}
[Fact]
public void IsHD_True_ExcludesAnItemWhoseVersionGroupReaches4K()
{
var ids = _repository.GetItemIdsList(new InternalItemsQuery { IsHD = true });
Assert.DoesNotContain(_versionedMovie, ids);
Assert.DoesNotContain(_unprobedMovie, ids);
// The 1920-wide second file alone would say HD; the 4K third file is the group's best.
Assert.DoesNotContain(_threeWayMovie, ids);
}
[Fact]
public void Is4K_MatchesAnItemWhoseVersionGroupHoldsBothHdAnd4K()
{
Assert.Contains(_threeWayMovie, _repository.GetItemIdsList(new InternalItemsQuery { Is4K = true }));
}
[Fact]
public void IsHD_True_MatchesAPrimaryPlacedOnlyByItsAlternateVersion()
{
// The primary carries no dimensions of its own; the HD second file is the group's best.
Assert.Contains(_hdOnlyByVersion, _repository.GetItemIdsList(new InternalItemsQuery { IsHD = true }));
}
[Fact]
public void Is4K_MatchesTheSeriesOfAnEpisodeWhoseAlternateVersionIs4K()
{
// The folder branch buckets a descendant the same way the item branch buckets a top-level item.
Assert.Contains(_versionedSeries, _repository.GetItemIdsList(new InternalItemsQuery { Is4K = true }));
}
[Fact]
public void IsHD_False_ExcludesTheSeriesOfAnSdEpisodeWithABetterAlternateVersion()
{
// Before the version group was consulted on descendants too, the SD episode alone matched here
// while the same pair at top level did not.
Assert.DoesNotContain(_versionedSeries, _repository.GetItemIdsList(new InternalItemsQuery { IsHD = false }));
}
[Theory]
[InlineData("und")]
[InlineData("UND")]
public void HasNoAudioTrackWithLanguage_TreatsUndeterminedCaseInsensitively(string language)
{
var ids = _repository.GetItemIdsList(new InternalItemsQuery { HasNoAudioTrackWithLanguage = language });
// The alternate version carries an audio track with no language, which is what "und" stands for,
// so the item it is reported against does have one.
Assert.DoesNotContain(_unprobedMovie, ids);
Assert.Contains(_versionedMovie, ids);
}
private void Seed(JellyfinDbContext context)
{
context.BaseItems.Add(new BaseItemEntity { Id = _library, Type = FolderType, Name = "Library", IsFolder = true });
context.BaseItems.Add(new BaseItemEntity { Id = _withSubtitles, Type = MovieType, Name = "With subtitles" });
context.BaseItems.Add(new BaseItemEntity { Id = _withoutSubtitles, Type = MovieType, Name = "Without subtitles" });
foreach (var itemId in new[] { _withSubtitles, _withoutSubtitles })
{
context.AncestorIds.Add(new AncestorId
{
ItemId = itemId,
ParentItemId = _library,
Item = null!,
ParentItem = null!
});
context.MediaStreamInfos.Add(new MediaStreamInfo
{
ItemId = itemId,
StreamIndex = 0,
StreamType = MediaStreamTypeEntity.Video,
Item = null!
});
}
context.MediaStreamInfos.Add(new MediaStreamInfo
{
ItemId = _withSubtitles,
StreamIndex = 1,
StreamType = MediaStreamTypeEntity.Subtitle,
Language = "ger",
Item = null!
});
// A collection linking a folder: the match is two edges away, one link then one closure hop.
context.BaseItems.Add(new BaseItemEntity { Id = _collection, Type = BoxSetType, Name = "Collection", IsFolder = true });
context.BaseItems.Add(new BaseItemEntity { Id = _linkedSeries, Type = FolderType, Name = "Linked series", IsFolder = true });
context.BaseItems.Add(new BaseItemEntity { Id = _linkedEpisode, Type = MovieType, Name = "Linked episode" });
context.AncestorIds.Add(new AncestorId
{
ItemId = _linkedEpisode,
ParentItemId = _linkedSeries,
Item = null!,
ParentItem = null!
});
context.LinkedChildren.Add(new LinkedChildEntity
{
ParentId = _collection,
ChildId = _linkedSeries,
ChildType = LinkedChildType.Manual,
SortOrder = 0
});
context.MediaStreamInfos.Add(new MediaStreamInfo
{
ItemId = _linkedEpisode,
StreamIndex = 0,
StreamType = MediaStreamTypeEntity.Subtitle,
Language = "ger",
Item = null!
});
context.Chapters.Add(new Chapter
{
ItemId = _withSubtitles,
ChapterIndex = 0,
StartPositionTicks = 0,
ImagePath = "/chapter.jpg",
Item = null!
});
SeedVersionGroup(context);
context.SaveChanges();
}
// An SD primary whose only extras live on a 4K second file, so every filter has to reach through
// PrimaryVersionId to answer correctly.
private void SeedVersionGroup(JellyfinDbContext context)
{
context.BaseItems.Add(new BaseItemEntity { Id = _versionLibrary, Type = FolderType, Name = "Version library", IsFolder = true });
context.BaseItems.Add(new BaseItemEntity { Id = _versionedMovie, Type = MovieType, Name = "Versioned movie", Width = 720, Height = 480 });
context.BaseItems.Add(new BaseItemEntity
{
Id = _alternateVersion,
Type = MovieType,
Name = "Versioned movie 4K",
PrimaryVersionId = _versionedMovie,
Width = 3840,
Height = 2160
});
foreach (var itemId in new[] { _versionedMovie, _alternateVersion })
{
context.AncestorIds.Add(new AncestorId
{
ItemId = itemId,
ParentItemId = _versionLibrary,
Item = null!,
ParentItem = null!
});
}
context.MediaStreamInfos.Add(new MediaStreamInfo
{
ItemId = _alternateVersion,
StreamIndex = 0,
StreamType = MediaStreamTypeEntity.Subtitle,
Language = "ger",
Item = null!
});
context.MediaStreamInfos.Add(new MediaStreamInfo
{
ItemId = _alternateVersion,
StreamIndex = 1,
StreamType = MediaStreamTypeEntity.Audio,
Language = "fre",
Item = null!
});
SeedVersionedSeries(context);
SeedUnprobedVersionGroup(context);
context.Chapters.Add(new Chapter
{
ItemId = _alternateVersion,
ChapterIndex = 0,
StartPositionTicks = 0,
ImagePath = "/alternate-chapter.jpg",
Item = null!
});
}
// The same SD primary / 4K second file pair one level down, so the resolution filter has to answer
// for the series off its descendants.
private void SeedVersionedSeries(JellyfinDbContext context)
{
context.BaseItems.Add(new BaseItemEntity { Id = _versionedSeries, Type = FolderType, Name = "Versioned series", IsFolder = true });
context.BaseItems.Add(new BaseItemEntity { Id = _versionedEpisode, Type = MovieType, Name = "Versioned episode", Width = 720, Height = 480 });
context.BaseItems.Add(new BaseItemEntity
{
Id = _episodeAlternate,
Type = MovieType,
Name = "Versioned episode 4K",
PrimaryVersionId = _versionedEpisode,
Width = 3840,
Height = 2160
});
context.AncestorIds.Add(new AncestorId
{
ItemId = _versionedSeries,
ParentItemId = _versionLibrary,
Item = null!,
ParentItem = null!
});
foreach (var itemId in new[] { _versionedEpisode, _episodeAlternate })
{
context.AncestorIds.Add(new AncestorId
{
ItemId = itemId,
ParentItemId = _versionedSeries,
Item = null!,
ParentItem = null!
});
}
}
// A primary that was never probed, so only its second file can place it in a bucket. Its audio track
// declares no language, which is what the "und" filters stand in for.
private void SeedUnprobedVersionGroup(JellyfinDbContext context)
{
context.BaseItems.Add(new BaseItemEntity { Id = _sdMovie, Type = MovieType, Name = "SD movie", Width = 720, Height = 480 });
context.AncestorIds.Add(new AncestorId
{
ItemId = _sdMovie,
ParentItemId = _versionLibrary,
Item = null!,
ParentItem = null!
});
context.BaseItems.Add(new BaseItemEntity { Id = _unprobedMovie, Type = MovieType, Name = "Unprobed movie" });
context.BaseItems.Add(new BaseItemEntity
{
Id = _unprobedAlternate,
Type = MovieType,
Name = "Unprobed movie SD",
PrimaryVersionId = _unprobedMovie,
Width = 720,
Height = 480
});
foreach (var itemId in new[] { _unprobedMovie, _unprobedAlternate })
{
context.AncestorIds.Add(new AncestorId
{
ItemId = itemId,
ParentItemId = _versionLibrary,
Item = null!,
ParentItem = null!
});
}
context.MediaStreamInfos.Add(new MediaStreamInfo
{
ItemId = _unprobedAlternate,
StreamIndex = 0,
StreamType = MediaStreamTypeEntity.Audio,
Item = null!
});
SeedMixedVersionGroups(context);
}
// The two groups that separate the HD bucket's lower bound from its upper one: one that only a 4K
// third file keeps out of HD, and one that only an HD second file puts into it.
private void SeedMixedVersionGroups(JellyfinDbContext context)
{
context.BaseItems.Add(new BaseItemEntity { Id = _threeWayMovie, Type = MovieType, Name = "Three-way movie", Width = 720, Height = 480 });
context.BaseItems.Add(new BaseItemEntity
{
Id = _threeWayHd,
Type = MovieType,
Name = "Three-way movie HD",
PrimaryVersionId = _threeWayMovie,
Width = 1920,
Height = 1080
});
context.BaseItems.Add(new BaseItemEntity
{
Id = _threeWay4K,
Type = MovieType,
Name = "Three-way movie 4K",
PrimaryVersionId = _threeWayMovie,
Width = 3840,
Height = 2160
});
context.BaseItems.Add(new BaseItemEntity { Id = _hdOnlyByVersion, Type = MovieType, Name = "HD only by version" });
context.BaseItems.Add(new BaseItemEntity
{
Id = _hdOnlyAlternate,
Type = MovieType,
Name = "HD only by version, HD file",
PrimaryVersionId = _hdOnlyByVersion,
Width = 1920,
Height = 1080
});
foreach (var itemId in new[] { _threeWayMovie, _threeWayHd, _threeWay4K, _hdOnlyByVersion, _hdOnlyAlternate })
{
context.AncestorIds.Add(new AncestorId
{
ItemId = itemId,
ParentItemId = _versionLibrary,
Item = null!,
ParentItem = null!
});
}
}
}
@@ -0,0 +1,526 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Database.Implementations.MatchCriteria;
using Microsoft.EntityFrameworkCore;
using Xunit;
namespace Jellyfin.Server.Implementations.Tests.Item;
/// <summary>
/// Verifies the descendant traversals against the SQLite provider: the sets they resolve, and that
/// they stay sub-selects instead of inlining every descendant id into the statement.
/// </summary>
public sealed class DescendantQueryHelperTests : SqliteDbTestFixture
{
private const string FolderType = "MediaBrowser.Controller.Entities.Folder";
private const string BoxSetType = "MediaBrowser.Controller.Entities.Movies.BoxSet";
private const string MovieType = "MediaBrowser.Controller.Entities.Movies.Movie";
private readonly Dictionary<Guid, int> _linkCounters = new();
public DescendantQueryHelperTests()
{
}
[Fact]
public void GetAllDescendantIds_Hierarchy_ReturnsEveryLevelWithoutTheParent()
{
var library = Guid.NewGuid();
var series = Guid.NewGuid();
var season = Guid.NewGuid();
var episode = Guid.NewGuid();
using (var ctx = CreateDbContext())
{
AddFolder(ctx, library);
AddFolder(ctx, series);
AddFolder(ctx, season);
AddItem(ctx, episode, MovieType);
// AncestorIds is a closure: production writes one row per ancestor, not just the parent.
AddAncestors(ctx, series, library);
AddAncestors(ctx, season, series, library);
AddAncestors(ctx, episode, season, series, library);
ctx.SaveChanges();
}
using (var ctx = CreateDbContext())
{
var descendants = DescendantQueryHelper.GetAllDescendantIds(ctx, library).ToHashSet();
Assert.Equal(new[] { series, season, episode }.Order(), descendants.Order());
Assert.DoesNotContain(library, descendants);
}
}
[Fact]
public void GetAllDescendantIds_LinkedFolder_IncludesItsOwnDescendants()
{
var boxSet = Guid.NewGuid();
var series = Guid.NewGuid();
var episode = Guid.NewGuid();
using (var ctx = CreateDbContext())
{
AddItem(ctx, boxSet, BoxSetType, isFolder: true);
AddFolder(ctx, series);
AddItem(ctx, episode, MovieType);
AddAncestors(ctx, episode, series);
AddLink(ctx, boxSet, series);
ctx.SaveChanges();
}
using (var ctx = CreateDbContext())
{
var descendants = DescendantQueryHelper.GetAllDescendantIds(ctx, boxSet).ToHashSet();
Assert.Contains(series, descendants);
Assert.Contains(episode, descendants);
}
}
// Timeout so that a missing termination guard fails the test instead of hanging the run.
[Fact(Timeout = 30000)]
public void GetAllDescendantIds_NestedLinks_AreFollowedAndCyclesTerminate()
{
var outer = Guid.NewGuid();
var inner = Guid.NewGuid();
var movie = Guid.NewGuid();
using (var ctx = CreateDbContext())
{
AddItem(ctx, outer, BoxSetType, isFolder: true);
AddItem(ctx, inner, BoxSetType, isFolder: true);
AddItem(ctx, movie, MovieType);
AddLink(ctx, outer, inner);
AddLink(ctx, inner, movie);
// The traversal must not spin on this cycle.
AddLink(ctx, inner, outer);
ctx.SaveChanges();
}
using (var ctx = CreateDbContext())
{
var descendants = DescendantQueryHelper.GetAllDescendantIds(ctx, outer).ToHashSet();
Assert.Contains(inner, descendants);
Assert.Contains(movie, descendants);
Assert.DoesNotContain(outer, descendants);
}
}
[Fact]
public void GetAllDescendantIds_LinksOfNonFolders_AreNotFollowed()
{
var library = Guid.NewGuid();
var movie = Guid.NewGuid();
var alternateVersion = Guid.NewGuid();
using (var ctx = CreateDbContext())
{
AddFolder(ctx, library);
AddItem(ctx, movie, MovieType);
AddItem(ctx, alternateVersion, MovieType);
AddAncestors(ctx, movie, library);
// An alternate version hangs off the movie by link, and the movie is not a folder.
AddLink(ctx, movie, alternateVersion);
ctx.SaveChanges();
}
using (var ctx = CreateDbContext())
{
var descendants = DescendantQueryHelper.GetAllDescendantIds(ctx, library).ToHashSet();
Assert.Contains(movie, descendants);
Assert.DoesNotContain(alternateVersion, descendants);
}
}
[Fact]
public void GetAllDescendantIds_ClosureSeamAboveTheCollectionFolder_IsCrossed()
{
var userRoot = Guid.NewGuid();
var collectionFolder = Guid.NewGuid();
var series = Guid.NewGuid();
var episode = Guid.NewGuid();
var boxSet = Guid.NewGuid();
var linkedMovie = Guid.NewGuid();
using (var ctx = CreateDbContext())
{
AddFolder(ctx, userRoot);
AddFolder(ctx, collectionFolder);
AddFolder(ctx, series);
AddItem(ctx, episode, MovieType);
AddItem(ctx, boxSet, BoxSetType, isFolder: true);
AddItem(ctx, linkedMovie, MovieType);
// An item carries its own chain plus its collection folder, but not the user root above
// it, so one hop from the user root stops at the collection folder.
AddAncestors(ctx, collectionFolder, userRoot);
AddAncestors(ctx, series, collectionFolder);
AddAncestors(ctx, episode, series, collectionFolder);
AddAncestors(ctx, boxSet, collectionFolder);
// The box set is only reachable across the seam, and its links have to be followed too.
AddLink(ctx, boxSet, linkedMovie);
ctx.SaveChanges();
}
using (var ctx = CreateDbContext())
{
var descendants = DescendantQueryHelper.GetAllDescendantIds(ctx, userRoot).ToHashSet();
Assert.Equal(
new[] { collectionFolder, series, episode, boxSet, linkedMovie }.Order(),
descendants.Order());
}
}
[Fact]
public void GetOwnedDescendantIds_ClosureSeamAboveTheCollectionFolder_IsCrossed()
{
var userRoot = Guid.NewGuid();
var collectionFolder = Guid.NewGuid();
var series = Guid.NewGuid();
var episode = Guid.NewGuid();
var boxSet = Guid.NewGuid();
var linkedMovie = Guid.NewGuid();
using (var ctx = CreateDbContext())
{
AddFolder(ctx, userRoot);
AddFolder(ctx, collectionFolder);
AddFolder(ctx, series);
AddItem(ctx, episode, MovieType);
AddItem(ctx, boxSet, BoxSetType, isFolder: true);
AddItem(ctx, linkedMovie, MovieType);
AddAncestors(ctx, collectionFolder, userRoot);
AddAncestors(ctx, series, collectionFolder);
AddAncestors(ctx, episode, series, collectionFolder);
AddAncestors(ctx, boxSet, collectionFolder);
AddLink(ctx, boxSet, linkedMovie);
ctx.SaveChanges();
}
using (var ctx = CreateDbContext())
{
// Owned only: the linked movie stays out, or deleting a library would delete it.
var expected = new[] { collectionFolder, series, episode, boxSet }.Order();
Assert.Equal(expected, DescendantQueryHelper.GetOwnedDescendantIds(ctx, userRoot).ToHashSet().Order());
Assert.Equal(expected, DescendantQueryHelper.GetOwnedDescendantIdsBatch(ctx, [userRoot]).Order());
}
}
[Fact]
public void GetFolderIdsMatching_LinkAboveAClosure_ReturnsTheLinkingFolder()
{
var collections = Guid.NewGuid();
var boxSet = Guid.NewGuid();
var library = Guid.NewGuid();
var series = Guid.NewGuid();
var episode = Guid.NewGuid();
var otherLibrary = Guid.NewGuid();
var otherBoxSet = Guid.NewGuid();
var silentMovie = Guid.NewGuid();
using (var ctx = CreateDbContext())
{
AddFolder(ctx, collections);
AddItem(ctx, boxSet, BoxSetType, isFolder: true);
AddFolder(ctx, library);
AddFolder(ctx, series);
AddItem(ctx, episode, MovieType);
AddAncestors(ctx, boxSet, collections);
AddAncestors(ctx, series, library);
AddAncestors(ctx, episode, series, library);
// The link lands on the series, not on the episode that carries the subtitles.
AddLink(ctx, boxSet, series);
AddStream(ctx, episode, MediaStreamTypeEntity.Subtitle);
AddFolder(ctx, otherLibrary);
AddItem(ctx, otherBoxSet, BoxSetType, isFolder: true);
AddItem(ctx, silentMovie, MovieType);
AddAncestors(ctx, otherBoxSet, collections);
AddAncestors(ctx, silentMovie, otherLibrary);
AddLink(ctx, otherBoxSet, silentMovie);
// A stream of another type: the criteria, not the mere presence of a stream, decides.
AddStream(ctx, silentMovie, MediaStreamTypeEntity.Video);
ctx.SaveChanges();
}
using (var ctx = CreateDbContext())
{
var folders = DescendantQueryHelper.GetFolderIdsMatching(ctx, new HasSubtitles()).ToHashSet();
Assert.Equal(new[] { library, series, boxSet, collections }.Order(), folders.Order());
}
}
[Fact(Timeout = 30000)]
public void GetFolderIdsMatching_NestedLinks_AreFollowedAndCyclesTerminate()
{
var outer = Guid.NewGuid();
var inner = Guid.NewGuid();
var movie = Guid.NewGuid();
var silentSet = Guid.NewGuid();
var silentMovie = Guid.NewGuid();
using (var ctx = CreateDbContext())
{
AddItem(ctx, outer, BoxSetType, isFolder: true);
AddItem(ctx, inner, BoxSetType, isFolder: true);
AddItem(ctx, movie, MovieType);
AddLink(ctx, outer, inner);
AddLink(ctx, inner, movie);
// Resolving the link parents must not spin on this cycle.
AddLink(ctx, inner, outer);
AddStream(ctx, movie, MediaStreamTypeEntity.Subtitle);
AddItem(ctx, silentSet, BoxSetType, isFolder: true);
AddItem(ctx, silentMovie, MovieType);
AddLink(ctx, silentSet, silentMovie);
AddStream(ctx, silentMovie, MediaStreamTypeEntity.Video);
ctx.SaveChanges();
}
using (var ctx = CreateDbContext())
{
var folders = DescendantQueryHelper.GetFolderIdsMatching(ctx, new HasSubtitles()).ToHashSet();
Assert.Equal(new[] { inner, outer }.Order(), folders.Order());
}
}
[Fact]
public void GetFolderIdsMatching_ClosureSeamAboveTheCollectionFolder_IsCrossed()
{
var userRoot = Guid.NewGuid();
var collectionFolder = Guid.NewGuid();
var series = Guid.NewGuid();
var episode = Guid.NewGuid();
using (var ctx = CreateDbContext())
{
AddFolder(ctx, userRoot);
AddFolder(ctx, collectionFolder);
AddFolder(ctx, series);
AddItem(ctx, episode, MovieType);
// The closure is not transitive at this seam: no item records the user root.
AddAncestors(ctx, episode, series, collectionFolder);
AddAncestors(ctx, series, collectionFolder);
AddAncestors(ctx, collectionFolder, userRoot);
AddStream(ctx, episode, MediaStreamTypeEntity.Subtitle);
ctx.SaveChanges();
}
using (var ctx = CreateDbContext())
{
var folders = DescendantQueryHelper.GetFolderIdsMatching(ctx, new HasSubtitles()).ToHashSet();
Assert.Equal(new[] { series, collectionFolder, userRoot }.Order(), folders.Order());
}
}
[Fact]
public void GetFolderIdsMatching_LinkedFolder_MatchesOnLanguageOnly()
{
var boxSet = Guid.NewGuid();
var series = Guid.NewGuid();
var episode = Guid.NewGuid();
using (var ctx = CreateDbContext())
{
AddItem(ctx, boxSet, BoxSetType, isFolder: true);
AddFolder(ctx, series);
AddItem(ctx, episode, MovieType);
AddAncestors(ctx, episode, series);
AddLink(ctx, boxSet, series);
AddStream(ctx, episode, MediaStreamTypeEntity.Subtitle, "ger");
ctx.SaveChanges();
}
using (var ctx = CreateDbContext())
{
var german = new HasMediaStreamType(MediaStreamTypeEntity.Subtitle, ["ger"]);
var french = new HasMediaStreamType(MediaStreamTypeEntity.Subtitle, ["fre"]);
Assert.Equal(new[] { series, boxSet }.Order(), DescendantQueryHelper.GetFolderIdsMatching(ctx, german).ToHashSet().Order());
Assert.Empty(DescendantQueryHelper.GetFolderIdsMatching(ctx, french).ToArray());
}
}
[Fact]
public void GetFolderIdsMatching_AlternateVersionLinks_AreNotWalked()
{
var collections = Guid.NewGuid();
var boxSet = Guid.NewGuid();
var library = Guid.NewGuid();
var movie = Guid.NewGuid();
var alternateVersion = Guid.NewGuid();
using (var ctx = CreateDbContext())
{
AddFolder(ctx, collections);
AddItem(ctx, boxSet, BoxSetType, isFolder: true);
AddFolder(ctx, library);
AddItem(ctx, movie, MovieType);
AddItem(ctx, alternateVersion, MovieType);
AddAncestors(ctx, boxSet, collections);
AddAncestors(ctx, movie, library);
AddAncestors(ctx, alternateVersion, library);
// Only the second file carries the subtitles, and it hangs off the movie by an alternate
// version link. The movie is not a folder, so that link is not a parent-child edge.
AddLink(ctx, movie, alternateVersion, LinkedChildType.LocalAlternateVersion);
AddLink(ctx, boxSet, movie);
AddStream(ctx, alternateVersion, MediaStreamTypeEntity.Subtitle);
ctx.SaveChanges();
}
using (var ctx = CreateDbContext())
{
var folders = DescendantQueryHelper.GetFolderIdsMatching(ctx, new HasSubtitles()).ToHashSet();
// The library still matches: the alternate version carries its own closure. The box set does
// not, matching the descendant side, which does not follow a non-folder's links either.
Assert.Equal([library], folders);
}
}
[Fact]
public void GetOwnedDescendantIds_IgnoresLinkedChildren()
{
var boxSet = Guid.NewGuid();
var owned = Guid.NewGuid();
var linked = Guid.NewGuid();
using (var ctx = CreateDbContext())
{
AddItem(ctx, boxSet, BoxSetType, isFolder: true);
AddItem(ctx, owned, MovieType);
AddItem(ctx, linked, MovieType);
AddAncestors(ctx, owned, boxSet);
AddLink(ctx, boxSet, linked);
ctx.SaveChanges();
}
using (var ctx = CreateDbContext())
{
Assert.Equal([owned], DescendantQueryHelper.GetOwnedDescendantIds(ctx, boxSet).ToArray());
Assert.Equal([owned], DescendantQueryHelper.GetOwnedDescendantIdsBatch(ctx, [boxSet]).ToArray());
}
}
[Fact]
public void GetAllDescendantIds_StatementSizeDoesNotGrowWithTheLibrary()
{
var small = SeedLibrary(10);
var large = SeedLibrary(500);
using var ctx = CreateDbContext();
var smallSql = CountingQuery(ctx, small).ToQueryString();
var largeSql = CountingQuery(ctx, large).ToQueryString();
// Reading the ids into memory and handing them back as AsQueryable() makes EF inline one
// literal per descendant, which is what allocated megabytes per call.
Assert.Equal(smallSql.Length, largeSql.Length);
Assert.Contains("AncestorIds", smallSql, StringComparison.Ordinal);
Assert.Equal(10, CountingQuery(ctx, small).Count());
Assert.Equal(500, CountingQuery(ctx, large).Count());
}
private static IQueryable<BaseItemEntity> CountingQuery(JellyfinDbContext context, Guid libraryId)
{
var descendantIds = DescendantQueryHelper.GetAllDescendantIds(context, libraryId);
return context.BaseItems
.AsNoTracking()
.Where(b => descendantIds.Contains(b.Id))
.Where(DescendantQueryHelper.IsCountableLeaf);
}
private Guid SeedLibrary(int childCount)
{
var library = Guid.NewGuid();
using var ctx = CreateDbContext();
AddFolder(ctx, library);
for (var i = 0; i < childCount; i++)
{
var child = Guid.NewGuid();
AddItem(ctx, child, MovieType);
AddAncestors(ctx, child, library);
}
ctx.SaveChanges();
return library;
}
private static void AddFolder(JellyfinDbContext context, Guid id)
=> AddItem(context, id, FolderType, isFolder: true);
private static void AddItem(JellyfinDbContext context, Guid id, string type, bool isFolder = false)
=> context.BaseItems.Add(new BaseItemEntity
{
Id = id,
Type = type,
Name = type + " " + id,
IsFolder = isFolder
});
private static void AddStream(JellyfinDbContext context, Guid itemId, MediaStreamTypeEntity type, string? language = null)
=> context.MediaStreamInfos.Add(new MediaStreamInfo
{
ItemId = itemId,
StreamIndex = 0,
StreamType = type,
Language = language,
Item = null!
});
private static void AddAncestors(JellyfinDbContext context, Guid itemId, params Guid[] ancestorIds)
{
foreach (var ancestorId in ancestorIds)
{
context.AncestorIds.Add(new AncestorId
{
ItemId = itemId,
ParentItemId = ancestorId,
Item = null!,
ParentItem = null!
});
}
}
// LinkedChildren is keyed on (ParentId, SortOrder), so every link of a parent needs its own slot.
private void AddLink(JellyfinDbContext context, Guid parentId, Guid childId, LinkedChildType childType = LinkedChildType.Manual)
{
_linkCounters.TryGetValue(parentId, out var sortOrder);
_linkCounters[parentId] = sortOrder + 1;
context.LinkedChildren.Add(new LinkedChildEntity
{
ParentId = parentId,
ChildId = childId,
ChildType = childType,
SortOrder = sortOrder
});
}
}
@@ -3,49 +3,27 @@ using System.Collections.Generic;
using System.Linq;
using System.Threading;
using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.Locking;
using Jellyfin.Database.Providers.Sqlite;
using Jellyfin.Server.Implementations.Item;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Configuration;
using MediaBrowser.Model.Entities;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
namespace Jellyfin.Server.Implementations.Tests.Item;
public sealed class ItemPersistenceOwnedRowTests : IDisposable
public sealed class ItemPersistenceOwnedRowTests : SqliteDbTestFixture
{
private readonly SqliteConnection _connection;
private readonly DbContextOptions<JellyfinDbContext> _dbOptions;
private readonly ItemPersistenceService _service;
private readonly IApplicationPaths _applicationPaths;
private readonly ILibraryManager? _previousLibraryManager;
private readonly IServerConfigurationManager? _previousConfigurationManager;
public ItemPersistenceOwnedRowTests()
{
_applicationPaths = new Mock<IApplicationPaths>().Object;
_connection = new SqliteConnection("Data Source=:memory:");
_connection.Open();
_dbOptions = new DbContextOptionsBuilder<JellyfinDbContext>()
.UseSqlite(_connection)
.Options;
using (var ctx = CreateDbContext())
{
ctx.Database.EnsureCreated();
}
// BaseItem resolves these through process-wide statics; restored in Dispose.
_previousLibraryManager = BaseItem.LibraryManager;
_previousConfigurationManager = BaseItem.ConfigurationManager;
@@ -59,20 +37,17 @@ public sealed class ItemPersistenceOwnedRowTests : IDisposable
configurationManager.Setup(c => c.Configuration).Returns(new ServerConfiguration());
BaseItem.ConfigurationManager = configurationManager.Object;
var factory = new Mock<IDbContextFactory<JellyfinDbContext>>();
factory.Setup(f => f.CreateDbContext()).Returns(CreateDbContext);
_service = new ItemPersistenceService(
factory.Object,
CreateDbContextFactory(),
new Mock<IServerApplicationHost>().Object,
NullLogger<ItemPersistenceService>.Instance);
}
public void Dispose()
protected override void Dispose(bool disposing)
{
BaseItem.LibraryManager = _previousLibraryManager!;
BaseItem.ConfigurationManager = _previousConfigurationManager!;
_connection.Dispose();
base.Dispose(disposing);
}
[Fact]
@@ -140,10 +115,4 @@ public sealed class ItemPersistenceOwnedRowTests : IDisposable
book.SetImage(new ItemImageInfo { Path = "/img/primary.jpg", Type = ImageType.Primary }, 0);
return book;
}
private JellyfinDbContext CreateDbContext() => new(
_dbOptions,
NullLogger<JellyfinDbContext>.Instance,
new SqliteDatabaseProvider(_applicationPaths, NullLogger<SqliteDatabaseProvider>.Instance),
new NoLockBehavior(NullLogger<NoLockBehavior>.Instance));
}
@@ -4,42 +4,27 @@ using Emby.Server.Implementations.Data;
using Jellyfin.Data.Enums;
using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Database.Implementations.Locking;
using Jellyfin.Database.Providers.Sqlite;
using Jellyfin.Server.Implementations.Item;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Persistence;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
using BaseItemKind = Jellyfin.Data.Enums.BaseItemKind;
namespace Jellyfin.Server.Implementations.Tests.Item;
public sealed class PeopleRepositoryUpdatePeopleTests : IDisposable
public sealed class PeopleRepositoryUpdatePeopleTests : SqliteDbTestFixture
{
private static readonly Guid _itemId = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
private readonly SqliteConnection _connection;
private readonly DbContextOptions<JellyfinDbContext> _dbOptions;
private readonly PeopleRepository _repository;
public PeopleRepositoryUpdatePeopleTests()
{
_connection = new SqliteConnection("Data Source=:memory:");
_connection.Open();
_dbOptions = new DbContextOptionsBuilder<JellyfinDbContext>()
.UseSqlite(_connection)
.Options;
var itemTypeLookup = new ItemTypeLookup();
using (var ctx = CreateDbContext())
{
ctx.Database.EnsureCreated();
ctx.BaseItems.Add(new BaseItemEntity
{
Id = _itemId,
@@ -53,20 +38,12 @@ public sealed class PeopleRepositoryUpdatePeopleTests : IDisposable
ctx.SaveChanges();
}
var factory = new Mock<IDbContextFactory<JellyfinDbContext>>();
factory.Setup(f => f.CreateDbContext()).Returns(CreateDbContext);
_repository = new PeopleRepository(
factory.Object,
CreateDbContextFactory(),
itemTypeLookup,
new Mock<IItemQueryHelpers>().Object);
}
public void Dispose()
{
_connection.Dispose();
}
[Fact]
public void UpdatePeople_SamePersonAndTypeWithDifferentRoles_KeepsEveryCredit()
{
@@ -174,13 +151,4 @@ public sealed class PeopleRepositoryUpdatePeopleTests : IDisposable
Role = role
};
}
private JellyfinDbContext CreateDbContext()
{
return new JellyfinDbContext(
_dbOptions,
NullLogger<JellyfinDbContext>.Instance,
new SqliteDatabaseProvider(null!, NullLogger<SqliteDatabaseProvider>.Instance),
new NoLockBehavior(NullLogger<NoLockBehavior>.Instance));
}
}
@@ -0,0 +1,85 @@
using System;
using Emby.Server.Implementations.Data;
using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.Locking;
using Jellyfin.Database.Providers.Sqlite;
using Jellyfin.Server.Implementations.Item;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Model.Configuration;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
namespace Jellyfin.Server.Implementations.Tests.Item;
/// <summary>
/// Base fixture for the item tests that run against the SQLite provider: one in-memory database per
/// test class, plus the wiring the repositories under test need. The connection owns the database, so
/// it stays open for the lifetime of the fixture. Derived classes seed in their own constructor.
/// </summary>
public abstract class SqliteDbTestFixture : IDisposable
{
private readonly SqliteConnection _connection;
private readonly DbContextOptions<JellyfinDbContext> _dbOptions;
protected SqliteDbTestFixture()
{
ApplicationPaths = new Mock<IApplicationPaths>().Object;
_connection = new SqliteConnection("Data Source=:memory:");
_connection.Open();
_dbOptions = new DbContextOptionsBuilder<JellyfinDbContext>()
.UseSqlite(_connection)
.Options;
using var context = CreateDbContext();
context.Database.EnsureCreated();
}
protected IApplicationPaths ApplicationPaths { get; }
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
protected JellyfinDbContext CreateDbContext() => new(
_dbOptions,
NullLogger<JellyfinDbContext>.Instance,
new SqliteDatabaseProvider(ApplicationPaths, NullLogger<SqliteDatabaseProvider>.Instance),
new NoLockBehavior(NullLogger<NoLockBehavior>.Instance));
protected IDbContextFactory<JellyfinDbContext> CreateDbContextFactory()
{
var factory = new Mock<IDbContextFactory<JellyfinDbContext>>();
factory.Setup(f => f.CreateDbContext()).Returns(CreateDbContext);
return factory.Object;
}
protected BaseItemRepository CreateBaseItemRepository(ItemTypeLookup itemTypeLookup)
{
var serverConfigurationManager = new Mock<IServerConfigurationManager>();
serverConfigurationManager.Setup(c => c.Configuration).Returns(new ServerConfiguration());
return new BaseItemRepository(
CreateDbContextFactory(),
new Mock<IServerApplicationHost>().Object,
itemTypeLookup,
serverConfigurationManager.Object,
NullLogger<BaseItemRepository>.Instance);
}
protected virtual void Dispose(bool disposing)
{
if (disposing)
{
_connection.Dispose();
}
}
}