Keep folder extras with the item that owns the folder

This commit is contained in:
Shadowghost
2026-08-03 10:50:33 +02:00
parent 33a8cdfc0b
commit 4e2089b6a1
5 changed files with 146 additions and 3 deletions
@@ -2376,6 +2376,7 @@ namespace Emby.Server.Implementations.Library
{
altVideo.OwnerId = video.Id;
altVideo.SetPrimaryVersionId(video.Id);
altVideo.IsInMixedFolder = video.IsInMixedFolder;
// ResolveAlternateVersion only sees the alternate's primary file.
// If the alternate is itself a stack (e.g. 1080p part1 + part2),
// detect its parts from sibling files so its AdditionalParts persist.
@@ -2561,6 +2562,8 @@ namespace Emby.Server.Implementations.Library
item.DateLastSaved = DateTime.UtcNow;
}
ForgetDroppedLocalAlternateVersions(items);
// Resolve and add any local alternate version items that don't exist yet
// This ensures they exist in the database when LinkedChildren are processed
var allItems = new List<BaseItem>(items);
@@ -2589,6 +2592,7 @@ namespace Emby.Server.Implementations.Library
{
altVideo.OwnerId = video.Id;
altVideo.SetPrimaryVersionId(video.Id);
altVideo.IsInMixedFolder = video.IsInMixedFolder;
// ResolveAlternateVersion only sees the alternate's primary file.
// If the alternate is itself a stack (e.g. 1080p part1 + part2),
// detect its parts from sibling files so its AdditionalParts persist.
@@ -2649,6 +2653,30 @@ namespace Emby.Server.Implementations.Library
public Task UpdateItemAsync(BaseItem item, BaseItem parent, ItemUpdateType updateReason, CancellationToken cancellationToken)
=> UpdateItemsAsync([item], parent, updateReason, cancellationToken);
/// <summary>
/// Forgets the cached local alternate versions of the supplied items that they no longer list.
/// </summary>
/// <param name="items">The items about to be saved.</param>
private void ForgetDroppedLocalAlternateVersions(IReadOnlyList<BaseItem> items)
{
foreach (var video in items.OfType<Video>())
{
var videoType = video.GetType();
var keptIds = video.LocalAlternateVersions
.Where(path => !string.IsNullOrEmpty(path))
.Select(path => GetNewItemId(path, videoType))
.ToHashSet();
foreach (var versionId in GetLocalAlternateVersionIds(video))
{
if (!keptIds.Contains(versionId))
{
_cache.TryRemove(versionId, out _);
}
}
}
}
/// <inheritdoc />
public async Task ReattachUserDataAsync(BaseItem item, CancellationToken cancellationToken)
{
+19 -1
View File
@@ -771,6 +771,17 @@ namespace MediaBrowser.Controller.Entities
[JsonIgnore]
protected virtual bool SupportsOwnedItems => !ParentId.IsEmpty() && IsFileProtocol;
/// <summary>
/// Gets a value indicating whether this item searches the folder it lives in for its own extras.
/// </summary>
[JsonIgnore]
protected virtual bool SearchesContainingFolderForExtras =>
IsFileProtocol
&& SupportsOwnedItems
&& !IsInMixedFolder
&& this is not (ICollectionFolder or UserRootFolder or AggregateFolder)
&& GetType() != typeof(Folder);
[JsonIgnore]
public virtual bool SupportsPeople => false;
@@ -1528,7 +1539,14 @@ namespace MediaBrowser.Controller.Entities
/// <returns><c>true</c> if any items have changed, else <c>false</c>.</returns>
protected virtual async Task<bool> RefreshedOwnedItems(MetadataRefreshOptions options, IReadOnlyList<FileSystemMetadata> fileSystemChildren, CancellationToken cancellationToken)
{
if (!IsFileProtocol || !SupportsOwnedItems || IsInMixedFolder || this is ICollectionFolder or UserRootFolder or AggregateFolder || this.GetType() == typeof(Folder))
if (!SearchesContainingFolderForExtras)
{
return false;
}
if (GetParent() is Folder container
&& container.SearchesContainingFolderForExtras
&& string.Equals(container.Path, ContainingFolderPath, StringComparison.OrdinalIgnoreCase))
{
return false;
}
@@ -47,7 +47,7 @@ namespace MediaBrowser.Controller.Entities.TV
public int? IndexNumberEnd { get; set; }
[JsonIgnore]
protected override bool SupportsOwnedItems => IsStacked || MediaSourceCount > 1;
protected override bool SupportsOwnedItems => IsStacked || LocalAlternateVersions.Length > 0 || MediaSourceCount > 1;
[JsonIgnore]
public override bool SupportsInheritedParentImages => true;
+18 -1
View File
@@ -527,7 +527,13 @@ namespace MediaBrowser.Controller.Entities
protected override async Task<bool> RefreshedOwnedItems(MetadataRefreshOptions options, IReadOnlyList<FileSystemMetadata> fileSystemChildren, CancellationToken cancellationToken)
{
var hasChanges = await base.RefreshedOwnedItems(options, fileSystemChildren, cancellationToken).ConfigureAwait(false);
var hasChanges = false;
// The extras of a version group are maintained by its primary.
if (!PrimaryVersionId.HasValue)
{
hasChanges = await base.RefreshedOwnedItems(options, fileSystemChildren, cancellationToken).ConfigureAwait(false);
}
// Clean up LocalAlternateVersions - remove paths that no longer exist
if (LocalAlternateVersions.Length > 0)
@@ -588,10 +594,20 @@ namespace MediaBrowser.Controller.Entities
{
altVideo.OwnerId = Id;
altVideo.SetPrimaryVersionId(Id);
altVideo.IsInMixedFolder = IsInMixedFolder;
LibraryManager.CreateItem(altVideo, GetParent());
}
}
// A version is resolved on its own, so it does not learn whether the folder it sits in
// holds other items. It has to share that with the version it belongs to, before the
// refresh below acts on it.
if (LibraryManager.GetItemById(id) is Video resolvedVersion && resolvedVersion.IsInMixedFolder != IsInMixedFolder)
{
resolvedVersion.IsInMixedFolder = IsInMixedFolder;
await resolvedVersion.UpdateToRepositoryAsync(ItemUpdateType.MetadataImport, cancellationToken).ConfigureAwait(false);
}
await RefreshMetadataForOwnedVideo(options, copyTitleMetadata, path, cancellationToken).ConfigureAwait(false);
// Create LinkedChild entry for this local alternate version
@@ -671,6 +687,7 @@ namespace MediaBrowser.Controller.Entities
video.Id = id;
video.OwnerId = Id;
video.IsInMixedFolder = IsInMixedFolder;
LibraryManager.CreateItem(video, parentFolder);
newOptions.ForceSave = true;
}
@@ -3,17 +3,22 @@ using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Database.Implementations.Entities;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Movies;
using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.LiveTv;
using MediaBrowser.Controller.MediaSegments;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Configuration;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.IO;
using MediaBrowser.Model.MediaInfo;
using MediaBrowser.Model.Querying;
using Moq;
using Xunit;
@@ -293,6 +298,81 @@ public class BaseItemTests
Times.Never);
}
[Theory]
// A version file the scan just found beside the episode is not linked yet, so it does not count
// towards MediaSourceCount. The episode still has to refresh its owned items, as that is what
// creates the item for the version and links it.
[InlineData(true, false, true)]
[InlineData(false, true, true)]
[InlineData(false, false, false)]
public void SupportsOwnedItems_EpisodeWithResolvedVersionOrPart_IsTrue(bool hasLocalVersion, bool isStacked, bool expected)
{
var libraryManager = new Mock<ILibraryManager>();
libraryManager.Setup(x => x.GetLinkedAlternateVersions(It.IsAny<Video>())).Returns(Array.Empty<Video>());
libraryManager.Setup(x => x.GetLocalAlternateVersionIds(It.IsAny<Video>())).Returns(Array.Empty<Guid>());
BaseItem.LibraryManager = libraryManager.Object;
var episode = new Episode
{
Id = Guid.NewGuid(),
Path = "/TV/Show/Season 1/S01E01 - 1080p.mkv",
LocalAlternateVersions = hasLocalVersion ? ["/TV/Show/Season 1/S01E01 - 720p.mkv"] : [],
AdditionalParts = isStacked ? ["/TV/Show/Season 1/S01E01 - 1080p-part2.mkv"] : []
};
var property = typeof(Episode).GetProperty("SupportsOwnedItems", BindingFlags.Instance | BindingFlags.NonPublic);
Assert.NotNull(property);
Assert.Equal(expected, (bool)property!.GetValue(episode)!);
}
[Theory]
// The season folder is the season's own, so the extras that sit in it are the season's. Whether
// the season holds one episode or two must not decide where its extras show up.
[InlineData("/TV/Show/Season 1/S01E01 - 1080p.mkv", false)]
// An episode with a folder of its own keeps the extras in it, as nothing else searches there
[InlineData("/TV/Show/Season 1/S01E01/S01E01 - 1080p.mkv", true)]
public async Task RefreshedOwnedItems_EpisodeInAContainersOwnFolder_LeavesExtrasToTheContainer(string episodePath, bool expectSearch)
{
// The season needs a parent of its own, as an item without one maintains no owned items
var season = new Season { Id = Guid.NewGuid(), ParentId = Guid.NewGuid(), Path = "/TV/Show/Season 1" };
var episode = new Episode
{
Id = Guid.NewGuid(),
ParentId = season.Id,
Path = episodePath,
// A version file is what makes an episode maintain owned items at all
LocalAlternateVersions = [episodePath.Replace("1080p", "720p", StringComparison.Ordinal)]
};
var mediaSourceManager = new Mock<IMediaSourceManager>();
mediaSourceManager.Setup(x => x.GetPathProtocol(It.IsAny<string>())).Returns(MediaProtocol.File);
BaseItem.MediaSourceManager = mediaSourceManager.Object;
var fileSystem = new Mock<IFileSystem>();
fileSystem.Setup(x => x.FileExists(It.IsAny<string>())).Returns(true);
BaseItem.FileSystem = fileSystem.Object;
var libraryManager = new Mock<ILibraryManager>();
libraryManager.Setup(x => x.GetItemById(season.Id)).Returns(season);
libraryManager.Setup(x => x.GetLinkedAlternateVersions(It.IsAny<Video>())).Returns(Array.Empty<Video>());
libraryManager.Setup(x => x.GetLocalAlternateVersionIds(It.IsAny<Video>())).Returns(Array.Empty<Guid>());
libraryManager.Setup(x => x.GetItemList(It.IsAny<InternalItemsQuery>())).Returns(Array.Empty<BaseItem>());
libraryManager.Setup(x => x.FindExtras(It.IsAny<BaseItem>(), It.IsAny<IReadOnlyList<FileSystemMetadata>>(), It.IsAny<IDirectoryService>()))
.Returns(Array.Empty<BaseItem>());
BaseItem.LibraryManager = libraryManager.Object;
var method = typeof(BaseItem).GetMethod("RefreshedOwnedItems", BindingFlags.Instance | BindingFlags.NonPublic);
Assert.NotNull(method);
var options = new MetadataRefreshOptions(Mock.Of<IDirectoryService>());
await (Task<bool>)method!.Invoke(episode, [options, Array.Empty<FileSystemMetadata>(), CancellationToken.None])!;
libraryManager.Verify(
x => x.FindExtras(episode, It.IsAny<IReadOnlyList<FileSystemMetadata>>(), It.IsAny<IDirectoryService>()),
expectSearch ? Times.Once() : Times.Never());
}
private static (Video Primary, Video Alt1, Video Alt2) SetupVersionGroup()
{
var primary = new Video { Id = Guid.NewGuid(), Path = "/Movies/Movie/Movie.mkv" };