aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorBond-009 <bond.009@outlook.com>2026-06-08 19:41:21 +0200
committerGitHub <noreply@github.com>2026-06-08 19:41:21 +0200
commit1a786f26c1e800550e823751f08581167edbdfc1 (patch)
treeb66f6b8c6f6127ce67f72e586678f3579a3cef9e
parent007515eb73be321550160abf688490301181028c (diff)
parente71914e99331c35ff1f44ddf3d69cd81d0121ec5 (diff)
Merge pull request #17041 from Shadowghost/media-source-handling-fixes
Media source handling fixes
-rw-r--r--Emby.Server.Implementations/Library/MediaSourceManager.cs44
-rw-r--r--Emby.Server.Implementations/ScheduledTasks/Tasks/ChapterImagesTask.cs3
-rw-r--r--Emby.Server.Implementations/ScheduledTasks/Tasks/MediaSegmentExtractionTask.cs1
-rw-r--r--Jellyfin.Api/Controllers/ItemsController.cs1
-rw-r--r--Jellyfin.Api/Controllers/MediaInfoController.cs2
-rw-r--r--Jellyfin.Api/Controllers/UniversalAudioController.cs2
-rw-r--r--Jellyfin.Api/Helpers/MediaInfoHelper.cs13
-rw-r--r--MediaBrowser.Providers/MediaInfo/SubtitleScheduledTask.cs3
-rw-r--r--src/Jellyfin.MediaEncoding.Hls/ScheduledTasks/KeyframeExtractionScheduledTask.cs1
-rw-r--r--tests/Jellyfin.Api.Tests/Helpers/MediaInfoHelperTests.cs99
10 files changed, 148 insertions, 21 deletions
diff --git a/Emby.Server.Implementations/Library/MediaSourceManager.cs b/Emby.Server.Implementations/Library/MediaSourceManager.cs
index 9ccfefa86e..c369fb0957 100644
--- a/Emby.Server.Implementations/Library/MediaSourceManager.cs
+++ b/Emby.Server.Implementations/Library/MediaSourceManager.cs
@@ -229,7 +229,7 @@ namespace Emby.Server.Implementations.Library
list.Add(source);
}
- return SortMediaSources(list).ToArray();
+ return SortMediaSources(list, item.Id).ToArray();
}
/// <inheritdoc />>
@@ -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<BaseItem>(sourceId, user) is not null)
+ .ToArray();
+
foreach (var source in sources)
{
SetDefaultAudioAndSubtitleStreamIndices(item, source, user);
@@ -540,24 +546,32 @@ namespace Emby.Server.Implementations.Library
}
}
- private static IEnumerable<MediaSourceInfo> SortMediaSources(IEnumerable<MediaSourceInfo> sources)
+ private static IEnumerable<MediaSourceInfo> SortMediaSources(IEnumerable<MediaSourceInfo> sources, Guid preferredItemId = default)
{
- return sources.OrderBy(i =>
- {
- if (i.VideoType.HasValue && i.VideoType.Value == VideoType.VideoFile)
+ // The source belonging to the queried item sorts first so it stays the default that gets played.
+ var preferredId = preferredItemId.IsEmpty()
+ ? null
+ : preferredItemId.ToString("N", CultureInfo.InvariantCulture);
+
+ return sources
+ .OrderByDescending(i => preferredId is not null && string.Equals(i.Id, preferredId, StringComparison.OrdinalIgnoreCase))
+ .ThenBy(i =>
{
- return 0;
- }
+ if (i.VideoType.HasValue && i.VideoType.Value == VideoType.VideoFile)
+ {
+ return 0;
+ }
- return 1;
- }).ThenBy(i => i.Video3DFormat.HasValue ? 1 : 0)
- .ThenByDescending(i =>
- {
- var stream = i.VideoStream;
+ return 1;
+ })
+ .ThenBy(i => i.Video3DFormat.HasValue ? 1 : 0)
+ .ThenByDescending(i =>
+ {
+ var stream = i.VideoStream;
- return stream?.Width ?? 0;
- })
- .Where(i => i.Type != MediaSourceType.Placeholder);
+ return stream?.Width ?? 0;
+ })
+ .Where(i => i.Type != MediaSourceType.Placeholder);
}
public async Task<Tuple<LiveStreamResponse, IDirectStreamProvider>> OpenLiveStreamInternal(LiveStreamRequest request, CancellationToken cancellationToken)
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<Video>()
.ToList();
diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/MediaSegmentExtractionTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/MediaSegmentExtractionTask.cs
index 5e92808f78..9cc6eb265a 100644
--- a/Emby.Server.Implementations/ScheduledTasks/Tasks/MediaSegmentExtractionTask.cs
+++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/MediaSegmentExtractionTask.cs
@@ -68,6 +68,7 @@ public class MediaSegmentExtractionTask : IScheduledTask
DtoOptions = new DtoOptions(true),
SourceTypes = [SourceType.Library],
Recursive = true,
+ IncludeOwnedItems = true,
Limit = pagesize
};
diff --git a/Jellyfin.Api/Controllers/ItemsController.cs b/Jellyfin.Api/Controllers/ItemsController.cs
index 9115227707..5f23f2fcee 100644
--- a/Jellyfin.Api/Controllers/ItemsController.cs
+++ b/Jellyfin.Api/Controllers/ItemsController.cs
@@ -981,6 +981,7 @@ public class ItemsController : BaseJellyfinApiController
MediaTypes = mediaTypes,
IsVirtualItem = false,
CollapseBoxSetItems = false,
+ IncludeOwnedItems = true,
EnableTotalRecordCount = enableTotalRecordCount,
AncestorIds = ancestorIds,
IncludeItemTypes = includeItemTypes,
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
/// </summary>
/// <param name="result">Playback info response.</param>
/// <param name="maxBitrate">Max bitrate.</param>
- public void SortMediaSources(PlaybackInfoResponse result, long? maxBitrate)
+ /// <param name="preferredItemId">The id of the queried item, whose own media source must stay the default.</param>
+ 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/MediaBrowser.Providers/MediaInfo/SubtitleScheduledTask.cs b/MediaBrowser.Providers/MediaInfo/SubtitleScheduledTask.cs
index f1582febf2..1d3a273354 100644
--- a/MediaBrowser.Providers/MediaInfo/SubtitleScheduledTask.cs
+++ b/MediaBrowser.Providers/MediaInfo/SubtitleScheduledTask.cs
@@ -102,7 +102,8 @@ namespace MediaBrowser.Providers.MediaInfo
DtoOptions = new DtoOptions(true),
SourceTypes = new[] { SourceType.Library },
Parent = library,
- Recursive = true
+ Recursive = true,
+ IncludeOwnedItems = true
};
if (skipIfAudioTrackMatches)
diff --git a/src/Jellyfin.MediaEncoding.Hls/ScheduledTasks/KeyframeExtractionScheduledTask.cs b/src/Jellyfin.MediaEncoding.Hls/ScheduledTasks/KeyframeExtractionScheduledTask.cs
index fcf37f35d7..6d3ae56f56 100644
--- a/src/Jellyfin.MediaEncoding.Hls/ScheduledTasks/KeyframeExtractionScheduledTask.cs
+++ b/src/Jellyfin.MediaEncoding.Hls/ScheduledTasks/KeyframeExtractionScheduledTask.cs
@@ -60,6 +60,7 @@ public class KeyframeExtractionScheduledTask : IScheduledTask
DtoOptions = new DtoOptions(true),
SourceTypes = [SourceType.Library],
Recursive = true,
+ IncludeOwnedItems = true,
Limit = Pagesize
};
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<IUserManager>(),
+ Mock.Of<ILibraryManager>(),
+ Mock.Of<IMediaSourceManager>(),
+ Mock.Of<IMediaEncoder>(),
+ Mock.Of<IServerConfigurationManager>(),
+ Mock.Of<ILogger<MediaInfoHelper>>(),
+ Mock.Of<INetworkManager>(),
+ Mock.Of<IDeviceManager>());
+ }
+
+ 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);
+ }
+ }
+}