Backport pull request #17842 from jellyfin/release-12.z

Fix versions of a video still listing separately from their group and preserve manual merges

Original-merge: cabec7ec28

Merged-by: crobibero <cody@robibe.ro>

Backported-by: Cody Robibero <cody@robibe.ro>
This commit is contained in:
Shadowghost authored and Cody Robibero committed 2026-09-15 11:13:48 -04:00
1 parent 7baca5f2b8
commit 8150ee2484
16 files changed
+1226 -28

No files matched your search

+46 -7
View File
@@ -316,7 +316,7 @@ namespace MediaBrowser.Controller.Entities
var dictionary = new Dictionary<Guid, BaseItem>();
Children = null; // invalidate cached children.
var childrenList = Children.ToList();
var childrenList = GetChildrenForValidation();
foreach (var child in childrenList)
{
@@ -551,7 +551,7 @@ namespace MediaBrowser.Controller.Entities
&& primaryVideo.OwnerId.IsEmpty()
&& (primaryVideo.LocalAlternateVersions ?? []).Any(p => alternateVersionPaths.Contains(p)))
{
var newPrimary = newItems
var newPrimary = validChildren
.OfType<Video>()
.FirstOrDefault(v => (v.LocalAlternateVersions ?? [])
.Any(p => (primaryVideo.LocalAlternateVersions ?? [])
@@ -593,6 +593,8 @@ namespace MediaBrowser.Controller.Entities
newPrimary.Name,
newPrimary.Id);
await PromoteToPrimaryVersionAsync(newPrimary, cancellationToken).ConfigureAwait(false);
// Reroute collection/playlist references from old primary to new primary
await LibraryManager.RerouteLinkedChildReferencesAsync(oldPrimary.Id, newPrimary.Id).ConfigureAwait(false);
@@ -621,9 +623,12 @@ namespace MediaBrowser.Controller.Entities
LibraryManager.DeleteItem(oldPrimary, new DeleteOptions { DeleteFileLocation = false }, this, false);
}
// Demote old primaries that are now alternate versions of newly created primaries.
// Demote old primaries that are now alternate versions of another primary.
// This handles the case where a new file is added that becomes the new primary
// (e.g. movie-2 added, movie-3 was primary → movie-3 needs demotion).
// (e.g. movie-2 added, movie-3 was primary → movie-3 needs demotion), and the case
// where the file that takes over was already in the library and merely traded
// places with this one — so the new primary is looked up among all valid children
// rather than only the newly created ones.
// Items in replacedPrimaries are excluded (already in actuallyRemoved).
var oldPrimariesToDemote = new List<(Video OldPrimary, Video NewPrimary)>();
foreach (var item in itemsRemoved.Except(actuallyRemoved))
@@ -633,7 +638,7 @@ namespace MediaBrowser.Controller.Entities
&& !string.IsNullOrEmpty(item.Path)
&& alternateVersionPaths.Contains(item.Path))
{
var newPrimary = newItems
var newPrimary = validChildren
.OfType<Video>()
.FirstOrDefault(v => (v.LocalAlternateVersions ?? [])
.Any(p => string.Equals(p, item.Path, StringComparison.OrdinalIgnoreCase)));
@@ -653,10 +658,13 @@ namespace MediaBrowser.Controller.Entities
newPrimary.Name,
newPrimary.Id);
await PromoteToPrimaryVersionAsync(newPrimary, cancellationToken).ConfigureAwait(false);
// First: update old primary's alternate items to point to new primary.
// Order matters — update alternates FIRST so they don't get orphan-deleted
// when old primary's arrays are cleared.
var oldAlternateIds = LibraryManager.GetLocalAlternateVersionIds(oldPrimary)
var oldLocalAlternateIds = LibraryManager.GetLocalAlternateVersionIds(oldPrimary).ToHashSet();
var oldAlternateIds = oldLocalAlternateIds
.Concat(LibraryManager.GetLinkedAlternateVersions(oldPrimary).Select(v => v.Id))
.Distinct()
.ToList();
@@ -666,7 +674,10 @@ namespace MediaBrowser.Controller.Entities
if (LibraryManager.GetItemById(altId) is Video altVideo && !altVideo.Id.Equals(newPrimary.Id))
{
altVideo.SetPrimaryVersionId(newPrimary.Id);
altVideo.OwnerId = newPrimary.Id;
// Only a version stored next to the new primary is owned by it; one that
// was merged in by hand keeps its own row and must stay unowned.
altVideo.OwnerId = oldLocalAlternateIds.Contains(altVideo.Id) ? newPrimary.Id : Guid.Empty;
await altVideo.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, cancellationToken).ConfigureAwait(false);
}
}
@@ -772,6 +783,23 @@ namespace MediaBrowser.Controller.Entities
}
}
private async Task PromoteToPrimaryVersionAsync(Video newPrimary, CancellationToken cancellationToken)
{
if (!newPrimary.PrimaryVersionId.HasValue && newPrimary.OwnerId.IsEmpty())
{
return;
}
Logger.LogInformation(
"Promoting {Name} ({Id}) to the primary version of its group",
newPrimary.Name,
newPrimary.Id);
newPrimary.SetPrimaryVersionId(null);
newPrimary.OwnerId = Guid.Empty;
await newPrimary.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, cancellationToken).ConfigureAwait(false);
}
private async Task RefreshMetadataRecursive(IList<BaseItem> children, MetadataRefreshOptions refreshOptions, bool recursive, IProgress<double> progress, CancellationToken cancellationToken)
{
await RunTasks(
@@ -876,6 +904,17 @@ namespace MediaBrowser.Controller.Entities
});
}
private IReadOnlyList<BaseItem> GetChildrenForValidation()
{
return ItemRepository.GetItemList(new InternalItemsQuery
{
Parent = this,
GroupByPresentationUniqueKey = false,
IncludeAlternateVersions = true,
DtoOptions = new DtoOptions(true)
});
}
public virtual int GetChildCount(User user)
{
if (LinkedChildren.Length > 0)
@@ -488,6 +488,14 @@ namespace MediaBrowser.Controller.Entities
/// </summary>
public bool IncludeOwnedItems { get; set; }
/// <summary>
/// Gets or sets a value indicating whether to include alternate versions, which carry a
/// <see cref="Video.PrimaryVersionId"/> and are normally hidden behind the version they
/// belong to. Unlike <see cref="IncludeOwnedItems"/> this keeps the versions a user merged
/// by hand without also returning the parts and extras owned by another item.
/// </summary>
public bool IncludeAlternateVersions { get; set; }
public bool? Is4K { get; set; }
public int? MaxHeight { get; set; }