aboutsummaryrefslogtreecommitdiff
path: root/tests/Jellyfin.Server.Implementations.Tests/Library
diff options
context:
space:
mode:
authorMarc Brooks <IDisposable@gmail.com>2026-03-25 22:10:40 +0000
committerMarc Brooks <IDisposable@gmail.com>2026-03-25 18:47:40 -0500
commitaa96ff42e616ecf5638a8f1e2e8459b94513c528 (patch)
tree3707f7a5d57cea52c731ade7c98a09242cb8758a /tests/Jellyfin.Server.Implementations.Tests/Library
parentbcc748e6649b3d1075b92af915fa9c2542255502 (diff)
Parse provider IDs from season and episode folder/file names
Season and episode directories/files can now include provider ID attributes in their names (e.g. "Season 01 [tvdbid=22222]" or "Show S01E01 [tmdbid=99999].mkv"), consistent with the existing behavior for series folders. Supported providers: imdbid, tvdbid, tvmazeid, tmdbid. Adds TmdbSeasonExternalId and TmdbEpisodeExternalId so that the TMDB season and episode IDs are surfaced in the metadata editor. Seasons do not have their own IMDb IDs, so we don't support imdbid parsing in SeasonResolver. Instead, generate IMDb season URLs via ImdbExternalUrlProvider using the parent series' IMDb ID and the season number, matching the IMDb URL format: imdb.com/title/{seriesId}/episodes/?season={N} Add tests for the ExternalUrlProviders.
Diffstat (limited to 'tests/Jellyfin.Server.Implementations.Tests/Library')
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Library/EpisodeResolverTest.cs88
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Library/SeasonResolverTests.cs145
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Library/SeriesResolverTests.cs124
3 files changed, 357 insertions, 0 deletions
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/EpisodeResolverTest.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/EpisodeResolverTest.cs
index cc2e47c33a..16b601dc3c 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Library/EpisodeResolverTest.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/Library/EpisodeResolverTest.cs
@@ -61,6 +61,94 @@ namespace Jellyfin.Server.Implementations.Tests.Library
Assert.NotNull(episodeResolver.Resolve(itemResolveArgs));
}
+ [Theory]
+ [InlineData("/media/Show/Season 01/Show S01E01 [tvdbid=12345].mkv", MetadataProvider.Tvdb, "12345")]
+ [InlineData("/media/Show/Season 01/Show S01E01 [tvdbid-12345].mkv", MetadataProvider.Tvdb, "12345")]
+ [InlineData("/media/Show/Season 01/Show S01E01 (tvdbid=12345).mkv", MetadataProvider.Tvdb, "12345")]
+ [InlineData("/media/Show/Season 02/Show S02E03 [tvmazeid=67890].mkv", MetadataProvider.TvMaze, "67890")]
+ [InlineData("/media/Show/Season 02/Show S02E03 [tvmazeid-67890].mkv", MetadataProvider.TvMaze, "67890")]
+ [InlineData("/media/Show/Season 03/Show S03E04 [tmdbid=99999].mkv", MetadataProvider.Tmdb, "99999")]
+ [InlineData("/media/Show/Season 03/Show S03E04 [tmdbid-99999].mkv", MetadataProvider.Tmdb, "99999")]
+ [InlineData("/media/Show/Season 04/Show S04E05 [imdbid=tt1234567].mkv", MetadataProvider.Imdb, "tt1234567")]
+ [InlineData("/media/Show/Season 04/Show S04E05 [imdbid-tt1234567].mkv", MetadataProvider.Imdb, "tt1234567")]
+ public void Resolve_EpisodeFileWithProviderId_SetsProviderId(string path, MetadataProvider provider, string expectedId)
+ {
+ var series = new Series { Name = "Show" };
+ var episodeResolver = new EpisodeResolverMock(Mock.Of<ILogger<EpisodeResolver>>(), _namingOptions, Mock.Of<IDirectoryService>());
+ var itemResolveArgs = new ItemResolveArgs(
+ Mock.Of<IServerApplicationPaths>(),
+ null)
+ {
+ Parent = series,
+ CollectionType = CollectionType.tvshows,
+ FileInfo = new FileSystemMetadata
+ {
+ FullName = path,
+ IsDirectory = false
+ }
+ };
+
+ var episode = episodeResolver.Resolve(itemResolveArgs);
+
+ Assert.NotNull(episode);
+ Assert.True(episode.TryGetProviderId(provider, out var actualId));
+ Assert.Equal(expectedId, actualId);
+ }
+
+ [Fact]
+ public void Resolve_EpisodeFileWithProviderIdsOnAllLevels_OnlyUsesEpisodeLevelId()
+ {
+ // Series folder has tvdbid=11111, season folder has tvdbid=22222, episode file has tvdbid=33333.
+ // The episode should only pick up its own ID, not the series- or season-level ones.
+ var series = new Series { Name = "Show" };
+ var episodeResolver = new EpisodeResolverMock(Mock.Of<ILogger<EpisodeResolver>>(), _namingOptions, Mock.Of<IDirectoryService>());
+ var itemResolveArgs = new ItemResolveArgs(
+ Mock.Of<IServerApplicationPaths>(),
+ null)
+ {
+ Parent = series,
+ CollectionType = CollectionType.tvshows,
+ FileInfo = new FileSystemMetadata
+ {
+ FullName = "/media/Show [tvdbid=11111]/Season 01 [tvdbid=22222]/Show S01E01 [tvdbid=33333].mkv",
+ IsDirectory = false
+ }
+ };
+
+ var episode = episodeResolver.Resolve(itemResolveArgs);
+
+ Assert.NotNull(episode);
+ Assert.True(episode.TryGetProviderId(MetadataProvider.Tvdb, out var tvdbId));
+ Assert.Equal("33333", tvdbId);
+ }
+
+ [Fact]
+ public void Resolve_EpisodeFileWithMultipleProviderIds_SetsAll()
+ {
+ var series = new Series { Name = "Show" };
+ var episodeResolver = new EpisodeResolverMock(Mock.Of<ILogger<EpisodeResolver>>(), _namingOptions, Mock.Of<IDirectoryService>());
+ var itemResolveArgs = new ItemResolveArgs(
+ Mock.Of<IServerApplicationPaths>(),
+ null)
+ {
+ Parent = series,
+ CollectionType = CollectionType.tvshows,
+ FileInfo = new FileSystemMetadata
+ {
+ FullName = "/media/Show/Season 01/Show S01E01 [tvdbid=12345][tmdbid=99999].mkv",
+ IsDirectory = false
+ }
+ };
+
+ var episode = episodeResolver.Resolve(itemResolveArgs);
+
+ Assert.NotNull(episode);
+ Assert.True(episode.TryGetProviderId(MetadataProvider.Tvdb, out var tvdbId));
+ Assert.Equal("12345", tvdbId);
+ Assert.True(episode.TryGetProviderId(MetadataProvider.Tmdb, out var tmdbId));
+ Assert.Equal("99999", tmdbId);
+ }
+
private sealed class EpisodeResolverMock : EpisodeResolver
{
public EpisodeResolverMock(ILogger<EpisodeResolver> logger, NamingOptions namingOptions, IDirectoryService directoryService) : base(logger, namingOptions, directoryService)
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/SeasonResolverTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/SeasonResolverTests.cs
new file mode 100644
index 0000000000..133a3f7d47
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Library/SeasonResolverTests.cs
@@ -0,0 +1,145 @@
+using Emby.Naming.Common;
+using Emby.Server.Implementations.Library.Resolvers.TV;
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.Entities.TV;
+using MediaBrowser.Model.Configuration;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.Globalization;
+using MediaBrowser.Model.IO;
+using Microsoft.Extensions.Logging;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Server.Implementations.Tests.Library
+{
+ public class SeasonResolverTests
+ {
+ private static readonly NamingOptions _namingOptions = new();
+ private readonly SeasonResolver _resolver;
+
+ public SeasonResolverTests()
+ {
+ var localizationMock = new Mock<ILocalizationManager>();
+ localizationMock
+ .Setup(l => l.GetLocalizedString(It.IsAny<string>()))
+ .Returns("Season {0}");
+
+ _resolver = new SeasonResolver(
+ _namingOptions,
+ localizationMock.Object,
+ Mock.Of<ILogger<SeasonResolver>>());
+ }
+
+ [Theory]
+ [InlineData("/media/Show/Season 01 [tvdbid=12345]", MetadataProvider.Tvdb, "12345")]
+ [InlineData("/media/Show/Season 01 [tvdbid-12345]", MetadataProvider.Tvdb, "12345")]
+ [InlineData("/media/Show/Season 01 (tvdbid=12345)", MetadataProvider.Tvdb, "12345")]
+ [InlineData("/media/Show/Season 02 [tvmazeid=67890]", MetadataProvider.TvMaze, "67890")]
+ [InlineData("/media/Show/Season 02 [tvmazeid-67890]", MetadataProvider.TvMaze, "67890")]
+ [InlineData("/media/Show/Season 03 [tmdbid=99999]", MetadataProvider.Tmdb, "99999")]
+ [InlineData("/media/Show/Season 03 [tmdbid-99999]", MetadataProvider.Tmdb, "99999")]
+ public void Resolve_SeasonFolderWithProviderId_SetsProviderId(string path, MetadataProvider provider, string expectedId)
+ {
+ var series = new Series { Path = "/media/Show" };
+
+ var args = new MediaBrowser.Controller.Library.ItemResolveArgs(
+ Mock.Of<IServerApplicationPaths>(),
+ null)
+ {
+ Parent = series,
+ LibraryOptions = new LibraryOptions(),
+ FileInfo = new FileSystemMetadata
+ {
+ FullName = path,
+ IsDirectory = true
+ }
+ };
+
+ var season = _resolver.Resolve(args);
+
+ Assert.NotNull(season);
+ Assert.True(season.TryGetProviderId(provider, out var actualId));
+ Assert.Equal(expectedId, actualId);
+ }
+
+ [Fact]
+ public void Resolve_SeasonFolderWithMultipleProviderIds_SetsAll()
+ {
+ var series = new Series { Path = "/media/Show" };
+
+ var args = new MediaBrowser.Controller.Library.ItemResolveArgs(
+ Mock.Of<IServerApplicationPaths>(),
+ null)
+ {
+ Parent = series,
+ LibraryOptions = new LibraryOptions(),
+ FileInfo = new FileSystemMetadata
+ {
+ FullName = "/media/Show/Season 01 [tvdbid=12345][tmdbid=99999]",
+ IsDirectory = true
+ }
+ };
+
+ var season = _resolver.Resolve(args);
+
+ Assert.NotNull(season);
+ Assert.True(season.TryGetProviderId(MetadataProvider.Tvdb, out var tvdbId));
+ Assert.Equal("12345", tvdbId);
+ Assert.True(season.TryGetProviderId(MetadataProvider.Tmdb, out var tmdbId));
+ Assert.Equal("99999", tmdbId);
+ }
+
+ [Fact]
+ public void Resolve_SeasonFolderWithSeriesProviderIdInParentPath_DoesNotInheritSeriesId()
+ {
+ // Series folder has tvdbid=11111, season folder has tvdbid=22222.
+ // The season should only pick up its own ID, not the series-level one.
+ var series = new Series { Path = "/media/Show [tvdbid=11111]" };
+
+ var args = new MediaBrowser.Controller.Library.ItemResolveArgs(
+ Mock.Of<IServerApplicationPaths>(),
+ null)
+ {
+ Parent = series,
+ LibraryOptions = new LibraryOptions(),
+ FileInfo = new FileSystemMetadata
+ {
+ FullName = "/media/Show [tvdbid=11111]/Season 01 [tvdbid=22222]",
+ IsDirectory = true
+ }
+ };
+
+ var season = _resolver.Resolve(args);
+
+ Assert.NotNull(season);
+ Assert.True(season.TryGetProviderId(MetadataProvider.Tvdb, out var tvdbId));
+ Assert.Equal("22222", tvdbId);
+ }
+
+ [Fact]
+ public void Resolve_SeasonFolderWithNoProviderId_HasNoProviderIds()
+ {
+ var series = new Series { Path = "/media/Show" };
+
+ var args = new MediaBrowser.Controller.Library.ItemResolveArgs(
+ Mock.Of<IServerApplicationPaths>(),
+ null)
+ {
+ Parent = series,
+ LibraryOptions = new LibraryOptions(),
+ FileInfo = new FileSystemMetadata
+ {
+ FullName = "/media/Show/Season 01",
+ IsDirectory = true
+ }
+ };
+
+ var season = _resolver.Resolve(args);
+
+ Assert.NotNull(season);
+ Assert.False(season.TryGetProviderId(MetadataProvider.Tvdb, out _));
+ Assert.False(season.TryGetProviderId(MetadataProvider.TvMaze, out _));
+ Assert.False(season.TryGetProviderId(MetadataProvider.Tmdb, out _));
+ }
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/SeriesResolverTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/SeriesResolverTests.cs
new file mode 100644
index 0000000000..8dbd5f5b41
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Library/SeriesResolverTests.cs
@@ -0,0 +1,124 @@
+using Emby.Naming.Common;
+using Emby.Server.Implementations.Library.Resolvers.TV;
+using Jellyfin.Data.Enums;
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.Entities.TV;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.IO;
+using Microsoft.Extensions.Logging;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Server.Implementations.Tests.Library
+{
+ public class SeriesResolverTests
+ {
+ private static readonly NamingOptions _namingOptions = new();
+ private readonly SeriesResolver _resolver;
+ private readonly Mock<ILibraryManager> _libraryManagerMock;
+
+ public SeriesResolverTests()
+ {
+ _libraryManagerMock = new Mock<ILibraryManager>();
+ // Return null so that configuredContentType != CollectionType.tvshows, allowing series resolution.
+ _libraryManagerMock
+ .Setup(m => m.GetConfiguredContentType(It.IsAny<string>()))
+ .Returns((CollectionType?)null);
+
+ _resolver = new SeriesResolver(Mock.Of<ILogger<SeriesResolver>>(), _namingOptions);
+ }
+
+ private MediaBrowser.Controller.Library.ItemResolveArgs MakeTvArgs(string path) =>
+ new(Mock.Of<IServerApplicationPaths>(), _libraryManagerMock.Object)
+ {
+ CollectionType = CollectionType.tvshows,
+ FileSystemChildren = [],
+ FileInfo = new FileSystemMetadata
+ {
+ FullName = path,
+ IsDirectory = true
+ }
+ };
+
+ [Theory]
+ [InlineData("/media/Show [tvdbid=12345]", MetadataProvider.Tvdb, "12345")]
+ [InlineData("/media/Show [tvdbid-12345]", MetadataProvider.Tvdb, "12345")]
+ [InlineData("/media/Show (tvdbid=12345)", MetadataProvider.Tvdb, "12345")]
+ [InlineData("/media/Show [tvmazeid=67890]", MetadataProvider.TvMaze, "67890")]
+ [InlineData("/media/Show [tvmazeid-67890]", MetadataProvider.TvMaze, "67890")]
+ [InlineData("/media/Show [tmdbid=99999]", MetadataProvider.Tmdb, "99999")]
+ [InlineData("/media/Show [tmdbid-99999]", MetadataProvider.Tmdb, "99999")]
+ [InlineData("/media/Show [imdbid=tt1234567]", MetadataProvider.Imdb, "tt1234567")]
+ [InlineData("/media/Show [imdbid-tt1234567]", MetadataProvider.Imdb, "tt1234567")]
+ public void ResolvePath_SeriesFolderWithProviderId_SetsProviderId(string path, MetadataProvider provider, string expectedId)
+ {
+ var series = _resolver.ResolvePath(MakeTvArgs(path)) as Series;
+
+ Assert.NotNull(series);
+ Assert.True(series.TryGetProviderId(provider, out var actualId));
+ Assert.Equal(expectedId, actualId);
+ }
+
+ [Theory]
+ [InlineData("/media/Show [anidbid=11111]", "AniDB", "11111")]
+ [InlineData("/media/Show [anilistid=22222]", "AniList", "22222")]
+ [InlineData("/media/Show [anisearchid=33333]", "AniSearch", "33333")]
+ public void ResolvePath_SeriesFolderWithAniProviderId_SetsProviderId(string path, string providerKey, string expectedId)
+ {
+ var series = _resolver.ResolvePath(MakeTvArgs(path)) as Series;
+
+ Assert.NotNull(series);
+ Assert.True(series.TryGetProviderId(providerKey, out var actualId));
+ Assert.Equal(expectedId, actualId);
+ }
+
+ [Fact]
+ public void ResolvePath_SeriesFolderWithMultipleProviderIds_SetsAll()
+ {
+ var series = _resolver.ResolvePath(MakeTvArgs("/media/Show [tvdbid=12345][tmdbid=99999]")) as Series;
+
+ Assert.NotNull(series);
+ Assert.True(series.TryGetProviderId(MetadataProvider.Tvdb, out var tvdbId));
+ Assert.Equal("12345", tvdbId);
+ Assert.True(series.TryGetProviderId(MetadataProvider.Tmdb, out var tmdbId));
+ Assert.Equal("99999", tmdbId);
+ }
+
+ [Fact]
+ public void ResolvePath_SeriesFolderWithNoProviderId_HasNoProviderIds()
+ {
+ var series = _resolver.ResolvePath(MakeTvArgs("/media/Show")) as Series;
+
+ Assert.NotNull(series);
+ Assert.False(series.TryGetProviderId(MetadataProvider.Tvdb, out _));
+ Assert.False(series.TryGetProviderId(MetadataProvider.TvMaze, out _));
+ Assert.False(series.TryGetProviderId(MetadataProvider.Tmdb, out _));
+ Assert.False(series.TryGetProviderId(MetadataProvider.Imdb, out _));
+ Assert.False(series.TryGetProviderId("AniDB", out _));
+ Assert.False(series.TryGetProviderId("AniList", out _));
+ Assert.False(series.TryGetProviderId("AniSearch", out _));
+ }
+
+ [Fact]
+ public void ResolvePath_SeriesFolderNotInTvShowsCollection_DoesNotResolve()
+ {
+ // Without CollectionType.tvshows, a plain folder with no tvshow.nfo and
+ // no season/episode children should not resolve as a Series.
+ var args = new MediaBrowser.Controller.Library.ItemResolveArgs(
+ Mock.Of<IServerApplicationPaths>(),
+ _libraryManagerMock.Object)
+ {
+ CollectionType = null,
+ FileSystemChildren = [],
+ FileInfo = new FileSystemMetadata
+ {
+ FullName = "/media/Show [tvdbid=12345]",
+ IsDirectory = true
+ }
+ };
+
+ Assert.Null(_resolver.ResolvePath(args));
+ }
+ }
+}