From 3d8bcf1ffd1c56efaffe9ff004c2ec44afbb9818 Mon Sep 17 00:00:00 2001 From: Bond_009 Date: Fri, 15 Mar 2024 14:30:55 +0100 Subject: [PATCH 01/13] Alternate solution to #7843 without extra prop --- Emby.Server.Implementations/Library/LibraryManager.cs | 10 ++++++++-- src/Jellyfin.LiveTv/Guide/GuideManager.cs | 9 +++++++-- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs index 30ff1bd333..1745d711b4 100644 --- a/Emby.Server.Implementations/Library/LibraryManager.cs +++ b/Emby.Server.Implementations/Library/LibraryManager.cs @@ -2432,8 +2432,14 @@ namespace Emby.Server.Implementations.Library var outdated = forceUpdate ? item.ImageInfos.Where(i => i.Path is not null).ToArray() : item.ImageInfos.Where(ImageNeedsRefresh).ToArray(); - // Skip image processing if current or live tv source - if (outdated.Length == 0 || item.SourceType != SourceType.Library) + + var parentItem = item.GetParent(); + var isLiveTvShow = item.SourceType != SourceType.Library && + parentItem is not null && + parentItem.SourceType != SourceType.Library; // not a channel + + // Skip image processing if current or live tv show + if (outdated.Length == 0 || isLiveTvShow) { RegisterItem(item); return; diff --git a/src/Jellyfin.LiveTv/Guide/GuideManager.cs b/src/Jellyfin.LiveTv/Guide/GuideManager.cs index 556516674b..c3cc70381e 100644 --- a/src/Jellyfin.LiveTv/Guide/GuideManager.cs +++ b/src/Jellyfin.LiveTv/Guide/GuideManager.cs @@ -448,14 +448,19 @@ public class GuideManager : IGuideManager item.Name = channelInfo.Name; - if (!item.HasImage(ImageType.Primary)) + var currentPrimary = item.GetImageInfo(ImageType.Primary, 0); + var imageUrlIsNull = string.IsNullOrWhiteSpace(channelInfo.ImageUrl); + + // Update channel image if image URL has changed + if (currentPrimary is null + || (!imageUrlIsNull && !string.Equals(currentPrimary.Path, channelInfo.ImageUrl, StringComparison.Ordinal))) { if (!string.IsNullOrWhiteSpace(channelInfo.ImagePath)) { item.SetImagePath(ImageType.Primary, channelInfo.ImagePath); forceUpdate = true; } - else if (!string.IsNullOrWhiteSpace(channelInfo.ImageUrl)) + else if (!imageUrlIsNull) { item.SetImagePath(ImageType.Primary, channelInfo.ImageUrl); forceUpdate = true; From fe1accc0e79885c5706babac5dea527d0b07d8e2 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Fri, 5 Jun 2026 19:40:28 +0200 Subject: [PATCH 02/13] Prefer local alternate versions when deduplicating linked children (cherry picked from commit 53f02a04ef7ff8f9d78c5f4e0924a0871b0e96fb) --- .../Item/ItemPersistenceService.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs b/Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs index ffa5cff1f2..7c0cfe7c15 100644 --- a/Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs +++ b/Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs @@ -557,9 +557,11 @@ public class ItemPersistenceService : IItemPersistenceService } } + // Deduplicate; local (file-based) relationships take priority over linked (user-merged) + // ones, matching the LinkedChildren migration. newLinkedChildren = newLinkedChildren .GroupBy(c => c.ChildId) - .Select(g => g.Last()) + .Select(g => g.OrderBy(c => c.Type == LinkedChildType.LocalAlternateVersion ? 0 : 1).First()) .ToList(); var childIdsToCheck = newLinkedChildren.Select(c => c.ChildId).ToList(); From f5847596384c35b5bf4119cd8b39e4234af6e899 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Fri, 5 Jun 2026 19:40:39 +0200 Subject: [PATCH 03/13] Drop linked alternate versions pointing at owned items in migration (cherry picked from commit 40afd403a655ada6cfa30b577454bb766ea2099f) --- .../20260113120000_MigrateLinkedChildren.cs | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/Jellyfin.Server/Migrations/Routines/20260113120000_MigrateLinkedChildren.cs b/Jellyfin.Server/Migrations/Routines/20260113120000_MigrateLinkedChildren.cs index 74f03f5107..c433c1d043 100644 --- a/Jellyfin.Server/Migrations/Routines/20260113120000_MigrateLinkedChildren.cs +++ b/Jellyfin.Server/Migrations/Routines/20260113120000_MigrateLinkedChildren.cs @@ -223,6 +223,35 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine toInsert = toInsert.Where(lc => existingChildIds.Contains(lc.ChildId)).ToList(); + // Drop linked (user-merged) entries that point at items the parent owns (local + // file-based alternates or extras). These stem from legacy data that merged an + // owned item onto its own primary and would wrongly mark server-merged groups + // as user-merged (splittable). + var linkedChildIds = toInsert + .Where(lc => lc.ChildType == LinkedChildType.LinkedAlternateVersion) + .Select(lc => lc.ChildId) + .Distinct() + .ToList(); + + if (linkedChildIds.Count > 0) + { + var ownerIdByChildId = context.BaseItems + .WhereOneOrMany(linkedChildIds, b => b.Id) + .Where(b => b.OwnerId.HasValue) + .Select(b => new { b.Id, b.OwnerId }) + .ToDictionary(b => b.Id, b => b.OwnerId!.Value); + + var removedCount = toInsert.RemoveAll(lc => + lc.ChildType == LinkedChildType.LinkedAlternateVersion + && ownerIdByChildId.TryGetValue(lc.ChildId, out var ownerId) + && ownerId.Equals(lc.ParentId)); + + if (removedCount > 0) + { + _logger.LogInformation("Skipped {Count} LinkedAlternateVersion records pointing at items owned by their parent.", removedCount); + } + } + context.LinkedChildren.AddRange(toInsert); context.SaveChanges(); From 6c931dcdda845f5a415190b859bb3f7e0cb781b8 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Fri, 5 Jun 2026 19:41:08 +0200 Subject: [PATCH 04/13] Keep the queried item's media source as the playback default --- .../Controllers/MediaInfoController.cs | 2 +- .../Controllers/UniversalAudioController.cs | 2 +- Jellyfin.Api/Helpers/MediaInfoHelper.cs | 13 ++- .../Helpers/MediaInfoHelperTests.cs | 99 +++++++++++++++++++ 4 files changed, 112 insertions(+), 4 deletions(-) create mode 100644 tests/Jellyfin.Api.Tests/Helpers/MediaInfoHelperTests.cs diff --git a/Jellyfin.Api/Controllers/MediaInfoController.cs b/Jellyfin.Api/Controllers/MediaInfoController.cs index f22ac0b73a..ac7c091f85 100644 --- a/Jellyfin.Api/Controllers/MediaInfoController.cs +++ b/Jellyfin.Api/Controllers/MediaInfoController.cs @@ -213,7 +213,7 @@ public class MediaInfoController : BaseJellyfinApiController Request.HttpContext.GetNormalizedRemoteIP()); } - _mediaInfoHelper.SortMediaSources(info, maxStreamingBitrate); + _mediaInfoHelper.SortMediaSources(info, maxStreamingBitrate, item.Id); } if (autoOpenLiveStream.Value) diff --git a/Jellyfin.Api/Controllers/UniversalAudioController.cs b/Jellyfin.Api/Controllers/UniversalAudioController.cs index 2f5ed327c0..e53d15acfd 100644 --- a/Jellyfin.Api/Controllers/UniversalAudioController.cs +++ b/Jellyfin.Api/Controllers/UniversalAudioController.cs @@ -163,7 +163,7 @@ public class UniversalAudioController : BaseJellyfinApiController Request.HttpContext.GetNormalizedRemoteIP()); } - _mediaInfoHelper.SortMediaSources(info, maxStreamingBitrate); + _mediaInfoHelper.SortMediaSources(info, maxStreamingBitrate, item.Id); foreach (var source in info.MediaSources) { diff --git a/Jellyfin.Api/Helpers/MediaInfoHelper.cs b/Jellyfin.Api/Helpers/MediaInfoHelper.cs index 454d3f08e3..ef81235808 100644 --- a/Jellyfin.Api/Helpers/MediaInfoHelper.cs +++ b/Jellyfin.Api/Helpers/MediaInfoHelper.cs @@ -351,11 +351,20 @@ public class MediaInfoHelper /// /// Playback info response. /// Max bitrate. - public void SortMediaSources(PlaybackInfoResponse result, long? maxBitrate) + /// The id of the queried item, whose own media source must stay the default. + public void SortMediaSources(PlaybackInfoResponse result, long? maxBitrate, Guid preferredItemId = default) { var originalList = result.MediaSources.ToList(); - result.MediaSources = result.MediaSources.OrderBy(i => + // The queried item's source carries the user's resume state for that version, so it must stay the + // default the client plays. An unfavorable bitrate means transcoding it, not switching to a sibling version. + var preferredId = preferredItemId.IsEmpty() + ? null + : preferredItemId.ToString("N", CultureInfo.InvariantCulture); + + result.MediaSources = result.MediaSources + .OrderByDescending(i => preferredId is not null && string.Equals(i.Id, preferredId, StringComparison.OrdinalIgnoreCase)) + .ThenBy(i => { // Nothing beats direct playing a file if (i.SupportsDirectPlay && i.Protocol == MediaProtocol.File) diff --git a/tests/Jellyfin.Api.Tests/Helpers/MediaInfoHelperTests.cs b/tests/Jellyfin.Api.Tests/Helpers/MediaInfoHelperTests.cs new file mode 100644 index 0000000000..a003be4d96 --- /dev/null +++ b/tests/Jellyfin.Api.Tests/Helpers/MediaInfoHelperTests.cs @@ -0,0 +1,99 @@ +using System; +using System.Globalization; +using Jellyfin.Api.Helpers; +using MediaBrowser.Common.Net; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Devices; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.MediaEncoding; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.MediaInfo; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; + +namespace Jellyfin.Api.Tests.Helpers +{ + public class MediaInfoHelperTests + { + private static MediaInfoHelper CreateHelper() + { + return new MediaInfoHelper( + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of>(), + Mock.Of(), + Mock.Of()); + } + + private static MediaSourceInfo CreateSource(Guid itemId, int bitrate, bool supportsDirectPlay = true) + { + return new MediaSourceInfo + { + Id = itemId.ToString("N", CultureInfo.InvariantCulture), + Protocol = MediaProtocol.File, + Bitrate = bitrate, + SupportsDirectPlay = supportsDirectPlay, + SupportsDirectStream = true, + SupportsTranscoding = true + }; + } + + [Fact] + public void SortMediaSources_PreferredItemExceedsBitrate_StaysDefault() + { + // The version the user was watching (the queried item) must stay the default + // even when a sibling version fits the bitrate limit better, since the resume + // position belongs to that exact version. + var preferredItemId = Guid.NewGuid(); + var preferredSource = CreateSource(preferredItemId, bitrate: 80_000_000, supportsDirectPlay: false); + var siblingSource = CreateSource(Guid.NewGuid(), bitrate: 8_000_000); + + var result = new PlaybackInfoResponse + { + MediaSources = [siblingSource, preferredSource] + }; + + CreateHelper().SortMediaSources(result, maxBitrate: 20_000_000, preferredItemId); + + Assert.Equal(preferredSource.Id, result.MediaSources[0].Id); + } + + [Fact] + public void SortMediaSources_NoPreferredItem_OrdersByPlayability() + { + var directPlay = CreateSource(Guid.NewGuid(), bitrate: 8_000_000); + var transcodeOnly = CreateSource(Guid.NewGuid(), bitrate: 8_000_000, supportsDirectPlay: false); + transcodeOnly.SupportsDirectStream = false; + + var result = new PlaybackInfoResponse + { + MediaSources = [transcodeOnly, directPlay] + }; + + CreateHelper().SortMediaSources(result, maxBitrate: 20_000_000); + + Assert.Equal(directPlay.Id, result.MediaSources[0].Id); + } + + [Fact] + public void SortMediaSources_PreferredIdNotInSources_KeepsPlayabilityOrder() + { + var directPlay = CreateSource(Guid.NewGuid(), bitrate: 8_000_000); + var transcodeOnly = CreateSource(Guid.NewGuid(), bitrate: 8_000_000, supportsDirectPlay: false); + transcodeOnly.SupportsDirectStream = false; + + var result = new PlaybackInfoResponse + { + MediaSources = [transcodeOnly, directPlay] + }; + + CreateHelper().SortMediaSources(result, maxBitrate: 20_000_000, Guid.NewGuid()); + + Assert.Equal(directPlay.Id, result.MediaSources[0].Id); + } + } +} From 2392e32779f7e5bced3294de3ea8a24e206a7084 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Thu, 4 Jun 2026 02:58:14 +0200 Subject: [PATCH 05/13] Filter inaccessible alternative versions --- Emby.Server.Implementations/Library/MediaSourceManager.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Emby.Server.Implementations/Library/MediaSourceManager.cs b/Emby.Server.Implementations/Library/MediaSourceManager.cs index 9ccfefa86e..f0c7bd1d20 100644 --- a/Emby.Server.Implementations/Library/MediaSourceManager.cs +++ b/Emby.Server.Implementations/Library/MediaSourceManager.cs @@ -386,6 +386,12 @@ namespace Emby.Server.Implementations.Library if (user is not null) { + sources = sources + .Where(source => !Guid.TryParse(source.Id, out var sourceId) + || sourceId.Equals(item.Id) + || _libraryManager.GetItemById(sourceId, user) is not null) + .ToArray(); + foreach (var source in sources) { SetDefaultAudioAndSubtitleStreamIndices(item, source, user); From dee63ef3f1c93541201c97712b0e08a0e9c02a82 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Thu, 4 Jun 2026 02:57:54 +0200 Subject: [PATCH 06/13] Fix data extraction for alternative versions --- .../ScheduledTasks/Tasks/ChapterImagesTask.cs | 3 ++- MediaBrowser.Providers/MediaInfo/SubtitleScheduledTask.cs | 3 ++- .../ScheduledTasks/KeyframeExtractionScheduledTask.cs | 1 + 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/ChapterImagesTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/ChapterImagesTask.cs index f81309560e..f1e1579a1d 100644 --- a/Emby.Server.Implementations/ScheduledTasks/Tasks/ChapterImagesTask.cs +++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/ChapterImagesTask.cs @@ -92,7 +92,8 @@ public class ChapterImagesTask : IScheduledTask EnableImages = false }, SourceTypes = [SourceType.Library], - IsVirtualItem = false + IsVirtualItem = false, + IncludeOwnedItems = true }) .OfType