aboutsummaryrefslogtreecommitdiff
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
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.
-rw-r--r--Emby.Server.Implementations/Library/Resolvers/TV/EpisodeResolver.cs26
-rw-r--r--Emby.Server.Implementations/Library/Resolvers/TV/SeasonResolver.cs25
-rw-r--r--MediaBrowser.Providers/Movies/ImdbExternalUrlProvider.cs13
-rw-r--r--MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeExternalId.cs25
-rw-r--r--MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonExternalId.cs25
-rw-r--r--MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesExternalId.cs5
-rw-r--r--tests/Jellyfin.Providers.Tests/ExternalId/AudioDbExternalUrlProviderTests.cs89
-rw-r--r--tests/Jellyfin.Providers.Tests/ExternalId/ComicVineExternalUrlProviderTests.cs56
-rw-r--r--tests/Jellyfin.Providers.Tests/ExternalId/GoogleBooksExternalUrlProviderTests.cs45
-rw-r--r--tests/Jellyfin.Providers.Tests/ExternalId/ImdbExternalUrlProviderTests.cs125
-rw-r--r--tests/Jellyfin.Providers.Tests/ExternalId/IsbnExternalUrlProviderTests.cs45
-rw-r--r--tests/Jellyfin.Providers.Tests/ExternalId/MusicBrainzExternalUrlProviderTests.cs201
-rw-r--r--tests/Jellyfin.Providers.Tests/ExternalId/TmdbExternalUrlProviderTests.cs193
-rw-r--r--tests/Jellyfin.Providers.Tests/ExternalId/Zap2ItExternalUrlProviderTests.cs33
-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
17 files changed, 1259 insertions, 4 deletions
diff --git a/Emby.Server.Implementations/Library/Resolvers/TV/EpisodeResolver.cs b/Emby.Server.Implementations/Library/Resolvers/TV/EpisodeResolver.cs
index 5fd23c9f50..85bf20cc2a 100644
--- a/Emby.Server.Implementations/Library/Resolvers/TV/EpisodeResolver.cs
+++ b/Emby.Server.Implementations/Library/Resolvers/TV/EpisodeResolver.cs
@@ -1,8 +1,10 @@
#nullable disable
using System;
+using System.IO;
using System.Linq;
using Emby.Naming.Common;
+using Emby.Server.Implementations.Library;
using Jellyfin.Data.Enums;
using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.Library;
@@ -81,10 +83,34 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV
episode.ParentIndexNumber = 1;
}
+ SetProviderIdFromPath(episode, args.Path);
+
return episode;
}
return null;
}
+
+ /// <summary>
+ /// Sets provider ids from the episode file name.
+ /// </summary>
+ /// <param name="item">The episode.</param>
+ /// <param name="path">The episode file path.</param>
+ private static void SetProviderIdFromPath(Episode item, string path)
+ {
+ var justName = Path.GetFileNameWithoutExtension(path.AsSpan());
+
+ var imdbId = justName.GetAttributeValue("imdbid");
+ item.TrySetProviderId(MetadataProvider.Imdb, imdbId);
+
+ var tvdbId = justName.GetAttributeValue("tvdbid");
+ item.TrySetProviderId(MetadataProvider.Tvdb, tvdbId);
+
+ var tvmazeId = justName.GetAttributeValue("tvmazeid");
+ item.TrySetProviderId(MetadataProvider.TvMaze, tvmazeId);
+
+ var tmdbId = justName.GetAttributeValue("tmdbid");
+ item.TrySetProviderId(MetadataProvider.Tmdb, tmdbId);
+ }
}
}
diff --git a/Emby.Server.Implementations/Library/Resolvers/TV/SeasonResolver.cs b/Emby.Server.Implementations/Library/Resolvers/TV/SeasonResolver.cs
index 6cb63a28a2..ad041086e5 100644
--- a/Emby.Server.Implementations/Library/Resolvers/TV/SeasonResolver.cs
+++ b/Emby.Server.Implementations/Library/Resolvers/TV/SeasonResolver.cs
@@ -1,10 +1,14 @@
#nullable disable
+using System;
using System.Globalization;
+using System.IO;
using Emby.Naming.Common;
using Emby.Naming.TV;
+using Emby.Server.Implementations.Library;
using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.Library;
+using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Globalization;
using Microsoft.Extensions.Logging;
@@ -91,10 +95,31 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV
args.LibraryOptions.PreferredMetadataLanguage);
}
+ SetProviderIdFromPath(season, path);
+
return season;
}
return null;
}
+
+ /// <summary>
+ /// Sets provider ids from the season folder name.
+ /// </summary>
+ /// <param name="item">The season.</param>
+ /// <param name="path">The season folder path.</param>
+ private static void SetProviderIdFromPath(Season item, string path)
+ {
+ var justName = Path.GetFileName(path.AsSpan());
+
+ var tvdbId = justName.GetAttributeValue("tvdbid");
+ item.TrySetProviderId(MetadataProvider.Tvdb, tvdbId);
+
+ var tvmazeId = justName.GetAttributeValue("tvmazeid");
+ item.TrySetProviderId(MetadataProvider.TvMaze, tvmazeId);
+
+ var tmdbId = justName.GetAttributeValue("tmdbid");
+ item.TrySetProviderId(MetadataProvider.Tmdb, tmdbId);
+ }
}
}
diff --git a/MediaBrowser.Providers/Movies/ImdbExternalUrlProvider.cs b/MediaBrowser.Providers/Movies/ImdbExternalUrlProvider.cs
index 980bac102e..67cb85de69 100644
--- a/MediaBrowser.Providers/Movies/ImdbExternalUrlProvider.cs
+++ b/MediaBrowser.Providers/Movies/ImdbExternalUrlProvider.cs
@@ -1,5 +1,6 @@
using System.Collections.Generic;
using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
@@ -17,6 +18,18 @@ public class ImdbExternalUrlProvider : IExternalUrlProvider
public IEnumerable<string> GetExternalUrls(BaseItem item)
{
var baseUrl = "https://www.imdb.com/";
+
+ if (item is Season season)
+ {
+ if (season.Series?.TryGetProviderId(MetadataProvider.Imdb, out var seriesImdbId) == true
+ && season.IndexNumber.HasValue)
+ {
+ yield return baseUrl + $"title/{seriesImdbId}/episodes/?season={season.IndexNumber.Value}";
+ }
+
+ yield break;
+ }
+
if (item.TryGetProviderId(MetadataProvider.Imdb, out var externalId))
{
if (item is Person)
diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeExternalId.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeExternalId.cs
new file mode 100644
index 0000000000..8d9d2d354b
--- /dev/null
+++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeExternalId.cs
@@ -0,0 +1,25 @@
+using MediaBrowser.Controller.Entities.TV;
+using MediaBrowser.Controller.Providers;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.Providers;
+
+namespace MediaBrowser.Providers.Plugins.Tmdb.TV
+{
+ /// <summary>
+ /// External id for a TMDb episode.
+ /// </summary>
+ public class TmdbEpisodeExternalId : IExternalId
+ {
+ /// <inheritdoc />
+ public string ProviderName => TmdbUtils.ProviderName;
+
+ /// <inheritdoc />
+ public string Key => MetadataProvider.Tmdb.ToString();
+
+ /// <inheritdoc />
+ public ExternalIdMediaType? Type => ExternalIdMediaType.Episode;
+
+ /// <inheritdoc />
+ public bool Supports(IHasProviderIds item) => item is Episode;
+ }
+}
diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonExternalId.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonExternalId.cs
new file mode 100644
index 0000000000..8191446363
--- /dev/null
+++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonExternalId.cs
@@ -0,0 +1,25 @@
+using MediaBrowser.Controller.Entities.TV;
+using MediaBrowser.Controller.Providers;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.Providers;
+
+namespace MediaBrowser.Providers.Plugins.Tmdb.TV
+{
+ /// <summary>
+ /// External id for a TMDb season.
+ /// </summary>
+ public class TmdbSeasonExternalId : IExternalId
+ {
+ /// <inheritdoc />
+ public string ProviderName => TmdbUtils.ProviderName;
+
+ /// <inheritdoc />
+ public string Key => MetadataProvider.Tmdb.ToString();
+
+ /// <inheritdoc />
+ public ExternalIdMediaType? Type => ExternalIdMediaType.Season;
+
+ /// <inheritdoc />
+ public bool Supports(IHasProviderIds item) => item is Season;
+ }
+}
diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesExternalId.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesExternalId.cs
index 840cec9841..477bcc6f0c 100644
--- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesExternalId.cs
+++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesExternalId.cs
@@ -20,9 +20,6 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV
public ExternalIdMediaType? Type => ExternalIdMediaType.Series;
/// <inheritdoc />
- public bool Supports(IHasProviderIds item)
- {
- return item is Series;
- }
+ public bool Supports(IHasProviderIds item) => item is Series;
}
}
diff --git a/tests/Jellyfin.Providers.Tests/ExternalId/AudioDbExternalUrlProviderTests.cs b/tests/Jellyfin.Providers.Tests/ExternalId/AudioDbExternalUrlProviderTests.cs
new file mode 100644
index 0000000000..a9161a0402
--- /dev/null
+++ b/tests/Jellyfin.Providers.Tests/ExternalId/AudioDbExternalUrlProviderTests.cs
@@ -0,0 +1,89 @@
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.Audio;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Providers.Plugins.AudioDb;
+using Xunit;
+
+namespace Jellyfin.Providers.Tests.ExternalId
+{
+ public sealed class AudioDbExternalUrlProviderTests
+ {
+ private readonly AudioDbAlbumExternalUrlProvider _albumProvider = new();
+ private readonly AudioDbArtistExternalUrlProvider _artistProvider = new();
+
+ [Fact]
+ public void GetExternalUrls_MusicAlbumWithAudioDbAlbumId_ReturnsCorrectUrl()
+ {
+ var album = new MusicAlbum();
+ album.SetProviderId(MetadataProvider.AudioDbAlbum, "12345");
+
+ var urls = _albumProvider.GetExternalUrls(album);
+
+ Assert.Contains("https://www.theaudiodb.com/album/12345", urls);
+ }
+
+ [Fact]
+ public void GetExternalUrls_MusicAlbumWithNoAudioDbAlbumId_ReturnsNoUrl()
+ {
+ var album = new MusicAlbum();
+
+ var urls = _albumProvider.GetExternalUrls(album);
+
+ Assert.Empty(urls);
+ }
+
+ [Fact]
+ public void GetExternalUrls_NonAlbumWithAudioDbAlbumId_ReturnsNoUrl()
+ {
+ var artist = new MusicArtist();
+ artist.SetProviderId(MetadataProvider.AudioDbAlbum, "12345");
+
+ var urls = _albumProvider.GetExternalUrls(artist);
+
+ Assert.Empty(urls);
+ }
+
+ [Fact]
+ public void GetExternalUrls_MusicArtistWithAudioDbArtistId_ReturnsCorrectUrl()
+ {
+ var artist = new MusicArtist();
+ artist.SetProviderId(MetadataProvider.AudioDbArtist, "67890");
+
+ var urls = _artistProvider.GetExternalUrls(artist);
+
+ Assert.Contains("https://www.theaudiodb.com/artist/67890", urls);
+ }
+
+ [Fact]
+ public void GetExternalUrls_PersonWithAudioDbArtistId_ReturnsCorrectUrl()
+ {
+ var person = new Person();
+ person.SetProviderId(MetadataProvider.AudioDbArtist, "67890");
+
+ var urls = _artistProvider.GetExternalUrls(person);
+
+ Assert.Contains("https://www.theaudiodb.com/artist/67890", urls);
+ }
+
+ [Fact]
+ public void GetExternalUrls_MusicArtistWithNoAudioDbArtistId_ReturnsNoUrl()
+ {
+ var artist = new MusicArtist();
+
+ var urls = _artistProvider.GetExternalUrls(artist);
+
+ Assert.Empty(urls);
+ }
+
+ [Fact]
+ public void GetExternalUrls_NonArtistWithAudioDbArtistId_ReturnsNoUrl()
+ {
+ var album = new MusicAlbum();
+ album.SetProviderId(MetadataProvider.AudioDbArtist, "67890");
+
+ var urls = _artistProvider.GetExternalUrls(album);
+
+ Assert.Empty(urls);
+ }
+ }
+}
diff --git a/tests/Jellyfin.Providers.Tests/ExternalId/ComicVineExternalUrlProviderTests.cs b/tests/Jellyfin.Providers.Tests/ExternalId/ComicVineExternalUrlProviderTests.cs
new file mode 100644
index 0000000000..99604e0933
--- /dev/null
+++ b/tests/Jellyfin.Providers.Tests/ExternalId/ComicVineExternalUrlProviderTests.cs
@@ -0,0 +1,56 @@
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.TV;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Providers.Plugins.ComicVine;
+using Xunit;
+
+namespace Jellyfin.Providers.Tests.ExternalId
+{
+ public sealed class ComicVineExternalUrlProviderTests
+ {
+ private readonly ComicVineExternalUrlProvider _provider = new();
+
+ [Fact]
+ public void GetExternalUrls_PersonWithComicVineId_ReturnsCorrectUrl()
+ {
+ var person = new Person();
+ person.SetProviderId("ComicVine", "person/4005-1234");
+
+ var urls = _provider.GetExternalUrls(person);
+
+ Assert.Contains("https://comicvine.gamespot.com/person/4005-1234", urls);
+ }
+
+ [Fact]
+ public void GetExternalUrls_BookWithComicVineId_ReturnsCorrectUrl()
+ {
+ var book = new Book();
+ book.SetProviderId("ComicVine", "issue/4000-5678");
+
+ var urls = _provider.GetExternalUrls(book);
+
+ Assert.Contains("https://comicvine.gamespot.com/issue/4000-5678", urls);
+ }
+
+ [Fact]
+ public void GetExternalUrls_PersonWithNoComicVineId_ReturnsNoUrl()
+ {
+ var person = new Person();
+
+ var urls = _provider.GetExternalUrls(person);
+
+ Assert.Empty(urls);
+ }
+
+ [Fact]
+ public void GetExternalUrls_NonSupportedItemWithComicVineId_ReturnsNoUrl()
+ {
+ var series = new Series();
+ series.SetProviderId("ComicVine", "volume/4050-9999");
+
+ var urls = _provider.GetExternalUrls(series);
+
+ Assert.Empty(urls);
+ }
+ }
+}
diff --git a/tests/Jellyfin.Providers.Tests/ExternalId/GoogleBooksExternalUrlProviderTests.cs b/tests/Jellyfin.Providers.Tests/ExternalId/GoogleBooksExternalUrlProviderTests.cs
new file mode 100644
index 0000000000..eec64ac53f
--- /dev/null
+++ b/tests/Jellyfin.Providers.Tests/ExternalId/GoogleBooksExternalUrlProviderTests.cs
@@ -0,0 +1,45 @@
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.TV;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Providers.Plugins.GoogleBooks;
+using Xunit;
+
+namespace Jellyfin.Providers.Tests.ExternalId
+{
+ public sealed class GoogleBooksExternalUrlProviderTests
+ {
+ private readonly GoogleBooksExternalUrlProvider _provider = new();
+
+ [Fact]
+ public void GetExternalUrls_BookWithGoogleBooksId_ReturnsCorrectUrl()
+ {
+ var book = new Book();
+ book.SetProviderId("GoogleBooks", "buc0AAAAMAAJ");
+
+ var urls = _provider.GetExternalUrls(book);
+
+ Assert.Contains("https://books.google.com/books?id=buc0AAAAMAAJ", urls);
+ }
+
+ [Fact]
+ public void GetExternalUrls_BookWithNoGoogleBooksId_ReturnsNoUrl()
+ {
+ var book = new Book();
+
+ var urls = _provider.GetExternalUrls(book);
+
+ Assert.Empty(urls);
+ }
+
+ [Fact]
+ public void GetExternalUrls_NonBookWithGoogleBooksId_ReturnsNoUrl()
+ {
+ var series = new Series();
+ series.SetProviderId("GoogleBooks", "buc0AAAAMAAJ");
+
+ var urls = _provider.GetExternalUrls(series);
+
+ Assert.Empty(urls);
+ }
+ }
+}
diff --git a/tests/Jellyfin.Providers.Tests/ExternalId/ImdbExternalUrlProviderTests.cs b/tests/Jellyfin.Providers.Tests/ExternalId/ImdbExternalUrlProviderTests.cs
new file mode 100644
index 0000000000..ed4a8e7478
--- /dev/null
+++ b/tests/Jellyfin.Providers.Tests/ExternalId/ImdbExternalUrlProviderTests.cs
@@ -0,0 +1,125 @@
+using System;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.Movies;
+using MediaBrowser.Controller.Entities.TV;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Providers.Movies;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Providers.Tests.ExternalId
+{
+ // put tests that mock the static LibraryManager in the same collection to avoid test interference
+ [Collection("LibraryManagerTests")]
+ public sealed class ImdbExternalUrlProviderTests : IDisposable
+ {
+ private readonly ImdbExternalUrlProvider _provider = new();
+ private readonly Mock<ILibraryManager> _libraryManagerMock = new();
+ private readonly ILibraryManager? _previousLibraryManager;
+
+ public ImdbExternalUrlProviderTests()
+ {
+ _previousLibraryManager = BaseItem.LibraryManager;
+ BaseItem.LibraryManager = _libraryManagerMock.Object;
+ }
+
+ public void Dispose()
+ {
+ BaseItem.LibraryManager = _previousLibraryManager;
+ }
+
+ [Fact]
+ public void GetExternalUrls_MovieWithImdbId_ReturnsCorrectUrl()
+ {
+ var movie = new Movie();
+ movie.SetProviderId(MetadataProvider.Imdb, "tt1234567");
+
+ var urls = _provider.GetExternalUrls(movie);
+
+ Assert.Contains("https://www.imdb.com/title/tt1234567", urls);
+ }
+
+ [Fact]
+ public void GetExternalUrls_SeriesWithImdbId_ReturnsCorrectUrl()
+ {
+ var series = new Series();
+ series.SetProviderId(MetadataProvider.Imdb, "tt7654321");
+
+ var urls = _provider.GetExternalUrls(series);
+
+ Assert.Contains("https://www.imdb.com/title/tt7654321", urls);
+ }
+
+ [Fact]
+ public void GetExternalUrls_EpisodeWithImdbId_ReturnsCorrectUrl()
+ {
+ var episode = new Episode();
+ episode.SetProviderId(MetadataProvider.Imdb, "tt9999999");
+
+ var urls = _provider.GetExternalUrls(episode);
+
+ Assert.Contains("https://www.imdb.com/title/tt9999999", urls);
+ }
+
+ [Fact]
+ public void GetExternalUrls_SeasonWithSeriesImdbId_ReturnsSeasonEpisodesUrl()
+ {
+ var series = new Series { Id = Guid.NewGuid() };
+ series.SetProviderId(MetadataProvider.Imdb, "tt1234567");
+
+ var season = new Season { IndexNumber = 2, SeriesId = series.Id };
+ _libraryManagerMock.Setup(m => m.GetItemById(series.Id)).Returns(series);
+
+ var urls = _provider.GetExternalUrls(season);
+
+ Assert.Contains("https://www.imdb.com/title/tt1234567/episodes/?season=2", urls);
+ }
+
+ [Fact]
+ public void GetExternalUrls_SeasonWithNoSeriesImdbId_ReturnsNoUrl()
+ {
+ var series = new Series { Id = Guid.NewGuid() };
+ var season = new Season { IndexNumber = 1, SeriesId = series.Id };
+ _libraryManagerMock.Setup(m => m.GetItemById(series.Id)).Returns(series);
+
+ var urls = _provider.GetExternalUrls(season);
+
+ Assert.Empty(urls);
+ }
+
+ [Fact]
+ public void GetExternalUrls_SeasonWithNoIndexNumber_ReturnsNoUrl()
+ {
+ var series = new Series { Id = Guid.NewGuid() };
+ series.SetProviderId(MetadataProvider.Imdb, "tt1234567");
+ var season = new Season { IndexNumber = null, SeriesId = series.Id };
+ _libraryManagerMock.Setup(m => m.GetItemById(series.Id)).Returns(series);
+
+ var urls = _provider.GetExternalUrls(season);
+
+ Assert.Empty(urls);
+ }
+
+ [Fact]
+ public void GetExternalUrls_SeasonWithUnknownSeriesId_ReturnsNoUrl()
+ {
+ var season = new Season { IndexNumber = 1, SeriesId = Guid.NewGuid() };
+ _libraryManagerMock.Setup(m => m.GetItemById(It.IsAny<Guid>())).Returns((BaseItem?)null);
+
+ var urls = _provider.GetExternalUrls(season);
+
+ Assert.Empty(urls);
+ }
+
+ [Fact]
+ public void GetExternalUrls_ItemWithNoImdbId_ReturnsNoUrl()
+ {
+ var movie = new Movie();
+
+ var urls = _provider.GetExternalUrls(movie);
+
+ Assert.Empty(urls);
+ }
+ }
+}
diff --git a/tests/Jellyfin.Providers.Tests/ExternalId/IsbnExternalUrlProviderTests.cs b/tests/Jellyfin.Providers.Tests/ExternalId/IsbnExternalUrlProviderTests.cs
new file mode 100644
index 0000000000..228a9d2656
--- /dev/null
+++ b/tests/Jellyfin.Providers.Tests/ExternalId/IsbnExternalUrlProviderTests.cs
@@ -0,0 +1,45 @@
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.TV;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Providers.Books.Isbn;
+using Xunit;
+
+namespace Jellyfin.Providers.Tests.ExternalId
+{
+ public sealed class IsbnExternalUrlProviderTests
+ {
+ private readonly IsbnExternalUrlProvider _provider = new();
+
+ [Fact]
+ public void GetExternalUrls_BookWithIsbnId_ReturnsCorrectUrl()
+ {
+ var book = new Book();
+ book.SetProviderId("ISBN", "9780306406157");
+
+ var urls = _provider.GetExternalUrls(book);
+
+ Assert.Contains("https://search.worldcat.org/search?q=bn:9780306406157", urls);
+ }
+
+ [Fact]
+ public void GetExternalUrls_BookWithNoIsbnId_ReturnsNoUrl()
+ {
+ var book = new Book();
+
+ var urls = _provider.GetExternalUrls(book);
+
+ Assert.Empty(urls);
+ }
+
+ [Fact]
+ public void GetExternalUrls_NonBookWithIsbnId_ReturnsNoUrl()
+ {
+ var series = new Series();
+ series.SetProviderId("ISBN", "9780306406157");
+
+ var urls = _provider.GetExternalUrls(series);
+
+ Assert.Empty(urls);
+ }
+ }
+}
diff --git a/tests/Jellyfin.Providers.Tests/ExternalId/MusicBrainzExternalUrlProviderTests.cs b/tests/Jellyfin.Providers.Tests/ExternalId/MusicBrainzExternalUrlProviderTests.cs
new file mode 100644
index 0000000000..920529bf73
--- /dev/null
+++ b/tests/Jellyfin.Providers.Tests/ExternalId/MusicBrainzExternalUrlProviderTests.cs
@@ -0,0 +1,201 @@
+using System;
+using System.Reflection;
+using MediaBrowser.Common;
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.Audio;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.Serialization;
+using MediaBrowser.Providers.Plugins.MusicBrainz;
+using MediaBrowser.Providers.Plugins.MusicBrainz.Configuration;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Providers.Tests.ExternalId
+{
+ public sealed class MusicBrainzExternalUrlProviderTests : IDisposable
+ {
+ private static readonly PropertyInfo _instanceProperty =
+ typeof(Plugin).GetProperty("Instance", BindingFlags.Public | BindingFlags.Static)!;
+
+ private static readonly MethodInfo _instanceSetter =
+ _instanceProperty.GetSetMethod(nonPublic: true)!;
+
+ private readonly Plugin? _previousPlugin;
+
+ public MusicBrainzExternalUrlProviderTests()
+ {
+ _previousPlugin = Plugin.Instance;
+
+ var appPathsMock = new Mock<IApplicationPaths>();
+ appPathsMock.Setup(p => p.PluginsPath).Returns(System.IO.Path.GetTempPath());
+ appPathsMock.Setup(p => p.PluginConfigurationsPath).Returns(System.IO.Path.GetTempPath());
+
+ var xmlSerializerMock = new Mock<IXmlSerializer>();
+ xmlSerializerMock
+ .Setup(s => s.DeserializeFromFile(typeof(PluginConfiguration), It.IsAny<string>()))
+ .Returns(new PluginConfiguration());
+
+ var appHostMock = new Mock<IApplicationHost>();
+ appHostMock.Setup(h => h.Name).Returns("Jellyfin");
+ appHostMock.Setup(h => h.ApplicationVersionString).Returns("1.0.0");
+ appHostMock.Setup(h => h.ApplicationUserAgentAddress).Returns("localhost");
+
+ _ = new Plugin(appPathsMock.Object, xmlSerializerMock.Object, appHostMock.Object);
+ }
+
+ public void Dispose()
+ {
+ _instanceSetter.Invoke(null, new object?[] { _previousPlugin });
+ }
+
+ [Fact]
+ public void GetExternalUrls_MusicAlbumWithMusicBrainzAlbumId_ReturnsCorrectUrl()
+ {
+ var album = new MusicAlbum();
+ album.SetProviderId(MetadataProvider.MusicBrainzAlbum, "a1b2c3d4-e5f6-7890-abcd-ef1234567890");
+
+ var urls = new MusicBrainzAlbumExternalUrlProvider().GetExternalUrls(album);
+
+ Assert.Contains(PluginConfiguration.DefaultServer + "/release/a1b2c3d4-e5f6-7890-abcd-ef1234567890", urls);
+ }
+
+ [Fact]
+ public void GetExternalUrls_MusicAlbumWithNoMusicBrainzAlbumId_ReturnsNoUrl()
+ {
+ var album = new MusicAlbum();
+
+ var urls = new MusicBrainzAlbumExternalUrlProvider().GetExternalUrls(album);
+
+ Assert.Empty(urls);
+ }
+
+ [Fact]
+ public void GetExternalUrls_NonAlbumWithMusicBrainzAlbumId_ReturnsNoUrl()
+ {
+ var artist = new MusicArtist();
+ artist.SetProviderId(MetadataProvider.MusicBrainzAlbum, "a1b2c3d4-e5f6-7890-abcd-ef1234567890");
+
+ var urls = new MusicBrainzAlbumExternalUrlProvider().GetExternalUrls(artist);
+
+ Assert.Empty(urls);
+ }
+
+ [Fact]
+ public void GetExternalUrls_MusicAlbumWithMusicBrainzAlbumArtistId_ReturnsCorrectUrl()
+ {
+ var album = new MusicAlbum();
+ album.SetProviderId(MetadataProvider.MusicBrainzAlbumArtist, "a1b2c3d4-e5f6-7890-abcd-ef1234567890");
+
+ var urls = new MusicBrainzAlbumArtistExternalUrlProvider().GetExternalUrls(album);
+
+ Assert.Contains(PluginConfiguration.DefaultServer + "/artist/a1b2c3d4-e5f6-7890-abcd-ef1234567890", urls);
+ }
+
+ [Fact]
+ public void GetExternalUrls_MusicAlbumWithNoMusicBrainzAlbumArtistId_ReturnsNoUrl()
+ {
+ var album = new MusicAlbum();
+
+ var urls = new MusicBrainzAlbumArtistExternalUrlProvider().GetExternalUrls(album);
+
+ Assert.Empty(urls);
+ }
+
+ [Fact]
+ public void GetExternalUrls_MusicArtistWithMusicBrainzArtistId_ReturnsCorrectUrl()
+ {
+ var artist = new MusicArtist();
+ artist.SetProviderId(MetadataProvider.MusicBrainzArtist, "a1b2c3d4-e5f6-7890-abcd-ef1234567890");
+
+ var urls = new MusicBrainzArtistExternalUrlProvider().GetExternalUrls(artist);
+
+ Assert.Contains(PluginConfiguration.DefaultServer + "/artist/a1b2c3d4-e5f6-7890-abcd-ef1234567890", urls);
+ }
+
+ [Fact]
+ public void GetExternalUrls_PersonWithMusicBrainzArtistId_ReturnsCorrectUrl()
+ {
+ var person = new Person();
+ person.SetProviderId(MetadataProvider.MusicBrainzArtist, "a1b2c3d4-e5f6-7890-abcd-ef1234567890");
+
+ var urls = new MusicBrainzArtistExternalUrlProvider().GetExternalUrls(person);
+
+ Assert.Contains(PluginConfiguration.DefaultServer + "/artist/a1b2c3d4-e5f6-7890-abcd-ef1234567890", urls);
+ }
+
+ [Fact]
+ public void GetExternalUrls_MusicArtistWithNoMusicBrainzArtistId_ReturnsNoUrl()
+ {
+ var artist = new MusicArtist();
+
+ var urls = new MusicBrainzArtistExternalUrlProvider().GetExternalUrls(artist);
+
+ Assert.Empty(urls);
+ }
+
+ [Fact]
+ public void GetExternalUrls_NonArtistWithMusicBrainzArtistId_ReturnsNoUrl()
+ {
+ var album = new MusicAlbum();
+ album.SetProviderId(MetadataProvider.MusicBrainzArtist, "a1b2c3d4-e5f6-7890-abcd-ef1234567890");
+
+ var urls = new MusicBrainzArtistExternalUrlProvider().GetExternalUrls(album);
+
+ Assert.Empty(urls);
+ }
+
+ [Fact]
+ public void GetExternalUrls_MusicAlbumWithMusicBrainzReleaseGroupId_ReturnsCorrectUrl()
+ {
+ var album = new MusicAlbum();
+ album.SetProviderId(MetadataProvider.MusicBrainzReleaseGroup, "a1b2c3d4-e5f6-7890-abcd-ef1234567890");
+
+ var urls = new MusicBrainzReleaseGroupExternalUrlProvider().GetExternalUrls(album);
+
+ Assert.Contains(PluginConfiguration.DefaultServer + "/release-group/a1b2c3d4-e5f6-7890-abcd-ef1234567890", urls);
+ }
+
+ [Fact]
+ public void GetExternalUrls_MusicAlbumWithNoMusicBrainzReleaseGroupId_ReturnsNoUrl()
+ {
+ var album = new MusicAlbum();
+
+ var urls = new MusicBrainzReleaseGroupExternalUrlProvider().GetExternalUrls(album);
+
+ Assert.Empty(urls);
+ }
+
+ [Fact]
+ public void GetExternalUrls_AudioWithMusicBrainzTrackId_ReturnsCorrectUrl()
+ {
+ var audio = new Audio();
+ audio.SetProviderId(MetadataProvider.MusicBrainzTrack, "a1b2c3d4-e5f6-7890-abcd-ef1234567890");
+
+ var urls = new MusicBrainzTrackExternalUrlProvider().GetExternalUrls(audio);
+
+ Assert.Contains(PluginConfiguration.DefaultServer + "/track/a1b2c3d4-e5f6-7890-abcd-ef1234567890", urls);
+ }
+
+ [Fact]
+ public void GetExternalUrls_AudioWithNoMusicBrainzTrackId_ReturnsNoUrl()
+ {
+ var audio = new Audio();
+
+ var urls = new MusicBrainzTrackExternalUrlProvider().GetExternalUrls(audio);
+
+ Assert.Empty(urls);
+ }
+
+ [Fact]
+ public void GetExternalUrls_NonAudioWithMusicBrainzTrackId_ReturnsNoUrl()
+ {
+ var album = new MusicAlbum();
+ album.SetProviderId(MetadataProvider.MusicBrainzTrack, "a1b2c3d4-e5f6-7890-abcd-ef1234567890");
+
+ var urls = new MusicBrainzTrackExternalUrlProvider().GetExternalUrls(album);
+
+ Assert.Empty(urls);
+ }
+ }
+}
diff --git a/tests/Jellyfin.Providers.Tests/ExternalId/TmdbExternalUrlProviderTests.cs b/tests/Jellyfin.Providers.Tests/ExternalId/TmdbExternalUrlProviderTests.cs
new file mode 100644
index 0000000000..814375a49c
--- /dev/null
+++ b/tests/Jellyfin.Providers.Tests/ExternalId/TmdbExternalUrlProviderTests.cs
@@ -0,0 +1,193 @@
+using System;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.Movies;
+using MediaBrowser.Controller.Entities.TV;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Providers.Plugins.Tmdb;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Providers.Tests.ExternalId
+{
+ // put tests that mock the static LibraryManager in the same collection to avoid test interference
+ [Collection("LibraryManagerTests")]
+ public sealed class TmdbExternalUrlProviderTests : IDisposable
+ {
+ private readonly TmdbExternalUrlProvider _provider = new();
+ private readonly Mock<ILibraryManager> _libraryManagerMock = new();
+ private readonly ILibraryManager? _previousLibraryManager;
+
+ public TmdbExternalUrlProviderTests()
+ {
+ _previousLibraryManager = BaseItem.LibraryManager;
+ BaseItem.LibraryManager = _libraryManagerMock.Object;
+ }
+
+ public void Dispose()
+ {
+ BaseItem.LibraryManager = _previousLibraryManager;
+ }
+
+ [Fact]
+ public void GetExternalUrls_SeriesWithTmdbId_ReturnsCorrectUrl()
+ {
+ var series = new Series();
+ series.SetProviderId(MetadataProvider.Tmdb, "1399");
+
+ var urls = _provider.GetExternalUrls(series);
+
+ Assert.Contains(TmdbUtils.BaseTmdbUrl + "tv/1399", urls);
+ }
+
+ [Fact]
+ public void GetExternalUrls_SeriesWithNoTmdbId_ReturnsNoUrl()
+ {
+ var series = new Series();
+
+ var urls = _provider.GetExternalUrls(series);
+
+ Assert.Empty(urls);
+ }
+
+ [Fact]
+ public void GetExternalUrls_SeasonWithSeriesTmdbId_ReturnsCorrectUrl()
+ {
+ var series = new Series { Id = Guid.NewGuid() };
+ series.SetProviderId(MetadataProvider.Tmdb, "1399");
+
+ var season = new Season { IndexNumber = 3, SeriesId = series.Id };
+ _libraryManagerMock.Setup(m => m.GetItemById(series.Id)).Returns(series);
+
+ var urls = _provider.GetExternalUrls(season);
+
+ Assert.Contains(TmdbUtils.BaseTmdbUrl + "tv/1399/season/3", urls);
+ }
+
+ [Fact]
+ public void GetExternalUrls_SeasonWithNoSeriesTmdbId_ReturnsNoUrl()
+ {
+ var series = new Series { Id = Guid.NewGuid() };
+ var season = new Season { IndexNumber = 1, SeriesId = series.Id };
+ _libraryManagerMock.Setup(m => m.GetItemById(series.Id)).Returns(series);
+
+ var urls = _provider.GetExternalUrls(season);
+
+ Assert.Empty(urls);
+ }
+
+ [Fact]
+ public void GetExternalUrls_SeasonWithNoIndexNumber_ReturnsNoUrl()
+ {
+ var series = new Series { Id = Guid.NewGuid() };
+ series.SetProviderId(MetadataProvider.Tmdb, "1399");
+ var season = new Season { IndexNumber = null, SeriesId = series.Id };
+ _libraryManagerMock.Setup(m => m.GetItemById(series.Id)).Returns(series);
+
+ var urls = _provider.GetExternalUrls(season);
+
+ Assert.Empty(urls);
+ }
+
+ [Fact]
+ public void GetExternalUrls_EpisodeWithSeriesTmdbId_ReturnsCorrectUrl()
+ {
+ var series = new Series { Id = Guid.NewGuid() };
+ series.SetProviderId(MetadataProvider.Tmdb, "1399");
+
+ var season = new Season { Id = Guid.NewGuid(), IndexNumber = 2, SeriesId = series.Id };
+
+ var episode = new Episode
+ {
+ IndexNumber = 5,
+ SeasonId = season.Id,
+ SeriesId = series.Id
+ };
+
+ _libraryManagerMock.Setup(m => m.GetItemById(series.Id)).Returns(series);
+ _libraryManagerMock.Setup(m => m.GetItemById(season.Id)).Returns(season);
+
+ var urls = _provider.GetExternalUrls(episode);
+
+ Assert.Contains(TmdbUtils.BaseTmdbUrl + "tv/1399/season/2/episode/5", urls);
+ }
+
+ [Fact]
+ public void GetExternalUrls_EpisodeWithNoSeriesTmdbId_ReturnsNoUrl()
+ {
+ var series = new Series { Id = Guid.NewGuid() };
+ var season = new Season { Id = Guid.NewGuid(), IndexNumber = 1, SeriesId = series.Id };
+ var episode = new Episode { IndexNumber = 1, SeasonId = season.Id, SeriesId = series.Id };
+
+ _libraryManagerMock.Setup(m => m.GetItemById(series.Id)).Returns(series);
+ _libraryManagerMock.Setup(m => m.GetItemById(season.Id)).Returns(season);
+
+ var urls = _provider.GetExternalUrls(episode);
+
+ Assert.Empty(urls);
+ }
+
+ [Fact]
+ public void GetExternalUrls_MovieWithTmdbId_ReturnsCorrectUrl()
+ {
+ var movie = new Movie();
+ movie.SetProviderId(MetadataProvider.Tmdb, "550");
+
+ var urls = _provider.GetExternalUrls(movie);
+
+ Assert.Contains(TmdbUtils.BaseTmdbUrl + "movie/550", urls);
+ }
+
+ [Fact]
+ public void GetExternalUrls_MovieWithNoTmdbId_ReturnsNoUrl()
+ {
+ var movie = new Movie();
+
+ var urls = _provider.GetExternalUrls(movie);
+
+ Assert.Empty(urls);
+ }
+
+ [Fact]
+ public void GetExternalUrls_PersonWithTmdbId_ReturnsCorrectUrl()
+ {
+ var person = new Person();
+ person.SetProviderId(MetadataProvider.Tmdb, "6384");
+
+ var urls = _provider.GetExternalUrls(person);
+
+ Assert.Contains(TmdbUtils.BaseTmdbUrl + "person/6384", urls);
+ }
+
+ [Fact]
+ public void GetExternalUrls_PersonWithNoTmdbId_ReturnsNoUrl()
+ {
+ var person = new Person();
+
+ var urls = _provider.GetExternalUrls(person);
+
+ Assert.Empty(urls);
+ }
+
+ [Fact]
+ public void GetExternalUrls_BoxSetWithTmdbId_ReturnsCorrectUrl()
+ {
+ var boxSet = new BoxSet();
+ boxSet.SetProviderId(MetadataProvider.Tmdb, "10");
+
+ var urls = _provider.GetExternalUrls(boxSet);
+
+ Assert.Contains(TmdbUtils.BaseTmdbUrl + "collection/10", urls);
+ }
+
+ [Fact]
+ public void GetExternalUrls_BoxSetWithNoTmdbId_ReturnsNoUrl()
+ {
+ var boxSet = new BoxSet();
+
+ var urls = _provider.GetExternalUrls(boxSet);
+
+ Assert.Empty(urls);
+ }
+ }
+}
diff --git a/tests/Jellyfin.Providers.Tests/ExternalId/Zap2ItExternalUrlProviderTests.cs b/tests/Jellyfin.Providers.Tests/ExternalId/Zap2ItExternalUrlProviderTests.cs
new file mode 100644
index 0000000000..dbe46d8fb1
--- /dev/null
+++ b/tests/Jellyfin.Providers.Tests/ExternalId/Zap2ItExternalUrlProviderTests.cs
@@ -0,0 +1,33 @@
+using MediaBrowser.Controller.Entities.TV;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Providers.TV;
+using Xunit;
+
+namespace Jellyfin.Providers.Tests.ExternalId
+{
+ public sealed class Zap2ItExternalUrlProviderTests
+ {
+ private readonly Zap2ItExternalUrlProvider _provider = new();
+
+ [Fact]
+ public void GetExternalUrls_ItemWithZap2ItId_ReturnsCorrectUrl()
+ {
+ var series = new Series();
+ series.SetProviderId(MetadataProvider.Zap2It, "EP012345678901");
+
+ var urls = _provider.GetExternalUrls(series);
+
+ Assert.Contains("http://tvlistings.zap2it.com/overview.html?programSeriesId=EP012345678901", urls);
+ }
+
+ [Fact]
+ public void GetExternalUrls_ItemWithNoZap2ItId_ReturnsNoUrl()
+ {
+ var series = new Series();
+
+ var urls = _provider.GetExternalUrls(series);
+
+ Assert.Empty(urls);
+ }
+ }
+}
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));
+ }
+ }
+}