From 6c931dcdda845f5a415190b859bb3f7e0cb781b8 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Fri, 5 Jun 2026 19:41:08 +0200 Subject: [PATCH 1/6] 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 2/6] 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 3/6] 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