diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs
index e5c520ca2b..4941465f4b 100644
--- a/Emby.Server.Implementations/Library/LibraryManager.cs
+++ b/Emby.Server.Implementations/Library/LibraryManager.cs
@@ -47,6 +47,8 @@ using MediaBrowser.Model.Querying;
using MediaBrowser.Model.Tasks;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
+using Season = MediaBrowser.Controller.Entities.TV.Season;
+using Series = MediaBrowser.Controller.Entities.TV.Series;
using Episode = MediaBrowser.Controller.Entities.TV.Episode;
using EpisodeInfo = Emby.Naming.TV.EpisodeInfo;
using Genre = MediaBrowser.Controller.Entities.Genre;
@@ -2614,11 +2616,12 @@ namespace Emby.Server.Implementations.Library
yield break;
}
+ var resolver = new EpisodeResolver(_namingOptions);
var count = fileSystemChildren.Count;
for (var i = 0; i < count; i++)
{
var current = fileSystemChildren[i];
- if (current.IsDirectory && _namingOptions.AllExtrasTypesFolderNames.ContainsKey(current.Name))
+ if (!owner.IsInMixedFolder && current.IsDirectory && _namingOptions.AllExtrasTypesFolderNames.ContainsKey(current.Name))
{
var filesInSubFolder = _fileSystem.GetFiles(current.FullName, null, false, false);
foreach (var file in filesInSubFolder)
@@ -2637,11 +2640,46 @@ namespace Emby.Server.Implementations.Library
}
else if (!current.IsDirectory && _extraResolver.TryGetExtraTypeForOwner(current.FullName, ownerVideoInfo, out var extraType))
{
- var extra = GetExtra(current, extraType.Value);
- if (extra is not null)
+ // if owner is dir, don't own episode extras
+ // test if extra filename is formatted like an episode
+ int? dashIndex = current.Name?.LastIndexOf('-');
+ String prefix = null;
+ if (dashIndex is int dashIndexValue)
{
- yield return extra;
+ if (dashIndexValue >= 0)
+ {
+ prefix = current.Name.Substring(0, dashIndexValue); // possible episode name
+ String path = current.FullName;
+ path = string.Concat(path.AsSpan(0, path.LastIndexOf('-')), current.Extension);
+ var episodeInfo = resolver.Resolve(path, false);
+
+ String SeriesName = null;
+ if (owner is Series series)
+ {
+ SeriesName = series.Name;
+ }
+ if (owner is Season season)
+ {
+ SeriesName = season.SeriesName;
+ }
+ if (SeriesName is not null && SeriesName.Equals(episodeInfo?.SeriesName, StringComparison.OrdinalIgnoreCase))
+ {
+ // don't attach episode extras to series or season
+ continue;
+ }
+ }
}
+
+ // if owner is Episode, only suffix type matches will be allowed, episode name must match exactly
+ if (owner is not Episode || (prefix is not null && prefix.Equals(ownerVideoInfo.Name, StringComparison.OrdinalIgnoreCase)))
+ {
+ var extra = GetExtra(current, extraType.Value);
+ if (extra is not null)
+ {
+ yield return extra;
+ }
+ }
+
}
}
diff --git a/MediaBrowser.Controller/Entities/BaseItem.cs b/MediaBrowser.Controller/Entities/BaseItem.cs
index 8fe9cfa7f9..e4391efe9b 100644
--- a/MediaBrowser.Controller/Entities/BaseItem.cs
+++ b/MediaBrowser.Controller/Entities/BaseItem.cs
@@ -19,6 +19,7 @@ using MediaBrowser.Controller.Channels;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Dto;
using MediaBrowser.Controller.Entities.Audio;
+using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Persistence;
using MediaBrowser.Controller.Providers;
@@ -1344,7 +1345,7 @@ namespace MediaBrowser.Controller.Entities
/// true if any items have changed, else false.
protected virtual async Task RefreshedOwnedItems(MetadataRefreshOptions options, IReadOnlyList fileSystemChildren, CancellationToken cancellationToken)
{
- if (!IsFileProtocol || !SupportsOwnedItems || IsInMixedFolder || this is ICollectionFolder or UserRootFolder or AggregateFolder || this.GetType() == typeof(Folder))
+ if (!IsFileProtocol || !SupportsOwnedItems || (IsInMixedFolder && this is not Episode) || this is ICollectionFolder or UserRootFolder or AggregateFolder || this.GetType() == typeof(Folder))
{
return false;
}
diff --git a/MediaBrowser.Controller/Entities/TV/Episode.cs b/MediaBrowser.Controller/Entities/TV/Episode.cs
index 597b4cecbc..c34f250679 100644
--- a/MediaBrowser.Controller/Entities/TV/Episode.cs
+++ b/MediaBrowser.Controller/Entities/TV/Episode.cs
@@ -44,7 +44,7 @@ namespace MediaBrowser.Controller.Entities.TV
public int? IndexNumberEnd { get; set; }
[JsonIgnore]
- protected override bool SupportsOwnedItems => IsStacked || MediaSourceCount > 1;
+ protected override bool SupportsOwnedItems => true;
[JsonIgnore]
public override bool SupportsInheritedParentImages => true;
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManager/FindExtrasTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManager/FindExtrasTests.cs
index 5995990711..d1b75ec33e 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManager/FindExtrasTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManager/FindExtrasTests.cs
@@ -302,4 +302,146 @@ public class FindExtrasTests
Assert.Equal("/series/Dexter/trailer.mkv", extras[0].Path);
Assert.Equal("/series/Dexter/trailers/trailer2.mkv", extras[1].Path);
}
+
+ [Fact]
+ public void FindExtras_SeriesWithExtras_FindsCorrectExtras()
+ {
+ var owner = new Series { Name = "Dexter", Path = "/series/Dexter" };
+ var paths = new List
+ {
+ "/series/Dexter/Season 1/Dexter - S01E01.mkv",
+ "/series/Dexter/Season 1/Dexter - S01E01-deleted.mkv",
+ "/series/Dexter/Season 1/Dexter - S01E02 - Second Epi.mkv",
+ "/series/Dexter/Season 1/Dexter - S01E02 - Second Epi-interview.mkv",
+ "/series/Dexter/Season 1/Dexter - S01E02 - Second Epi-scene.mkv",
+ "/series/Dexter/Season 1/It's a begining-behindthescenes.mkv",
+ "/series/Dexter/Season 1/interviews/The Cast.mkv",
+ "/series/Dexter/Funny-behindthescenes.mkv",
+ "/series/Dexter/interviews/The Director.mkv",
+ "/series/Dexter/Dexter - S02E05.mkv",
+ "/series/Dexter/Dexter - S02E05-clip.mkv",
+ "/series/Dexter/Dexter - S03E05/Dexter - S03E05 - Fifth.mkv",
+ "/series/Dexter/Dexter - S03E05/Dexter - S03E05 - Fifth-featurette.mkv",
+ };
+
+ var files = paths.Select(p => new FileSystemMetadata
+ {
+ FullName = p,
+ Name = Path.GetFileName(p),
+ Extension = Path.GetExtension(p),
+ IsDirectory = string.IsNullOrEmpty(Path.GetExtension(p))
+ }).ToList();
+
+ var extras = _libraryManager.FindExtras(owner, files, new DirectoryService(_fileSystemMock.Object)).OrderBy(e => e.ExtraType).ToList();
+
+ Assert.Equal(2, extras.Count);
+ Assert.Equal(ExtraType.BehindTheScenes, extras[0].ExtraType);
+ Assert.Equal(typeof(Video), extras[0].GetType());
+ Assert.Equal("Funny-behindthescenes", extras[0].FileNameWithoutExtension);
+ Assert.Equal("/series/Dexter/Funny-behindthescenes.mkv", extras[0].Path);
+ Assert.Equal("/series/Dexter/interviews/The Director.mkv", extras[1].Path);
+ }
+
+ [Fact]
+ public void FindExtras_SeasonWithExtras_FindsCorrectExtras()
+ {
+ var owner = new Season { Name = "Season 1", SeriesName = "Dexter", Path = "/series/Dexter/Season 1" };
+ var paths = new List
+ {
+ "/series/Dexter/Season 1/Dexter - S01E01.mkv",
+ "/series/Dexter/Season 1/Dexter - S01E01-deleted.mkv",
+ "/series/Dexter/Season 1/Dexter - S01E02 - Second Epi.mkv",
+ "/series/Dexter/Season 1/Dexter - S01E02 - Second Epi-interview.mkv",
+ "/series/Dexter/Season 1/Dexter - S01E02 - Second Epi-scene.mkv",
+ "/series/Dexter/Season 1/It's a begining-behindthescenes.mkv",
+ "/series/Dexter/Season 1/interviews/The Cast.mkv",
+ "/series/Dexter/Funny-behindthescenes.mkv",
+ "/series/Dexter/interviews/The Director.mkv",
+ "/series/Dexter/Dexter - S02E05.mkv",
+ "/series/Dexter/Dexter - S02E05-clip.mkv",
+ "/series/Dexter/Dexter - S03E05/Dexter - S03E05 - Fifth.mkv",
+ "/series/Dexter/Dexter - S03E05/Dexter - S03E05 - Fifth-featurette.mkv",
+ };
+
+ var files = paths.Select(p => new FileSystemMetadata
+ {
+ FullName = p,
+ Name = Path.GetFileName(p),
+ Extension = Path.GetExtension(p),
+ IsDirectory = string.IsNullOrEmpty(Path.GetExtension(p))
+ }).ToList();
+
+ var extras = _libraryManager.FindExtras(owner, files, new DirectoryService(_fileSystemMock.Object)).OrderBy(e => e.ExtraType).ToList();
+
+ Assert.Equal(2, extras.Count);
+ Assert.Equal(ExtraType.BehindTheScenes, extras[0].ExtraType);
+ Assert.Equal(typeof(Video), extras[0].GetType());
+ Assert.Equal("It's a begining-behindthescenes", extras[0].FileNameWithoutExtension);
+ Assert.Equal("/series/Dexter/Season 1/It's a begining-behindthescenes.mkv", extras[0].Path);
+ Assert.Equal("/series/Dexter/Season 1/interviews/The Cast.mkv", extras[1].Path);
+ }
+
+ [Fact]
+ public void FindExtras_EpisodeWithExtras_FindsCorrectExtras()
+ {
+ var paths = new List
+ {
+ "/series/Dexter/Season 1/Dexter - S01E01.mkv",
+ "/series/Dexter/Season 1/Dexter - S01E01-deleted.mkv",
+ "/series/Dexter/Season 1/Dexter - S01E02 - Second Epi.mkv",
+ "/series/Dexter/Season 1/Dexter - S01E02 - Second Epi-interview.mkv",
+ "/series/Dexter/Season 1/Dexter - S01E02 - Second Epi-scene.mkv",
+ "/series/Dexter/Season 1/It's a begining-behindthescenes.mkv",
+ "/series/Dexter/Season 1/interviews/The Cast.mkv",
+ "/series/Dexter/Funny-behindthescenes.mkv",
+ "/series/Dexter/interviews/The Director.mkv",
+ "/series/Dexter/Dexter - S02E05.mkv",
+ "/series/Dexter/Dexter - S02E05-clip.mkv",
+ "/series/Dexter/Dexter - S03E05/Dexter - S03E05 - Fifth.mkv",
+ "/series/Dexter/Dexter - S03E05/Dexter - S03E05 - Fifth-featurette.mkv",
+ };
+
+ var files = paths.Select(p => new FileSystemMetadata
+ {
+ FullName = p,
+ Name = Path.GetFileName(p),
+ Extension = Path.GetExtension(p),
+ IsDirectory = string.IsNullOrEmpty(Path.GetExtension(p))
+ }).ToList();
+
+ var owner = new Episode { Name = "Dexter - S01E01", Path = "/series/Dexter/Season 1/Dexter - S01E01.mkv", IsInMixedFolder = true };
+ var extras = _libraryManager.FindExtras(owner, files, new DirectoryService(_fileSystemMock.Object)).OrderBy(e => e.ExtraType).ToList();
+
+ Assert.Single(extras);
+ Assert.Equal(ExtraType.DeletedScene, extras[0].ExtraType);
+ Assert.Equal(typeof(Video), extras[0].GetType());
+ Assert.Equal("/series/Dexter/Season 1/Dexter - S01E01-deleted.mkv", extras[0].Path);
+
+ owner = new Episode { Name = "Dexter - S01E02 - Second Epi", Path = "/series/Dexter/Season 1/Dexter - S01E02 - Second Epi.mkv", IsInMixedFolder = true };
+ extras = _libraryManager.FindExtras(owner, files, new DirectoryService(_fileSystemMock.Object)).OrderBy(e => e.ExtraType).ToList();
+
+ Assert.Equal(2, extras.Count);
+ Assert.Equal(ExtraType.Interview, extras[0].ExtraType);
+ Assert.Equal(ExtraType.Scene, extras[1].ExtraType);
+ Assert.Equal(typeof(Video), extras[0].GetType());
+ Assert.Equal("/series/Dexter/Season 1/Dexter - S01E02 - Second Epi-interview.mkv", extras[0].Path);
+ Assert.Equal("/series/Dexter/Season 1/Dexter - S01E02 - Second Epi-scene.mkv", extras[1].Path);
+
+ owner = new Episode { Name = "Dexter - S02E05", Path = "/series/Dexter/Dexter - S02E05.mkv", IsInMixedFolder = true };
+ extras = _libraryManager.FindExtras(owner, files, new DirectoryService(_fileSystemMock.Object)).OrderBy(e => e.ExtraType).ToList();
+
+ Assert.Single(extras);
+ Assert.Equal(ExtraType.Clip, extras[0].ExtraType);
+ Assert.Equal(typeof(Video), extras[0].GetType());
+ Assert.Equal("/series/Dexter/Dexter - S02E05-clip.mkv", extras[0].Path);
+
+ // episode folder with special feature subfolders are not supported yet, but it should be considered as not mixed, but current is marked as mixed
+ Folder folderOwner = new Folder { Name = "Dexter - S03E05", Path = "/series/Dexter/Dexter - S03E05", IsInMixedFolder = true };
+ extras = _libraryManager.FindExtras(folderOwner, files, new DirectoryService(_fileSystemMock.Object)).OrderBy(e => e.ExtraType).ToList();
+
+ Assert.Single(extras);
+ Assert.Equal(ExtraType.Clip, extras[0].ExtraType);
+ Assert.Equal(typeof(Video), extras[0].GetType());
+ Assert.Equal("/series/Dexter/Dexter - S03E05/Dexter - S03E05 - Fifth-featurette.mkv", extras[0].Path);
+ }
}