Keep folder extras with the item that owns the folder
This commit is contained in:
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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" };
|
||||
|
||||
Reference in New Issue
Block a user