aboutsummaryrefslogtreecommitdiff
path: root/MediaBrowser.Controller/Entities
diff options
context:
space:
mode:
authorShadowghost <Shadowghost@users.noreply.github.com>2026-09-15 11:13:48 -0400
committerCody Robibero <cody@robibe.ro>2026-09-15 11:13:48 -0400
commit8150ee2484dabed61718ef43d5c98cdd2c0fa01c (patch)
treecb81b848e8931122d4771de780746be51a0e1d94 /MediaBrowser.Controller/Entities
parent7baca5f2b891cbc92e48145e633e1417d9bf2de3 (diff)
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: cabec7ec28df671e3bb1c360c32db60b085f4084 Merged-by: crobibero <cody@robibe.ro> Backported-by: Cody Robibero <cody@robibe.ro>
Diffstat (limited to 'MediaBrowser.Controller/Entities')
-rw-r--r--MediaBrowser.Controller/Entities/Folder.cs53
-rw-r--r--MediaBrowser.Controller/Entities/InternalItemsQuery.cs8
2 files changed, 54 insertions, 7 deletions
diff --git a/MediaBrowser.Controller/Entities/Folder.cs b/MediaBrowser.Controller/Entities/Folder.cs
index d8203ea6f2..b3e7369205 100644
--- a/MediaBrowser.Controller/Entities/Folder.cs
+++ b/MediaBrowser.Controller/Entities/Folder.cs
@@ -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)
diff --git a/MediaBrowser.Controller/Entities/InternalItemsQuery.cs b/MediaBrowser.Controller/Entities/InternalItemsQuery.cs
index eb2a3676ac..7c88d5dd05 100644
--- a/MediaBrowser.Controller/Entities/InternalItemsQuery.cs
+++ b/MediaBrowser.Controller/Entities/InternalItemsQuery.cs
@@ -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; }