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);
+ }
+ }
+}