aboutsummaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
authorShadowghost <Ghost_of_Stone@web.de>2026-05-12 22:50:16 +0200
committerShadowghost <Ghost_of_Stone@web.de>2026-05-12 22:50:16 +0200
commit8f7c54ee5ef8647bc049499819606ad7946378ec (patch)
tree4411b82fd0d0660a426b869a5781782e6dee7500 /tests
parent5e82b61bab8c9461624fd2095fc9ccd11e33ce8d (diff)
parente9942c385775f33c70dbb4b910085ae2c563e898 (diff)
Merge remote-tracking branch 'upstream/master' into search-rebased
Diffstat (limited to 'tests')
-rw-r--r--tests/Jellyfin.MediaEncoding.Hls.Tests/Playlist/DynamicHlsPlaylistGeneratorTests.cs11
-rw-r--r--tests/Jellyfin.MediaEncoding.Tests/Probing/ProbeResultNormalizerTests.cs3
-rw-r--r--tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs3
-rw-r--r--tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-AndroidTVExoPlayer-NoHevcRotation.json162
-rw-r--r--tests/Jellyfin.Model.Tests/Test Data/MediaSourceInfo-mp4-hevc-aac-4000k-r180.json56
-rw-r--r--tests/Jellyfin.Naming.Tests/TV/EpisodeNumberTests.cs4
-rw-r--r--tests/Jellyfin.Naming.Tests/TV/EpisodeNumberWithoutSeasonTests.cs2
-rw-r--r--tests/Jellyfin.Naming.Tests/TV/SeasonNumberTests.cs2
-rw-r--r--tests/Jellyfin.Networking.Tests/NetworkParseTests.cs38
-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.cs202
-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.Providers.Tests/MediaInfo/FFProbeVideoInfoTests.cs21
-rw-r--r--tests/Jellyfin.Providers.Tests/MediaInfo/MediaInfoResolverTests.cs18
-rw-r--r--tests/Jellyfin.Providers.Tests/TV/EpisodeMetadataServiceTests.cs110
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Library/DotIgnoreIgnoreRuleTest.cs392
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Library/EpisodeResolverTest.cs88
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Library/MediaSourceManagerTests.cs116
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Library/SeasonResolverTests.cs145
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Library/SeriesResolverTests.cs124
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs34
-rw-r--r--tests/Jellyfin.Server.Integration.Tests/JellyfinApplicationFactory.cs2
-rw-r--r--tests/Jellyfin.XbmcMetadata.Tests/Parsers/MovieNfoParserTests.cs43
-rw-r--r--tests/Jellyfin.XbmcMetadata.Tests/Test Data/CommunityRating.nfo5
-rw-r--r--tests/Jellyfin.XbmcMetadata.Tests/Test Data/CommunityRating_Comma.nfo5
-rw-r--r--tests/Jellyfin.XbmcMetadata.Tests/Test Data/CommunityRating_OutOfRange.nfo5
31 files changed, 2160 insertions, 17 deletions
diff --git a/tests/Jellyfin.MediaEncoding.Hls.Tests/Playlist/DynamicHlsPlaylistGeneratorTests.cs b/tests/Jellyfin.MediaEncoding.Hls.Tests/Playlist/DynamicHlsPlaylistGeneratorTests.cs
index fc969527e8..1406c8ee91 100644
--- a/tests/Jellyfin.MediaEncoding.Hls.Tests/Playlist/DynamicHlsPlaylistGeneratorTests.cs
+++ b/tests/Jellyfin.MediaEncoding.Hls.Tests/Playlist/DynamicHlsPlaylistGeneratorTests.cs
@@ -15,10 +15,17 @@ namespace Jellyfin.MediaEncoding.Hls.Tests.Playlist
}
[Fact]
- public void ComputeSegments_InvalidDuration_ThrowsArgumentException()
+ public void ComputeSegments_ZeroDurationOvershoot_ClampsToDuration()
{
var keyframeData = new KeyframeData(0, new[] { MsToTicks(10000) });
- Assert.Throws<ArgumentException>(() => DynamicHlsPlaylistGenerator.ComputeSegments(keyframeData, 6000));
+ Assert.Equal(new[] { 10.0 }, DynamicHlsPlaylistGenerator.ComputeSegments(keyframeData, 6000));
+ }
+
+ [Fact]
+ public void ComputeSegments_MinorDurationOvershoot_ClampsToDuration()
+ {
+ var keyframeData = new KeyframeData(MsToTicks(9900), new[] { 0L, MsToTicks(5000), MsToTicks(10000) });
+ Assert.Equal(new[] { 10.0 }, DynamicHlsPlaylistGenerator.ComputeSegments(keyframeData, 6000));
}
[Theory]
diff --git a/tests/Jellyfin.MediaEncoding.Tests/Probing/ProbeResultNormalizerTests.cs b/tests/Jellyfin.MediaEncoding.Tests/Probing/ProbeResultNormalizerTests.cs
index 3369af0e84..198cdaa4fc 100644
--- a/tests/Jellyfin.MediaEncoding.Tests/Probing/ProbeResultNormalizerTests.cs
+++ b/tests/Jellyfin.MediaEncoding.Tests/Probing/ProbeResultNormalizerTests.cs
@@ -105,10 +105,12 @@ namespace Jellyfin.MediaEncoding.Tests.Probing
var audio1 = res.MediaStreams[1];
Assert.Equal("eac3", audio1.Codec);
+ Assert.True(audio1.IsOriginal);
Assert.Equal(AudioSpatialFormat.DolbyAtmos, audio1.AudioSpatialFormat);
var audio2 = res.MediaStreams[2];
Assert.Equal("dts", audio2.Codec);
+ Assert.False(audio2.IsOriginal);
Assert.Equal(AudioSpatialFormat.DTSX, audio2.AudioSpatialFormat);
Assert.Empty(res.Chapters);
@@ -156,6 +158,7 @@ namespace Jellyfin.MediaEncoding.Tests.Probing
Assert.Equal("aac", res.MediaStreams[1].Codec);
Assert.Equal(7, res.MediaStreams[1].Channels);
Assert.True(res.MediaStreams[1].IsDefault);
+ Assert.False(res.MediaStreams[1].IsOriginal);
Assert.Equal("eng", res.MediaStreams[1].Language);
Assert.Equal("Surround 6.1", res.MediaStreams[1].Title);
diff --git a/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs b/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs
index 8269ae58cd..0b103debad 100644
--- a/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs
+++ b/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs
@@ -171,6 +171,9 @@ namespace Jellyfin.Model.Tests
[InlineData("AndroidTVExoPlayer", "mkv-vp9-aac-srt-2600k", PlayMethod.DirectPlay)]
[InlineData("AndroidTVExoPlayer", "mkv-vp9-ac3-srt-2600k", PlayMethod.DirectPlay)]
[InlineData("AndroidTVExoPlayer", "mkv-vp9-vorbis-vtt-2600k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported, "Transcode")] // Full transcode because profile only has ts which does not allow vp9
+ [InlineData("AndroidTVExoPlayer", "mp4-hevc-aac-4000k-r180", PlayMethod.DirectPlay)] // #13712
+ // AndroidTV NoHevcRotation
+ [InlineData("AndroidTVExoPlayer-NoHevcRotation", "mp4-hevc-aac-4000k-r180", PlayMethod.Transcode, TranscodeReason.VideoRotationNotSupported, "Transcode")] // #13712
// Tizen 3 Stereo
[InlineData("Tizen3-stereo", "mp4-h264-aac-vtt-2600k", PlayMethod.DirectPlay)]
[InlineData("Tizen3-stereo", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.DirectPlay)]
diff --git a/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-AndroidTVExoPlayer-NoHevcRotation.json b/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-AndroidTVExoPlayer-NoHevcRotation.json
new file mode 100644
index 0000000000..341638bc52
--- /dev/null
+++ b/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-AndroidTVExoPlayer-NoHevcRotation.json
@@ -0,0 +1,162 @@
+{
+ "Name": "Jellyfin AndroidTV-ExoPlayer",
+ "EnableAlbumArtInDidl": false,
+ "EnableSingleAlbumArtLimit": false,
+ "EnableSingleSubtitleLimit": false,
+ "SupportedMediaTypes": "Audio,Photo,Video",
+ "MaxAlbumArtWidth": 0,
+ "MaxAlbumArtHeight": 0,
+ "MaxStreamingBitrate": 120000000,
+ "MaxStaticBitrate": 100000000,
+ "MusicStreamingTranscodingBitrate": 192000,
+ "TimelineOffsetSeconds": 0,
+ "RequiresPlainVideoItems": false,
+ "RequiresPlainFolders": false,
+ "EnableMSMediaReceiverRegistrar": false,
+ "IgnoreTranscodeByteRangeRequests": false,
+ "DirectPlayProfiles": [
+ {
+ "Container": "m4v,mov,xvid,vob,mkv,wmv,asf,ogm,ogv,mp4,webm",
+ "AudioCodec": "aac,mp3,mp2,aac_latm,alac,ac3,eac3,dca,dts,mlp,truehd,pcm_alaw,pcm_mulaw",
+ "VideoCodec": "h264,hevc,vp8,vp9,mpeg,mpeg2video",
+ "Type": "Video",
+ "$type": "DirectPlayProfile"
+ },
+ {
+ "Container": "aac,mp3,mp2,aac_latm,alac,ac3,eac3,dca,dts,mlp,truehd,pcm_alaw,pcm_mulaw,,pa,flac,wav,wma,ogg,oga,webma,ape,opus",
+ "Type": "Audio",
+ "$type": "DirectPlayProfile"
+ },
+ {
+ "Container": "jpg,jpeg,png,gif,web",
+ "Type": "Photo",
+ "$type": "DirectPlayProfile"
+ }
+ ],
+ "CodecProfiles": [
+ {
+ "Type": "Video",
+ "Conditions": [
+ {
+ "Condition": "EqualsAny",
+ "Property": "VideoProfile",
+ "Value": "main|main 10",
+ "IsRequired": false,
+ "$type": "ProfileCondition"
+ },
+ {
+ "Condition": "LessThanEqual",
+ "Property": "VideoLevel",
+ "Value": "51",
+ "IsRequired": false,
+ "$type": "ProfileCondition"
+ },
+ {
+ "Condition": "Equals",
+ "Property": "VideoRotation",
+ "Value": "0",
+ "IsRequired": false,
+ "$type": "ProfileCondition"
+ }
+ ],
+ "Codec": "hevc",
+ "$type": "CodecProfile"
+ }
+ ],
+ "TranscodingProfiles": [
+ {
+ "Container": "ts",
+ "Type": "Video",
+ "VideoCodec": "h264",
+ "AudioCodec": "aac,mp3",
+ "Protocol": "hls",
+ "EstimateContentLength": false,
+ "EnableMpegtsM2TsMode": false,
+ "TranscodeSeekInfo": "Auto",
+ "CopyTimestamps": false,
+ "Context": "Streaming",
+ "EnableSubtitlesInManifest": false,
+ "MinSegments": 0,
+ "SegmentLength": 0,
+ "$type": "TranscodingProfile"
+ },
+ {
+ "Container": "mp3",
+ "Type": "Audio",
+ "AudioCodec": "mp3",
+ "Protocol": "http",
+ "EstimateContentLength": false,
+ "EnableMpegtsM2TsMode": false,
+ "TranscodeSeekInfo": "Auto",
+ "CopyTimestamps": false,
+ "Context": "Streaming",
+ "EnableSubtitlesInManifest": false,
+ "MinSegments": 0,
+ "SegmentLength": 0,
+ "$type": "TranscodingProfile"
+ }
+ ],
+ "SubtitleProfiles": [
+ {
+ "Format": "srt",
+ "Method": "Embed",
+ "$type": "SubtitleProfile"
+ },
+ {
+ "Format": "srt",
+ "Method": "External",
+ "$type": "SubtitleProfile"
+ },
+ {
+ "Format": "subrip",
+ "Method": "Embed",
+ "$type": "SubtitleProfile"
+ },
+ {
+ "Format": "subrip",
+ "Method": "External",
+ "$type": "SubtitleProfile"
+ },
+ {
+ "Format": "ass",
+ "Method": "Encode",
+ "$type": "SubtitleProfile"
+ },
+ {
+ "Format": "ssa",
+ "Method": "Encode",
+ "$type": "SubtitleProfile"
+ },
+ {
+ "Format": "pgs",
+ "Method": "Encode",
+ "$type": "SubtitleProfile"
+ },
+ {
+ "Format": "pgssub",
+ "Method": "Encode",
+ "$type": "SubtitleProfile"
+ },
+ {
+ "Format": "dvdsub",
+ "Method": "Encode",
+ "$type": "SubtitleProfile"
+ },
+ {
+ "Format": "vtt",
+ "Method": "Embed",
+ "$type": "SubtitleProfile"
+ },
+ {
+ "Format": "sub",
+ "Method": "Embed",
+ "$type": "SubtitleProfile"
+ },
+ {
+ "Format": "idx",
+ "Method": "Embed",
+ "$type": "SubtitleProfile"
+ }
+ ],
+ "$type": "DeviceProfile"
+}
diff --git a/tests/Jellyfin.Model.Tests/Test Data/MediaSourceInfo-mp4-hevc-aac-4000k-r180.json b/tests/Jellyfin.Model.Tests/Test Data/MediaSourceInfo-mp4-hevc-aac-4000k-r180.json
new file mode 100644
index 0000000000..393b10171d
--- /dev/null
+++ b/tests/Jellyfin.Model.Tests/Test Data/MediaSourceInfo-mp4-hevc-aac-4000k-r180.json
@@ -0,0 +1,56 @@
+{
+ "Id": "b7a9e2d4c815f36b0d9241a7e58c3f42",
+ "Path": "/Media/MyVideo-1080p.mp4",
+ "Container": "mov,mp4,m4a,3gp,3g2,mj2",
+ "Size": 1421636271,
+ "Name": "MyVideo-1080p",
+ "ETag": "d8e2a1b5c4f907e8a1d2b3c4e5f6a7b8",
+ "RunTimeTicks": 25801230336,
+ "SupportsTranscoding": true,
+ "SupportsDirectStream": true,
+ "SupportsDirectPlay": true,
+ "SupportsProbing": true,
+ "MediaStreams": [
+ {
+ "Codec": "hevc",
+ "CodecTag": "hvc1",
+ "Language": "eng",
+ "TimeBase": "1/11988",
+ "VideoRange": "SDR",
+ "DisplayTitle": "1080p HEVC SDR",
+ "NalLengthSize": "0",
+ "BitRate": 4014613,
+ "BitDepth": 8,
+ "RefFrames": 1,
+ "IsDefault": true,
+ "Height": 1080,
+ "Width": 1920,
+ "AverageFrameRate": 23.976,
+ "RealFrameRate": 23.976,
+ "Profile": "Main",
+ "Type": 1,
+ "AspectRatio": "16:9",
+ "PixelFormat": "yuv420p",
+ "Level": 50,
+ "Rotation": 180
+ },
+ {
+ "Codec": "aac",
+ "CodecTag": "mp4a",
+ "Language": "eng",
+ "TimeBase": "1/48000",
+ "DisplayTitle": "En - AAC - Stereo - Default",
+ "ChannelLayout": "stereo",
+ "BitRate": 125427,
+ "Channels": 2,
+ "SampleRate": 48000,
+ "IsDefault": true,
+ "Profile": "LC",
+ "Index": 1,
+ "Score": 203
+ }
+ ],
+ "Bitrate": 4331578,
+ "DefaultAudioStreamIndex": 1,
+ "DefaultSubtitleStreamIndex": 2
+}
diff --git a/tests/Jellyfin.Naming.Tests/TV/EpisodeNumberTests.cs b/tests/Jellyfin.Naming.Tests/TV/EpisodeNumberTests.cs
index 7bfab570b7..abdade8f6d 100644
--- a/tests/Jellyfin.Naming.Tests/TV/EpisodeNumberTests.cs
+++ b/tests/Jellyfin.Naming.Tests/TV/EpisodeNumberTests.cs
@@ -80,7 +80,9 @@ namespace Jellyfin.Naming.Tests.TV
[InlineData("[VCB-Studio] Re Zero kara Hajimeru Isekai Seikatsu [21][Ma10p_1080p][x265_flac].mkv", 21)]
[InlineData("[CASO&Sumisora][Oda_Nobuna_no_Yabou][04][BDRIP][1920x1080][x264_AAC][7620E503].mp4", 4)]
- // [InlineData("Case Closed (1996-2007)/Case Closed - 317.mkv", 317)] // triple digit episode number
+ [InlineData("Case Closed (1996-2007)/Case Closed - 317.mkv", 317)] // triple digit episode number
+ [InlineData("Season 2/Hunter X Hunter - 101.mkv", 101)] // triple digit episode number without brackets
+ [InlineData("Season 1/Show Name - 1234 [720p].mkv", 1234)] // four digit episode number with quality tag
// TODO: [InlineData("Season 2/16 12 Some Title.avi", 16)]
// TODO: [InlineData("Season 4/Uchuu.Senkan.Yamato.2199.E03.avi", 3)]
// TODO: [InlineData("Season 2/7 12 Angry Men.avi", 7)]
diff --git a/tests/Jellyfin.Naming.Tests/TV/EpisodeNumberWithoutSeasonTests.cs b/tests/Jellyfin.Naming.Tests/TV/EpisodeNumberWithoutSeasonTests.cs
index 7f2188a3eb..9fef6c517a 100644
--- a/tests/Jellyfin.Naming.Tests/TV/EpisodeNumberWithoutSeasonTests.cs
+++ b/tests/Jellyfin.Naming.Tests/TV/EpisodeNumberWithoutSeasonTests.cs
@@ -18,7 +18,7 @@ namespace Jellyfin.Naming.Tests.TV
[InlineData(2, "The Simpsons/The Simpsons - 02.avi")]
[InlineData(2, "The Simpsons/The Simpsons - 02 Ep Name.avi")]
[InlineData(7, "GJ Club (2013)/GJ Club - 07.mkv")]
- [InlineData(17, "Case Closed (1996-2007)/Case Closed - 317.mkv")]
+ [InlineData(317, "Case Closed (1996-2007)/Case Closed - 317.mkv")]
// TODO: [InlineData(2, @"The Simpsons/The Simpsons 5 - 02 - Ep Name.avi")]
// TODO: [InlineData(2, @"The Simpsons/The Simpsons 5 - 02 Ep Name.avi")]
// TODO: [InlineData(7, @"Seinfeld/Seinfeld 0807 The Checks.avi")]
diff --git a/tests/Jellyfin.Naming.Tests/TV/SeasonNumberTests.cs b/tests/Jellyfin.Naming.Tests/TV/SeasonNumberTests.cs
index ab825c9fa7..09bae2adab 100644
--- a/tests/Jellyfin.Naming.Tests/TV/SeasonNumberTests.cs
+++ b/tests/Jellyfin.Naming.Tests/TV/SeasonNumberTests.cs
@@ -52,7 +52,7 @@ namespace Jellyfin.Naming.Tests.TV
[InlineData("Season 2009/S2009E23-E24-E26 - The Woman.mp4", 2009)]
[InlineData("Series/1-12 - The Woman.mp4", 1)]
[InlineData("Running Man/Running Man S2017E368.mkv", 2017)]
- [InlineData("Case Closed (1996-2007)/Case Closed - 317.mkv", 3)]
+ [InlineData("Case Closed (1996-2007)/Case Closed - 317.mkv", null)]
// TODO: [InlineData(@"Seinfeld/Seinfeld 0807 The Checks.avi", 8)]
public void GetSeasonNumberFromEpisodeFileTest(string path, int? expected)
{
diff --git a/tests/Jellyfin.Networking.Tests/NetworkParseTests.cs b/tests/Jellyfin.Networking.Tests/NetworkParseTests.cs
index b63009d6a5..66eec077dc 100644
--- a/tests/Jellyfin.Networking.Tests/NetworkParseTests.cs
+++ b/tests/Jellyfin.Networking.Tests/NetworkParseTests.cs
@@ -7,6 +7,7 @@ using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Net;
using MediaBrowser.Model.Net;
using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
@@ -94,10 +95,47 @@ namespace Jellyfin.Networking.Tests
[InlineData("256.128.0.0.0.1")]
[InlineData("fd23:184f:2029:0:3139:7386:67d7:d517:1231")]
[InlineData("[fd23:184f:2029:0:3139:7386:67d7:d517:1231]")]
+ [InlineData("fd23:184f:2029:0100/56")]
public static void TryParseInvalidIPStringsFalse(string address)
=> Assert.False(NetworkUtils.TryParseToSubnet(address, out _));
/// <summary>
+ /// Verifies that <see cref="NetworkUtils.TryParseToSubnets"/> emits a targeted warning
+ /// for IPv6 prefix-only notation and a generic warning for other malformed entries.
+ /// </summary>
+ [Fact]
+ public static void TryParseToSubnets_InvalidEntries_LogsWarnings()
+ {
+ var logger = new Mock<ILogger>();
+
+ var values = new[] { "10.0.0.0/8", "fd23:184f:2029:0100/56", "not-an-address" };
+ Assert.True(NetworkUtils.TryParseToSubnets(values, out var result, false, logger.Object));
+ Assert.NotNull(result);
+ Assert.Single(result);
+
+ // IPv6 prefix-only notation should produce a specific, actionable warning.
+ logger.Verify(
+ l => l.Log(
+ LogLevel.Warning,
+ It.IsAny<EventId>(),
+ It.Is<It.IsAnyType>((state, _) => state.ToString()!.Contains("IPv6 prefix-only", StringComparison.Ordinal)
+ && state.ToString()!.Contains("fd23:184f:2029:0100/56", StringComparison.Ordinal)),
+ It.IsAny<Exception>(),
+ It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
+ Times.Once);
+
+ // Other malformed entries should still produce a generic warning.
+ logger.Verify(
+ l => l.Log(
+ LogLevel.Warning,
+ It.IsAny<EventId>(),
+ It.Is<It.IsAnyType>((state, _) => state.ToString()!.Contains("not-an-address", StringComparison.Ordinal)),
+ It.IsAny<Exception>(),
+ It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
+ Times.Once);
+ }
+
+ /// <summary>
/// Checks if IPv4 address is within a defined subnet.
/// </summary>
/// <param name="netMask">Network mask.</param>
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..d35211f387
--- /dev/null
+++ b/tests/Jellyfin.Providers.Tests/ExternalId/MusicBrainzExternalUrlProviderTests.cs
@@ -0,0 +1,202 @@
+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 Microsoft.Extensions.Logging.Abstractions;
+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, NullLogger<Plugin>.Instance);
+ }
+
+ 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.Providers.Tests/MediaInfo/FFProbeVideoInfoTests.cs b/tests/Jellyfin.Providers.Tests/MediaInfo/FFProbeVideoInfoTests.cs
index 76922af8d5..a7491f42e9 100644
--- a/tests/Jellyfin.Providers.Tests/MediaInfo/FFProbeVideoInfoTests.cs
+++ b/tests/Jellyfin.Providers.Tests/MediaInfo/FFProbeVideoInfoTests.cs
@@ -45,8 +45,9 @@ public class FFProbeVideoInfoTests
[Theory]
[InlineData(null, 0)]
[InlineData(0L, 0)]
- [InlineData(1L, 0)]
- [InlineData(TimeSpan.TicksPerMinute * 5, 0)]
+ [InlineData(1L, 1)]
+ [InlineData(TimeSpan.TicksPerMinute * 3, 1)]
+ [InlineData(TimeSpan.TicksPerMinute * 5, 1)]
[InlineData((TimeSpan.TicksPerMinute * 5) + 1, 1)]
[InlineData(TimeSpan.TicksPerMinute * 50, 10)]
public void CreateDummyChapters_ValidRuntime_CorrectChaptersCount(long? runtime, int chaptersCount)
@@ -58,4 +59,20 @@ public class FFProbeVideoInfoTests
Assert.Equal(chaptersCount, chapters.Length);
}
+
+ [Theory]
+ [InlineData(1L)]
+ [InlineData(TimeSpan.TicksPerMinute * 3)]
+ [InlineData(TimeSpan.TicksPerMinute * 5)]
+ [InlineData((TimeSpan.TicksPerMinute * 5) + 1)]
+ [InlineData((TimeSpan.TicksPerMinute * 50) + 1)]
+ public void CreateDummyChapters_PositiveRuntime_NoChapterBeyondRuntime(long runtime)
+ {
+ var chapters = _fFProbeVideoInfo.CreateDummyChapters(new Video()
+ {
+ RunTimeTicks = runtime
+ });
+
+ Assert.All(chapters, chapter => Assert.True(chapter.StartPositionTicks < runtime));
+ }
}
diff --git a/tests/Jellyfin.Providers.Tests/MediaInfo/MediaInfoResolverTests.cs b/tests/Jellyfin.Providers.Tests/MediaInfo/MediaInfoResolverTests.cs
index 222e624aa2..876f18741f 100644
--- a/tests/Jellyfin.Providers.Tests/MediaInfo/MediaInfoResolverTests.cs
+++ b/tests/Jellyfin.Providers.Tests/MediaInfo/MediaInfoResolverTests.cs
@@ -123,13 +123,13 @@ public class MediaInfoResolverTests
var directoryService = new Mock<IDirectoryService>(MockBehavior.Strict);
// any path other than test target exists and provides an empty listing
- directoryService.Setup(ds => ds.GetFilePaths(It.IsAny<string>(), It.IsAny<bool>(), It.IsAny<bool>()))
+ directoryService.Setup(ds => ds.GetFilePaths(It.IsAny<string>(), It.IsAny<bool>()))
.Returns(Array.Empty<string>());
_subtitleResolver.GetExternalFiles(video.Object, directoryService.Object, false);
directoryService.Verify(
- ds => ds.GetFilePaths(It.IsRegex(pathNotFoundRegex), It.IsAny<bool>(), It.IsAny<bool>()),
+ ds => ds.GetFilePaths(It.IsRegex(pathNotFoundRegex), It.IsAny<bool>()),
Times.Never);
}
@@ -196,7 +196,7 @@ public class MediaInfoResolverTests
};
var directoryService = new Mock<IDirectoryService>(MockBehavior.Strict);
- directoryService.Setup(ds => ds.GetFilePaths(It.IsAny<string>(), It.IsAny<bool>(), It.IsAny<bool>()))
+ directoryService.Setup(ds => ds.GetFilePaths(It.IsAny<string>(), It.IsAny<bool>()))
.Returns(Array.Empty<string>());
var mediaEncoder = Mock.Of<IMediaEncoder>(MockBehavior.Strict);
@@ -341,9 +341,9 @@ public class MediaInfoResolverTests
}
var directoryService = new Mock<IDirectoryService>(MockBehavior.Strict);
- directoryService.Setup(ds => ds.GetFilePaths(It.IsRegex(VideoDirectoryRegex), It.IsAny<bool>(), It.IsAny<bool>()))
+ directoryService.Setup(ds => ds.GetFilePaths(It.IsRegex(VideoDirectoryRegex), It.IsAny<bool>()))
.Returns(files);
- directoryService.Setup(ds => ds.GetFilePaths(It.IsRegex(MetadataDirectoryRegex), It.IsAny<bool>(), It.IsAny<bool>()))
+ directoryService.Setup(ds => ds.GetFilePaths(It.IsRegex(MetadataDirectoryRegex), It.IsAny<bool>()))
.Returns(Array.Empty<string>());
List<MediaStream> GenerateMediaStreams()
@@ -413,16 +413,16 @@ public class MediaInfoResolverTests
var directoryService = new Mock<IDirectoryService>(MockBehavior.Strict);
if (useMetadataDirectory)
{
- directoryService.Setup(ds => ds.GetFilePaths(It.IsRegex(VideoDirectoryRegex), It.IsAny<bool>(), It.IsAny<bool>()))
+ directoryService.Setup(ds => ds.GetFilePaths(It.IsRegex(VideoDirectoryRegex), It.IsAny<bool>()))
.Returns(Array.Empty<string>());
- directoryService.Setup(ds => ds.GetFilePaths(It.IsRegex(MetadataDirectoryRegex), It.IsAny<bool>(), It.IsAny<bool>()))
+ directoryService.Setup(ds => ds.GetFilePaths(It.IsRegex(MetadataDirectoryRegex), It.IsAny<bool>()))
.Returns(new[] { MetadataDirectoryPath + "/" + file });
}
else
{
- directoryService.Setup(ds => ds.GetFilePaths(It.IsRegex(VideoDirectoryRegex), It.IsAny<bool>(), It.IsAny<bool>()))
+ directoryService.Setup(ds => ds.GetFilePaths(It.IsRegex(VideoDirectoryRegex), It.IsAny<bool>()))
.Returns(new[] { VideoDirectoryPath + "/" + file });
- directoryService.Setup(ds => ds.GetFilePaths(It.IsRegex(MetadataDirectoryRegex), It.IsAny<bool>(), It.IsAny<bool>()))
+ directoryService.Setup(ds => ds.GetFilePaths(It.IsRegex(MetadataDirectoryRegex), It.IsAny<bool>()))
.Returns(Array.Empty<string>());
}
diff --git a/tests/Jellyfin.Providers.Tests/TV/EpisodeMetadataServiceTests.cs b/tests/Jellyfin.Providers.Tests/TV/EpisodeMetadataServiceTests.cs
new file mode 100644
index 0000000000..8f5b1b3c48
--- /dev/null
+++ b/tests/Jellyfin.Providers.Tests/TV/EpisodeMetadataServiceTests.cs
@@ -0,0 +1,110 @@
+using System;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Entities.TV;
+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 MediaBrowser.Providers.TV;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Providers.Tests.TV;
+
+public class EpisodeMetadataServiceTests
+{
+ private readonly TestEpisodeMetadataService _service = new();
+
+ [Fact]
+ public void MergeData_ProviderSeasonOverridesPathDerivedSeason()
+ {
+ var source = new MetadataResult<Episode>
+ {
+ Item = new Episode
+ {
+ ParentIndexNumber = 2
+ }
+ };
+
+ var target = new MetadataResult<Episode>
+ {
+ Item = new Episode
+ {
+ ParentIndexNumber = 1
+ }
+ };
+
+ _service.Merge(source, target, replaceData: false, mergeMetadataSettings: true);
+
+ Assert.Equal(2, target.Item.ParentIndexNumber);
+ }
+
+ [Fact]
+ public void MergeData_BackfillExistingMetadata_DoesNotOverrideProviderSeason()
+ {
+ var existingMetadata = new MetadataResult<Episode>
+ {
+ Item = new Episode
+ {
+ ParentIndexNumber = 1
+ }
+ };
+
+ var temp = new MetadataResult<Episode>
+ {
+ Item = new Episode
+ {
+ ParentIndexNumber = 2
+ }
+ };
+
+ _service.Merge(existingMetadata, temp, replaceData: false, mergeMetadataSettings: false);
+
+ Assert.Equal(2, temp.Item.ParentIndexNumber);
+ }
+
+ [Fact]
+ public void MergeData_MissingProviderSeasonKeepsExistingSeason()
+ {
+ var source = new MetadataResult<Episode>
+ {
+ Item = new Episode()
+ };
+
+ var target = new MetadataResult<Episode>
+ {
+ Item = new Episode
+ {
+ ParentIndexNumber = 1
+ }
+ };
+
+ _service.Merge(source, target, replaceData: false, mergeMetadataSettings: true);
+
+ Assert.Equal(1, target.Item.ParentIndexNumber);
+ }
+
+ private sealed class TestEpisodeMetadataService : EpisodeMetadataService
+ {
+ public TestEpisodeMetadataService()
+ : base(
+ Mock.Of<IServerConfigurationManager>(),
+ NullLogger<EpisodeMetadataService>.Instance,
+ Mock.Of<IProviderManager>(),
+ Mock.Of<IFileSystem>(),
+ Mock.Of<ILibraryManager>(),
+ Mock.Of<IExternalDataManager>(),
+ Mock.Of<IItemRepository>())
+ {
+ }
+
+ public void Merge(MetadataResult<Episode> source, MetadataResult<Episode> target, bool replaceData, bool mergeMetadataSettings)
+ {
+ MergeData(source, target, Array.Empty<MetadataField>(), replaceData, mergeMetadataSettings);
+ }
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/DotIgnoreIgnoreRuleTest.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/DotIgnoreIgnoreRuleTest.cs
index a7bbef7ed4..03c0b4af39 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Library/DotIgnoreIgnoreRuleTest.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/Library/DotIgnoreIgnoreRuleTest.cs
@@ -1,4 +1,9 @@
+using System;
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
using Emby.Server.Implementations.Library;
+using MediaBrowser.Model.IO;
using Xunit;
namespace Jellyfin.Server.Implementations.Tests.Library;
@@ -78,4 +83,391 @@ public class DotIgnoreIgnoreRuleTest
// Without normalization, Windows paths with backslashes won't match patterns expecting forward slashes
Assert.False(DotIgnoreIgnoreRule.CheckIgnoreRules(path, _rule1, isDirectory: false, normalizePath: false));
}
+
+ [Fact]
+ public void CacheHit_RepeatedCallsDoNotRereadFiles()
+ {
+ var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
+ Directory.CreateDirectory(tempDir);
+ var subDir = Path.Combine(tempDir, "subdir");
+ Directory.CreateDirectory(subDir);
+
+ try
+ {
+ var ignoreFilePath = Path.Combine(tempDir, ".ignore");
+ File.WriteAllText(ignoreFilePath, "*.tmp");
+
+ var rule = new DotIgnoreIgnoreRule();
+ var fileInfo = new FileSystemMetadata
+ {
+ FullName = Path.Combine(subDir, "test.tmp"),
+ IsDirectory = false
+ };
+
+ // First call - should cache
+ var result1 = rule.ShouldIgnore(fileInfo, null);
+ Assert.True(result1);
+
+ // Second call - should use cache
+ var result2 = rule.ShouldIgnore(fileInfo, null);
+ Assert.True(result2);
+
+ // Third call with different file in same directory - should use cache
+ var fileInfo2 = new FileSystemMetadata
+ {
+ FullName = Path.Combine(subDir, "other.tmp"),
+ IsDirectory = false
+ };
+ var result3 = rule.ShouldIgnore(fileInfo2, null);
+ Assert.True(result3);
+
+ // Call with file that doesn't match pattern
+ var fileInfo3 = new FileSystemMetadata
+ {
+ FullName = Path.Combine(subDir, "other.txt"),
+ IsDirectory = false
+ };
+ var result4 = rule.ShouldIgnore(fileInfo3, null);
+ Assert.False(result4);
+ }
+ finally
+ {
+ Directory.Delete(tempDir, true);
+ }
+ }
+
+ [Fact]
+ public void CacheInvalidation_ModifyIgnoreFile_Reparses()
+ {
+ var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
+ Directory.CreateDirectory(tempDir);
+
+ try
+ {
+ var ignoreFilePath = Path.Combine(tempDir, ".ignore");
+ File.WriteAllText(ignoreFilePath, "*.tmp");
+
+ var rule = new DotIgnoreIgnoreRule();
+ var fileInfo = new FileSystemMetadata
+ {
+ FullName = Path.Combine(tempDir, "test.tmp"),
+ IsDirectory = false
+ };
+
+ // First call - should ignore .tmp files
+ var result1 = rule.ShouldIgnore(fileInfo, null);
+ Assert.True(result1);
+
+ // Modify the .ignore file to ignore .txt instead
+ // Wait a bit to ensure the file modification time changes
+ Thread.Sleep(50);
+ File.WriteAllText(ignoreFilePath, "*.txt");
+
+ // Now .tmp files should NOT be ignored
+ var result2 = rule.ShouldIgnore(fileInfo, null);
+ Assert.False(result2);
+
+ // And .txt files SHOULD be ignored
+ var txtFileInfo = new FileSystemMetadata
+ {
+ FullName = Path.Combine(tempDir, "test.txt"),
+ IsDirectory = false
+ };
+ var result3 = rule.ShouldIgnore(txtFileInfo, null);
+ Assert.True(result3);
+ }
+ finally
+ {
+ Directory.Delete(tempDir, true);
+ }
+ }
+
+ [Fact]
+ public void EmptyIgnoreFile_IgnoresEverything()
+ {
+ var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
+ Directory.CreateDirectory(tempDir);
+
+ try
+ {
+ var ignoreFilePath = Path.Combine(tempDir, ".ignore");
+ File.WriteAllText(ignoreFilePath, string.Empty);
+
+ var rule = new DotIgnoreIgnoreRule();
+
+ var fileInfo = new FileSystemMetadata
+ {
+ FullName = Path.Combine(tempDir, "anyfile.mkv"),
+ IsDirectory = false
+ };
+
+ // Empty .ignore file should ignore everything
+ var result = rule.ShouldIgnore(fileInfo, null);
+ Assert.True(result);
+ }
+ finally
+ {
+ Directory.Delete(tempDir, true);
+ }
+ }
+
+ [Fact]
+ public void WhitespaceOnlyIgnoreFile_IgnoresEverything()
+ {
+ var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
+ Directory.CreateDirectory(tempDir);
+
+ try
+ {
+ var ignoreFilePath = Path.Combine(tempDir, ".ignore");
+ File.WriteAllText(ignoreFilePath, " \n\t\n ");
+
+ var rule = new DotIgnoreIgnoreRule();
+
+ var fileInfo = new FileSystemMetadata
+ {
+ FullName = Path.Combine(tempDir, "anyfile.mkv"),
+ IsDirectory = false
+ };
+
+ // Whitespace-only .ignore file should ignore everything
+ var result = rule.ShouldIgnore(fileInfo, null);
+ Assert.True(result);
+ }
+ finally
+ {
+ Directory.Delete(tempDir, true);
+ }
+ }
+
+ [Fact]
+ public void NoIgnoreFile_DoesNotIgnore()
+ {
+ var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
+ Directory.CreateDirectory(tempDir);
+
+ try
+ {
+ var rule = new DotIgnoreIgnoreRule();
+
+ var fileInfo = new FileSystemMetadata
+ {
+ FullName = Path.Combine(tempDir, "anyfile.mkv"),
+ IsDirectory = false
+ };
+
+ // No .ignore file means don't ignore
+ var result = rule.ShouldIgnore(fileInfo, null);
+ Assert.False(result);
+ }
+ finally
+ {
+ Directory.Delete(tempDir, true);
+ }
+ }
+
+ [Fact]
+ public void ConcurrentAccess_ThreadSafe()
+ {
+ var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
+ Directory.CreateDirectory(tempDir);
+
+ try
+ {
+ var ignoreFilePath = Path.Combine(tempDir, ".ignore");
+ File.WriteAllText(ignoreFilePath, "*.tmp");
+
+ var rule = new DotIgnoreIgnoreRule();
+
+ // Run multiple parallel checks
+ Parallel.For(0, 100, i =>
+ {
+ var fileInfo = new FileSystemMetadata
+ {
+ FullName = Path.Combine(tempDir, $"test{i}.tmp"),
+ IsDirectory = false
+ };
+
+ var result = rule.ShouldIgnore(fileInfo, null);
+ Assert.True(result);
+ });
+
+ // Also test with non-matching files
+ Parallel.For(0, 100, i =>
+ {
+ var fileInfo = new FileSystemMetadata
+ {
+ FullName = Path.Combine(tempDir, $"test{i}.txt"),
+ IsDirectory = false
+ };
+
+ var result = rule.ShouldIgnore(fileInfo, null);
+ Assert.False(result);
+ });
+ }
+ finally
+ {
+ Directory.Delete(tempDir, true);
+ }
+ }
+
+ [Fact]
+ public void ClearCache_ClearsAllCachedData()
+ {
+ var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
+ Directory.CreateDirectory(tempDir);
+
+ try
+ {
+ var ignoreFilePath = Path.Combine(tempDir, ".ignore");
+ File.WriteAllText(ignoreFilePath, "*.tmp");
+
+ var rule = new DotIgnoreIgnoreRule();
+ var fileInfo = new FileSystemMetadata
+ {
+ FullName = Path.Combine(tempDir, "test.tmp"),
+ IsDirectory = false
+ };
+
+ // First call to populate cache
+ var result1 = rule.ShouldIgnore(fileInfo, null);
+ Assert.True(result1);
+
+ // Clear cache
+ rule.ClearDirectoryCache();
+
+ // Should still work (will re-populate cache)
+ var result2 = rule.ShouldIgnore(fileInfo, null);
+ Assert.True(result2);
+ }
+ finally
+ {
+ Directory.Delete(tempDir, true);
+ }
+ }
+
+ [Fact]
+ public void IgnoreFileDeleted_HandlesGracefully()
+ {
+ var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
+ Directory.CreateDirectory(tempDir);
+
+ try
+ {
+ var ignoreFilePath = Path.Combine(tempDir, ".ignore");
+ File.WriteAllText(ignoreFilePath, "*.tmp");
+
+ var rule = new DotIgnoreIgnoreRule();
+ var fileInfo = new FileSystemMetadata
+ {
+ FullName = Path.Combine(tempDir, "test.tmp"),
+ IsDirectory = false
+ };
+
+ // First call - should ignore
+ var result1 = rule.ShouldIgnore(fileInfo, null);
+ Assert.True(result1);
+
+ // Delete the .ignore file
+ File.Delete(ignoreFilePath);
+
+ // Should not ignore anymore (file deleted)
+ var result2 = rule.ShouldIgnore(fileInfo, null);
+ Assert.False(result2);
+ }
+ finally
+ {
+ if (Directory.Exists(tempDir))
+ {
+ Directory.Delete(tempDir, true);
+ }
+ }
+ }
+
+ [Fact]
+ public void ParentDirectoryIgnoreFile_AppliesToSubdirectories()
+ {
+ var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
+ Directory.CreateDirectory(tempDir);
+ var subDir1 = Path.Combine(tempDir, "sub1");
+ var subDir2 = Path.Combine(tempDir, "sub1", "sub2");
+ Directory.CreateDirectory(subDir1);
+ Directory.CreateDirectory(subDir2);
+
+ try
+ {
+ // Put .ignore in root
+ var ignoreFilePath = Path.Combine(tempDir, ".ignore");
+ File.WriteAllText(ignoreFilePath, "*.tmp");
+
+ var rule = new DotIgnoreIgnoreRule();
+
+ // Check file in sub2 - should find .ignore in parent
+ var fileInfo = new FileSystemMetadata
+ {
+ FullName = Path.Combine(subDir2, "test.tmp"),
+ IsDirectory = false
+ };
+
+ var result = rule.ShouldIgnore(fileInfo, null);
+ Assert.True(result);
+
+ // Check file in sub1
+ var fileInfo2 = new FileSystemMetadata
+ {
+ FullName = Path.Combine(subDir1, "test.tmp"),
+ IsDirectory = false
+ };
+
+ var result2 = rule.ShouldIgnore(fileInfo2, null);
+ Assert.True(result2);
+ }
+ finally
+ {
+ Directory.Delete(tempDir, true);
+ }
+ }
+
+ [Fact]
+ public void DirectoryMatching_TrailingSlashPattern()
+ {
+ var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
+ Directory.CreateDirectory(tempDir);
+ var subDir = Path.Combine(tempDir, "videos");
+ Directory.CreateDirectory(subDir);
+
+ try
+ {
+ var ignoreFilePath = Path.Combine(tempDir, ".ignore");
+ File.WriteAllText(ignoreFilePath, "videos/");
+
+ var rule = new DotIgnoreIgnoreRule();
+
+ // Directory should be ignored
+ var dirInfo = new FileSystemMetadata
+ {
+ FullName = subDir,
+ IsDirectory = true
+ };
+
+ var result = rule.ShouldIgnore(dirInfo, null);
+ Assert.True(result);
+
+ // File named "videos" should NOT be ignored (pattern has trailing slash)
+ var fileInfo = new FileSystemMetadata
+ {
+ FullName = Path.Combine(tempDir, "videos"),
+ IsDirectory = false
+ };
+
+ // Note: The Ignore library behavior may vary here, this tests the actual behavior
+ var resultFile = rule.ShouldIgnore(fileInfo, null);
+ // The file named "videos" without trailing slash might or might not match depending on the library
+ // This test documents the actual behavior
+ }
+ finally
+ {
+ Directory.Delete(tempDir, true);
+ }
+ }
}
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/MediaSourceManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/MediaSourceManagerTests.cs
index 8ed3d8b944..facdb2bc2e 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Library/MediaSourceManagerTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/Library/MediaSourceManagerTests.cs
@@ -1,9 +1,18 @@
+using System;
using AutoFixture;
using AutoFixture.AutoMoq;
+using Castle.Components.DictionaryAdapter;
using Emby.Server.Implementations.IO;
using Emby.Server.Implementations.Library;
+using Jellyfin.Database.Implementations.Entities;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.Globalization;
using MediaBrowser.Model.IO;
using MediaBrowser.Model.MediaInfo;
+using Moq;
using Xunit;
namespace Jellyfin.Server.Implementations.Tests.Library
@@ -11,12 +20,28 @@ namespace Jellyfin.Server.Implementations.Tests.Library
public class MediaSourceManagerTests
{
private readonly MediaSourceManager _mediaSourceManager;
+ private readonly Mock<IUserDataManager> _mockUserDataManager;
+ private readonly Mock<ILocalizationManager> _mockLocalizationManager;
+ private Video _item;
+ private User _user;
public MediaSourceManagerTests()
{
IFixture fixture = new Fixture().Customize(new AutoMoqCustomization { ConfigureMembers = true });
fixture.Inject<IFileSystem>(fixture.Create<ManagedFileSystem>());
+
+ _mockUserDataManager = fixture.Freeze<Mock<IUserDataManager>>();
+ _mockUserDataManager.Setup(m => m.GetUserData(It.IsAny<User>(), It.IsAny<BaseItem>())).Returns(new UserItemData() { Key = "key" });
+
+ _mockLocalizationManager = fixture.Create<Mock<ILocalizationManager>>();
+ _mockLocalizationManager.Setup(m => m.FindLanguageInfo(It.IsAny<string>())).Returns((string s) => string.IsNullOrEmpty(s) ? null : new CultureDto(s, s, s, new EditableList<string> { s }));
+ fixture.Inject(_mockLocalizationManager.Object);
+
_mediaSourceManager = fixture.Create<MediaSourceManager>();
+
+ _item = new Video { Id = Guid.NewGuid(), OwnerId = Guid.Empty, ParentId = Guid.Empty };
+
+ _user = fixture.Create<User>();
}
[Theory]
@@ -28,5 +53,96 @@ namespace Jellyfin.Server.Implementations.Tests.Library
[InlineData("rtsp://media.example.com:554/twister/audiotrack", MediaProtocol.Rtsp)]
public void GetPathProtocol_ValidArg_Correct(string path, MediaProtocol expected)
=> Assert.Equal(expected, _mediaSourceManager.GetPathProtocol(path));
+
+ [Theory]
+ [InlineData(5, "eng", "eng", false, true)]
+ [InlineData(5, "eng", "eng", true, true)]
+ [InlineData(2, "ger", "eng", false, true)]
+ [InlineData(2, "ger", "eng", true, true)]
+ [InlineData(1, "fre", "eng", false, true)]
+ [InlineData(2, "fre", "eng", true, true)]
+ [InlineData(5, "OriginalLanguage", "eng", false, false)]
+ [InlineData(4, "OriginalLanguage", "eng", false, true)]
+ [InlineData(5, "OriginalLanguage", "eng", true, false)]
+ [InlineData(5, "OriginalLanguage", "eng", true, true)]
+ [InlineData(2, "OriginalLanguage", "jpn", true, true)]
+ [InlineData(2, "OriginalLanguage", "jpn", false, true)]
+ [InlineData(2, "OriginalLanguage", "jpn,eng", false, true)]
+ [InlineData(4, "OriginalLanguage", null, false, true)]
+ [InlineData(2, "OriginalLanguage", null, true, true)]
+ [InlineData(4, "OriginalLanguage", "", false, true)]
+ [InlineData(2, "OriginalLanguage", "", false, false)]
+ [InlineData(2, "OriginalLanguage", "ger", false, true)]
+ [InlineData(2, "OriginalLanguage", "ger", false, false)]
+ [InlineData(1, "OriginalLanguage", "fre", false, false)]
+ [InlineData(2, "OriginalLanguage", "fre", true, true)]
+ [InlineData(2, "OriginalLanguage", "fre", true, false)]
+ public void SetDefaultAudioStreamIndex_Index_Correct(
+ int expectedIndex,
+ string prefferedLanguage,
+ string? originalLanguage,
+ bool playDefault,
+ bool originalExist)
+ {
+ var streams = new MediaStream[]
+ {
+ new()
+ {
+ Index = 0,
+ Type = MediaStreamType.Video,
+ IsDefault = true
+ },
+ new()
+ {
+ Index = 1,
+ Type = MediaStreamType.Audio,
+ Language = "fre",
+ IsDefault = false,
+ IsOriginal = false
+ },
+ new()
+ {
+ Index = 2,
+ Type = MediaStreamType.Audio,
+ Language = "jpn",
+ IsDefault = true,
+ IsOriginal = false
+ },
+ new()
+ {
+ Index = 3,
+ Type = MediaStreamType.Audio,
+ Language = "eng",
+ IsDefault = false,
+ IsOriginal = false
+ },
+ new()
+ {
+ Index = 4,
+ Type = MediaStreamType.Audio,
+ Language = "eng",
+ IsDefault = false,
+ IsOriginal = originalExist,
+ },
+ new()
+ {
+ Index = 5,
+ Type = MediaStreamType.Audio,
+ Language = "eng",
+ IsDefault = true,
+ IsOriginal = false,
+ }
+ };
+ var mediaInfo = new MediaSourceInfo
+ {
+ MediaStreams = streams
+ };
+ _user.AudioLanguagePreference = prefferedLanguage;
+ _user.PlayDefaultAudioTrack = playDefault;
+ _item.OriginalLanguage = originalLanguage;
+
+ _mediaSourceManager.SetDefaultAudioAndSubtitleStreamIndices(_item, mediaInfo, _user);
+ Assert.Equal(expectedIndex, mediaInfo.DefaultAudioStreamIndex);
+ }
}
}
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));
+ }
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs
index 5bcfc580ff..acabaf3acb 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs
@@ -242,6 +242,40 @@ namespace Jellyfin.Server.Implementations.Tests.Localization
}
[Theory]
+ [InlineData("US:INVALID", "US")] // Colon separator, known country code, unknown rating
+ [InlineData("us:INVALID", "US")] // Colon separator, lowercase country code
+ [InlineData("DE-INVALID", "US")] // Hyphen separator, known language prefix, unknown rating
+ [InlineData("ca:INVALID", "US")] // Colon separator, known country code (Canada)
+ public async Task GetRatingScore_UnknownRatingWithKnownCountry_ReturnsNull(string rating, string countryCode)
+ {
+ var localizationManager = Setup(new ServerConfiguration
+ {
+ MetadataCountryCode = countryCode
+ });
+ await localizationManager.LoadAll();
+
+ Assert.Null(localizationManager.GetRatingScore(rating));
+ }
+
+ [Theory]
+ [InlineData("us:R", "DE", 17, 0)] // Colon separator, explicit US country, valid US rating
+ [InlineData("US:PG-13", "DE", 13, 0)] // Colon separator, explicit US country, valid US rating
+ [InlineData("ca:R", "US", 18, 1)] // Colon separator, Canada country code, valid CA rating
+ public async Task GetRatingScore_ValidRatingWithCountrySeparator_ReturnsScore(string rating, string countryCode, int expectedScore, int? expectedSubScore)
+ {
+ var localizationManager = Setup(new ServerConfiguration
+ {
+ MetadataCountryCode = countryCode
+ });
+ await localizationManager.LoadAll();
+
+ var score = localizationManager.GetRatingScore(rating);
+ Assert.NotNull(score);
+ Assert.Equal(expectedScore, score.Score);
+ Assert.Equal(expectedSubScore, score.SubScore);
+ }
+
+ [Theory]
[InlineData("Default", "Default")]
[InlineData("HeaderLiveTV", "Live TV")]
public void GetLocalizedString_Valid_Success(string key, string expected)
diff --git a/tests/Jellyfin.Server.Integration.Tests/JellyfinApplicationFactory.cs b/tests/Jellyfin.Server.Integration.Tests/JellyfinApplicationFactory.cs
index 0952fb8b63..54f443de2d 100644
--- a/tests/Jellyfin.Server.Integration.Tests/JellyfinApplicationFactory.cs
+++ b/tests/Jellyfin.Server.Integration.Tests/JellyfinApplicationFactory.cs
@@ -111,7 +111,7 @@ namespace Jellyfin.Server.Integration.Tests
var appHost = (TestAppHost)host.Services.GetRequiredService<IApplicationHost>();
appHost.ServiceProvider = host.Services;
var applicationPaths = appHost.ServiceProvider.GetRequiredService<IApplicationPaths>();
- Program.ApplyStartupMigrationAsync((ServerApplicationPaths)applicationPaths, appHost.ServiceProvider.GetRequiredService<IConfiguration>()).GetAwaiter().GetResult();
+ Program.ApplyStartupMigrationAsync((ServerApplicationPaths)applicationPaths, appHost.ServiceProvider.GetRequiredService<IConfiguration>(), new()).GetAwaiter().GetResult();
Program.ApplyCoreMigrationsAsync(appHost.ServiceProvider, Migrations.Stages.JellyfinMigrationStageTypes.CoreInitialisation).GetAwaiter().GetResult();
appHost.InitializeServices(Mock.Of<IConfiguration>()).GetAwaiter().GetResult();
Program.ApplyCoreMigrationsAsync(appHost.ServiceProvider, Migrations.Stages.JellyfinMigrationStageTypes.AppInitialisation).GetAwaiter().GetResult();
diff --git a/tests/Jellyfin.XbmcMetadata.Tests/Parsers/MovieNfoParserTests.cs b/tests/Jellyfin.XbmcMetadata.Tests/Parsers/MovieNfoParserTests.cs
index 1e8652f4b9..4142831c31 100644
--- a/tests/Jellyfin.XbmcMetadata.Tests/Parsers/MovieNfoParserTests.cs
+++ b/tests/Jellyfin.XbmcMetadata.Tests/Parsers/MovieNfoParserTests.cs
@@ -294,5 +294,48 @@ namespace Jellyfin.XbmcMetadata.Tests.Parsers
// Verify that the lowercase "tmdbcol" is NOT in the provider IDs
Assert.False(item.ProviderIds.ContainsKey("tmdbcol"));
}
+
+ [Fact]
+ public void Parse_CommunityRating_ValidRating_Success()
+ {
+ var result = new MetadataResult<Video>()
+ {
+ Item = new Movie()
+ };
+
+ _parser.Fetch(result, "Test Data/CommunityRating.nfo", CancellationToken.None);
+ var item = (Movie)result.Item;
+
+ Assert.Equal(7.5f, item.CommunityRating);
+ }
+
+ [Fact]
+ public void Parse_CommunityRating_OutOfRange_Ignored()
+ {
+ var result = new MetadataResult<Video>()
+ {
+ Item = new Movie()
+ };
+
+ _parser.Fetch(result, "Test Data/CommunityRating_OutOfRange.nfo", CancellationToken.None);
+ var item = (Movie)result.Item;
+
+ // Rating should not be set if outside 0-10 range
+ Assert.Null(item.CommunityRating);
+ }
+
+ [Fact]
+ public void Parse_CommunityRating_Comma()
+ {
+ var result = new MetadataResult<Video>()
+ {
+ Item = new Movie()
+ };
+
+ _parser.Fetch(result, "Test Data/CommunityRating_Comma.nfo", CancellationToken.None);
+ var item = (Movie)result.Item;
+
+ Assert.Equal(7.5f, item.CommunityRating);
+ }
}
}
diff --git a/tests/Jellyfin.XbmcMetadata.Tests/Test Data/CommunityRating.nfo b/tests/Jellyfin.XbmcMetadata.Tests/Test Data/CommunityRating.nfo
new file mode 100644
index 0000000000..387de10c0e
--- /dev/null
+++ b/tests/Jellyfin.XbmcMetadata.Tests/Test Data/CommunityRating.nfo
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<movie>
+ <title>Test Movie</title>
+ <communityrating>7.5</communityrating>
+</movie> \ No newline at end of file
diff --git a/tests/Jellyfin.XbmcMetadata.Tests/Test Data/CommunityRating_Comma.nfo b/tests/Jellyfin.XbmcMetadata.Tests/Test Data/CommunityRating_Comma.nfo
new file mode 100644
index 0000000000..4ec215e2e1
--- /dev/null
+++ b/tests/Jellyfin.XbmcMetadata.Tests/Test Data/CommunityRating_Comma.nfo
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<movie>
+ <title>Test Movie</title>
+ <communityrating>7,5</communityrating>
+</movie> \ No newline at end of file
diff --git a/tests/Jellyfin.XbmcMetadata.Tests/Test Data/CommunityRating_OutOfRange.nfo b/tests/Jellyfin.XbmcMetadata.Tests/Test Data/CommunityRating_OutOfRange.nfo
new file mode 100644
index 0000000000..126854edd3
--- /dev/null
+++ b/tests/Jellyfin.XbmcMetadata.Tests/Test Data/CommunityRating_OutOfRange.nfo
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<movie>
+ <title>Test Movie</title>
+ <communityrating>15.5</communityrating>
+</movie> \ No newline at end of file