diff options
| author | Bond-009 <bond.009@outlook.com> | 2026-06-08 19:41:21 +0200 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-06-08 19:41:21 +0200 |
| commit | 1a786f26c1e800550e823751f08581167edbdfc1 (patch) | |
| tree | b66f6b8c6f6127ce67f72e586678f3579a3cef9e | |
| parent | 007515eb73be321550160abf688490301181028c (diff) | |
| parent | e71914e99331c35ff1f44ddf3d69cd81d0121ec5 (diff) | |
Merge pull request #17041 from Shadowghost/media-source-handling-fixes
Media source handling fixes
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); + } + } +} |
