From 7939f3b009e830e38a3f37455418011043429ee3 Mon Sep 17 00:00:00 2001
From: TheMelmacian <76712303+TheMelmacian@users.noreply.github.com>
Date: Mon, 25 May 2026 23:40:40 +0200
Subject: only fetch language codes for the requested library when generating
filter values
---
MediaBrowser.Controller/Library/ILibraryManager.cs | 8 ++++++++
MediaBrowser.Controller/Persistence/IItemRepository.cs | 9 +++++++++
2 files changed, 17 insertions(+)
(limited to 'MediaBrowser.Controller')
diff --git a/MediaBrowser.Controller/Library/ILibraryManager.cs b/MediaBrowser.Controller/Library/ILibraryManager.cs
index c23eba75ef..c37b13ea4f 100644
--- a/MediaBrowser.Controller/Library/ILibraryManager.cs
+++ b/MediaBrowser.Controller/Library/ILibraryManager.cs
@@ -800,5 +800,13 @@ namespace MediaBrowser.Controller.Library
/// The stream type.
/// List of language codes.
IReadOnlyList GetMediaStreamLanguages(MediaStreamType mediaStreamType);
+
+ ///
+ /// Gets a list of all language codes for the matching items and the the provided stream type.
+ ///
+ /// The stream type.
+ /// The query filter.
+ /// List of language codes.
+ IReadOnlyList GetMediaStreamLanguages(MediaStreamType mediaStreamType, InternalItemsQuery query);
}
}
diff --git a/MediaBrowser.Controller/Persistence/IItemRepository.cs b/MediaBrowser.Controller/Persistence/IItemRepository.cs
index 291916ab25..d44fe57bed 100644
--- a/MediaBrowser.Controller/Persistence/IItemRepository.cs
+++ b/MediaBrowser.Controller/Persistence/IItemRepository.cs
@@ -7,6 +7,7 @@ using Jellyfin.Data.Enums;
using Jellyfin.Database.Implementations.Entities;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Querying;
namespace MediaBrowser.Controller.Persistence;
@@ -119,6 +120,14 @@ public interface IItemRepository
/// The list of genre names.
IReadOnlyList GetGenreNames();
+ ///
+ /// Gets all language codes of the matching base items and the provided stream type.
+ ///
+ /// The query filter.
+ /// The type of the media stream.
+ /// List of language codes.
+ public IReadOnlyList GetMediaStreamLanguages(InternalItemsQuery filter, MediaStreamType mediaStreamType);
+
///
/// Gets all artist names.
///
--
cgit v1.2.3
From 507998a4e327ad13d03b4244da967cffb8b03a72 Mon Sep 17 00:00:00 2001
From: Shadowghost
Date: Sun, 7 Jun 2026 22:37:34 +0200
Subject: Derive version-aware media source names
---
MediaBrowser.Controller/Entities/BaseItem.cs | 117 +++++++++++++++++++--
.../Entities/BaseItemTests.cs | 62 +++++++++++
2 files changed, 172 insertions(+), 7 deletions(-)
(limited to 'MediaBrowser.Controller')
diff --git a/MediaBrowser.Controller/Entities/BaseItem.cs b/MediaBrowser.Controller/Entities/BaseItem.cs
index 21304768bd..c69e24f876 100644
--- a/MediaBrowser.Controller/Entities/BaseItem.cs
+++ b/MediaBrowser.Controller/Entities/BaseItem.cs
@@ -87,6 +87,10 @@ namespace MediaBrowser.Controller.Entities
Model.Entities.ExtraType.Short
};
+ // Separators the naming layer treats as version delimiters (Emby.Naming VideoFlagDelimiters),
+ // used when stripping the shared prefix from an alternate version's media source name.
+ private static readonly char[] VersionSeparators = [' ', '-', '_', '.'];
+
private string _sortName;
private string _forcedSortName;
@@ -1099,8 +1103,9 @@ namespace MediaBrowser.Controller.Entities
}
}
- var list = GetAllItemsForMediaSources();
- var result = list.Select(i => GetVersionInfo(enablePathSubstitution, i.Item, i.MediaSourceType)).ToList();
+ var list = GetAllItemsForMediaSources().ToList();
+ var commonPrefix = GetCommonNamePrefix(list);
+ var result = list.Select(i => GetVersionInfo(enablePathSubstitution, i.Item, i.MediaSourceType, commonPrefix)).ToList();
if (IsActiveRecording())
{
@@ -1128,7 +1133,7 @@ namespace MediaBrowser.Controller.Entities
return Enumerable.Empty<(BaseItem, MediaSourceType)>();
}
- private MediaSourceInfo GetVersionInfo(bool enablePathSubstitution, BaseItem item, MediaSourceType type)
+ private MediaSourceInfo GetVersionInfo(bool enablePathSubstitution, BaseItem item, MediaSourceType type, string commonPrefix = null)
{
ArgumentNullException.ThrowIfNull(item);
@@ -1141,7 +1146,7 @@ namespace MediaBrowser.Controller.Entities
Protocol = protocol ?? MediaProtocol.File,
MediaStreams = MediaSourceManager.GetMediaStreams(item.Id),
MediaAttachments = MediaSourceManager.GetMediaAttachments(item.Id),
- Name = GetMediaSourceName(item),
+ Name = GetMediaSourceName(item, commonPrefix),
Path = enablePathSubstitution ? GetMappedPath(item, itemPath, protocol) : itemPath,
RunTimeTicks = item.RunTimeTicks,
Container = item.Container,
@@ -1220,7 +1225,7 @@ namespace MediaBrowser.Controller.Entities
return info;
}
- internal string GetMediaSourceName(BaseItem item)
+ internal string GetMediaSourceName(BaseItem item, string commonPrefix = null)
{
var terms = new List();
@@ -1228,12 +1233,31 @@ namespace MediaBrowser.Controller.Entities
if (item.IsFileProtocol && !string.IsNullOrEmpty(path))
{
var displayName = System.IO.Path.GetFileNameWithoutExtension(path);
- if (HasLocalAlternateVersions)
+
+ // Prefer the suffix that differs from the other versions: strip the prefix shared by
+ // all sibling files. This works regardless of folder layout, so it also labels episode
+ // versions that share a season folder (e.g. "Greyscale" instead of the full
+ // "Show - S01E02 - Title - Greyscale"). The prefix is already retreated to a separator
+ // boundary (see GetCommonVersionPrefix).
+ if (!string.IsNullOrEmpty(commonPrefix)
+ && displayName.Length > commonPrefix.Length
+ && displayName.StartsWith(commonPrefix, StringComparison.OrdinalIgnoreCase))
+ {
+ var name = displayName.AsSpan(commonPrefix.Length).TrimStart(VersionSeparators);
+ if (!name.IsWhiteSpace())
+ {
+ terms.Add(name.ToString());
+ }
+ }
+
+ // Fall back to the containing folder name (the common layout for movie versions, and
+ // the path taken when no common prefix could be derived).
+ if (terms.Count == 0 && HasLocalAlternateVersions)
{
var containingFolderName = System.IO.Path.GetFileName(ContainingFolderPath);
if (displayName.Length > containingFolderName.Length && displayName.StartsWith(containingFolderName, StringComparison.OrdinalIgnoreCase))
{
- var name = displayName.AsSpan(containingFolderName.Length).TrimStart([' ', '-']);
+ var name = displayName.AsSpan(containingFolderName.Length).TrimStart(VersionSeparators);
if (!name.IsWhiteSpace())
{
terms.Add(name.ToString());
@@ -1290,6 +1314,85 @@ namespace MediaBrowser.Controller.Entities
return string.Join('/', terms);
}
+ ///
+ /// Derives the prefix shared by the supplied media source items' file names, used to strip the
+ /// common part and surface a short version label per source. Returns null when there are fewer
+ /// than two file-based sources, since there is nothing to differentiate.
+ ///
+ /// The media source items.
+ /// The shared prefix, or null when no useful prefix exists.
+ private static string GetCommonNamePrefix(IReadOnlyList<(BaseItem Item, MediaSourceType MediaSourceType)> items)
+ {
+ var fileNames = new List();
+ foreach (var (item, _) in items)
+ {
+ if (item.IsFileProtocol && !string.IsNullOrEmpty(item.Path))
+ {
+ fileNames.Add(System.IO.Path.GetFileNameWithoutExtension(item.Path));
+ }
+ }
+
+ if (fileNames.Count < 2)
+ {
+ return null;
+ }
+
+ var prefix = GetCommonVersionPrefix(fileNames);
+ return string.IsNullOrEmpty(prefix) ? null : prefix;
+ }
+
+ ///
+ /// Computes the case-insensitive longest common prefix of the supplied version file names,
+ /// retreated to the last separator boundary. Retreating keeps the differing suffix intact:
+ /// it avoids slicing through a word every version shares (e.g. "Grey" in "Greyscale" and
+ /// "Greyish") while still trimming the common part when every version is suffixed (e.g.
+ /// "- Greyscale" / "- Colorized"). The separators mirror the version delimiters recognised by
+ /// the naming layer (Emby.Naming VideoFlagDelimiters).
+ ///
+ /// The version file names without extension; must contain at least one entry.
+ /// The shared prefix retreated to a separator boundary, or an empty string when none is shared.
+ internal static string GetCommonVersionPrefix(IReadOnlyList fileNames)
+ {
+ var prefix = fileNames[0];
+ for (var i = 1; i < fileNames.Count && prefix.Length > 0; i++)
+ {
+ var name = fileNames[i];
+ var length = Math.Min(prefix.Length, name.Length);
+ var common = 0;
+ while (common < length && char.ToUpperInvariant(prefix[common]) == char.ToUpperInvariant(name[common]))
+ {
+ common++;
+ }
+
+ prefix = prefix[..common];
+ }
+
+ // If the common prefix is itself a whole file name then one version is unlabelled (the
+ // base name); the boundary already sits at the end of that name, so don't retreat into it.
+ var prefixIsWholeName = false;
+ for (var i = 0; i < fileNames.Count; i++)
+ {
+ if (fileNames[i].Length == prefix.Length)
+ {
+ prefixIsWholeName = true;
+ break;
+ }
+ }
+
+ if (!prefixIsWholeName)
+ {
+ var cut = prefix.Length;
+ while (cut > 0 && Array.IndexOf(VersionSeparators, prefix[cut - 1]) < 0)
+ {
+ cut--;
+ }
+
+ prefix = prefix[..cut];
+ }
+
+ return prefix;
+ }
+
public Task RefreshMetadata(CancellationToken cancellationToken)
{
return RefreshMetadata(new MetadataRefreshOptions(new DirectoryService(FileSystem)), cancellationToken);
diff --git a/tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs b/tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs
index 5c187da413..8af176138c 100644
--- a/tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs
+++ b/tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs
@@ -46,4 +46,66 @@ public class BaseItemTests
Assert.Equal(name, video.GetMediaSourceName(video));
Assert.Equal(altName, video.GetMediaSourceName(videoAlt));
}
+
+ [Theory]
+ // Episode versions share a season folder; the common prefix (not the folder name) yields the label.
+ // Both files carry a suffix (no bare base name), so the shared "- " must be stripped too.
+ [InlineData(
+ "Spider-Noir - S01E02 - Wo ist Flint - Greyscale",
+ "Spider-Noir - S01E02 - Wo ist Flint - Colorized",
+ "Greyscale",
+ "Colorized")]
+ // One version is the bare base name; the other is suffixed.
+ [InlineData(
+ "Spider-Noir - S01E02 - Wo ist Flint",
+ "Spider-Noir - S01E02 - Wo ist Flint - Greyscale",
+ "Spider-Noir - S01E02 - Wo ist Flint",
+ "Greyscale")]
+ // Suffixes share a leading word ("Grey"); the prefix must retreat to the separator, not split it.
+ [InlineData(
+ "Demo - S01E01 - Greyscale",
+ "Demo - S01E01 - Greyish",
+ "Greyscale",
+ "Greyish")]
+ // Underscore separator.
+ [InlineData("Movie (2020)_4K", "Movie (2020)_1080p", "4K", "1080p")]
+ // Dot separator.
+ [InlineData("Movie (2020).UHD", "Movie (2020).1080p", "UHD", "1080p")]
+ // Resolution variants that share leading digits must retreat to the separator, not yield "p"/"i".
+ [InlineData("Movie - 1080p", "Movie - 1080i", "1080p", "1080i")]
+ // Bracketed version labels: the opening bracket is kept in the label.
+ [InlineData(
+ "Blade Runner (1982) [Final Cut] [1080p HEVC AAC]",
+ "Blade Runner (1982) [EE by ADM] [480p HEVC AAC]",
+ "[Final Cut] [1080p HEVC AAC]",
+ "[EE by ADM] [480p HEVC AAC]")]
+ public void GetMediaSourceName_CommonPrefix_Valid(string primaryName, string altName, string expectedPrimary, string expectedAlt)
+ {
+ var primaryPath = "/Shows/Demo/Season 01/" + primaryName + ".mkv";
+ var altPath = "/Shows/Demo/Season 01/" + altName + ".mkv";
+ var commonPrefix = BaseItem.GetCommonVersionPrefix([primaryName, altName]);
+
+ var video = new Video()
+ {
+ Path = primaryPath
+ };
+
+ var videoAlt = new Video()
+ {
+ Path = altPath,
+ };
+
+ var mediaSourceManager = new Mock();
+ mediaSourceManager.Setup(x => x.GetPathProtocol(It.IsAny()))
+ .Returns((string x) => MediaProtocol.File);
+ var libraryManager = new Mock();
+ // No local alternate versions: these are linked (separate items), so the folder fallback is unavailable.
+ libraryManager.Setup(x => x.GetLocalAlternateVersionIds(It.IsAny()))
+ .Returns(Array.Empty());
+ BaseItem.MediaSourceManager = mediaSourceManager.Object;
+ BaseItem.LibraryManager = libraryManager.Object;
+
+ Assert.Equal(expectedPrimary, video.GetMediaSourceName(video, commonPrefix));
+ Assert.Equal(expectedAlt, videoAlt.GetMediaSourceName(videoAlt, commonPrefix));
+ }
}
--
cgit v1.2.3
From c242533f4ecdbf1fb04c172751007aab89a8645e Mon Sep 17 00:00:00 2001
From: Shadowghost
Date: Sun, 7 Jun 2026 22:37:34 +0200
Subject: Add version-aware playback tracking
---
.../Library/UserDataManager.cs | 36 ++++
.../Session/SessionManager.cs | 51 +++++-
MediaBrowser.Controller/Entities/BaseItem.cs | 33 ++--
MediaBrowser.Controller/Entities/Video.cs | 145 +++++++++++----
.../Library/IUserDataManager.cs | 8 +
.../Entities/BaseItemTests.cs | 204 +++++++++++++++++++++
6 files changed, 430 insertions(+), 47 deletions(-)
(limited to 'MediaBrowser.Controller')
diff --git a/Emby.Server.Implementations/Library/UserDataManager.cs b/Emby.Server.Implementations/Library/UserDataManager.cs
index 1281f1587f..962dd6fda8 100644
--- a/Emby.Server.Implementations/Library/UserDataManager.cs
+++ b/Emby.Server.Implementations/Library/UserDataManager.cs
@@ -385,5 +385,41 @@ namespace Emby.Server.Implementations.Library
return playedToCompletion;
}
+
+ ///
+ public void ResetPlaybackStreamSelections(User user, BaseItem item)
+ {
+ ArgumentNullException.ThrowIfNull(user);
+ ArgumentNullException.ThrowIfNull(item);
+
+ using var dbContext = _repository.CreateDbContext();
+ var rows = dbContext.UserData
+ .Where(e => e.ItemId == item.Id && e.UserId == user.Id
+ && (e.AudioStreamIndex != null || e.SubtitleStreamIndex != null))
+ .ToList();
+
+ if (rows.Count == 0)
+ {
+ return;
+ }
+
+ foreach (var row in rows)
+ {
+ row.AudioStreamIndex = null;
+ row.SubtitleStreamIndex = null;
+ }
+
+ dbContext.SaveChanges();
+
+ var cacheKey = GetCacheKey(user.InternalId, item.Id);
+ if (_cache.TryGet(cacheKey, out var cached))
+ {
+ cached.AudioStreamIndex = null;
+ cached.SubtitleStreamIndex = null;
+ _cache.AddOrUpdate(cacheKey, cached);
+ }
+
+ item.UserData = dbContext.UserData.Where(e => e.ItemId == item.Id).AsNoTracking().ToArray();
+ }
}
}
diff --git a/Emby.Server.Implementations/Session/SessionManager.cs b/Emby.Server.Implementations/Session/SessionManager.cs
index 18811ef3a9..6017b7cbf6 100644
--- a/Emby.Server.Implementations/Session/SessionManager.cs
+++ b/Emby.Server.Implementations/Session/SessionManager.cs
@@ -725,6 +725,31 @@ namespace Emby.Server.Implementations.Session
return item;
}
+ ///
+ /// Resolves the item whose user data (playback position, played status) should be updated
+ /// for a playback report. When an alternate version is played the client reports the displayed
+ /// item as ItemId and the played version as MediaSourceId .
+ ///
+ /// The now playing (displayed) item.
+ /// The reported media source id.
+ /// The item to track progress against.
+ private BaseItem GetProgressItem(BaseItem libraryItem, string mediaSourceId)
+ {
+ if (libraryItem is Video libraryVideo
+ && !string.IsNullOrEmpty(mediaSourceId)
+ && Guid.TryParse(mediaSourceId, out var mediaSourceItemId)
+ && !mediaSourceItemId.Equals(libraryVideo.Id))
+ {
+ var versionItem = libraryVideo.GetAlternateVersion(mediaSourceItemId);
+ if (versionItem is not null)
+ {
+ return versionItem;
+ }
+ }
+
+ return libraryItem;
+ }
+
///
/// Used to report that playback has started for an item.
///
@@ -756,9 +781,10 @@ namespace Emby.Server.Implementations.Session
if (libraryItem is not null)
{
+ var progressItem = GetProgressItem(libraryItem, info.MediaSourceId);
foreach (var user in users)
{
- OnPlaybackStart(user, libraryItem);
+ OnPlaybackStart(user, progressItem);
}
}
@@ -890,9 +916,10 @@ namespace Emby.Server.Implementations.Session
// only update saved user data on actual check-ins, not automated ones
if (libraryItem is not null && !isAutomated)
{
+ var progressItem = GetProgressItem(libraryItem, info.MediaSourceId);
foreach (var user in users)
{
- OnPlaybackProgress(user, libraryItem, info);
+ OnPlaybackProgress(user, progressItem, info);
}
}
@@ -952,6 +979,17 @@ namespace Emby.Server.Implementations.Session
if (changed)
{
_userDataManager.SaveUserData(user, item, data, UserDataSaveReason.PlaybackProgress, CancellationToken.None);
+
+ if (data.Played == true && item is Video playedVideo)
+ {
+ playedVideo.PropagatePlayedState(user, true);
+ }
+ }
+
+ if ((!user.RememberAudioSelections && data.AudioStreamIndex.HasValue)
+ || (!user.RememberSubtitleSelections && data.SubtitleStreamIndex.HasValue))
+ {
+ _userDataManager.ResetPlaybackStreamSelections(user, item);
}
}
@@ -1083,9 +1121,10 @@ namespace Emby.Server.Implementations.Session
if (libraryItem is not null)
{
+ var progressItem = GetProgressItem(libraryItem, info.MediaSourceId);
foreach (var user in users)
{
- playedToCompletion = OnPlaybackStopped(user, libraryItem, info.PositionTicks, info.Failed);
+ playedToCompletion = OnPlaybackStopped(user, progressItem, info.PositionTicks, info.Failed);
}
}
@@ -1138,6 +1177,12 @@ namespace Emby.Server.Implementations.Session
_userDataManager.SaveUserData(user, item, data, UserDataSaveReason.PlaybackFinished, CancellationToken.None);
+ // A completed version marks all of its alternate versions played; positions stay per-version.
+ if (data.Played == true && item is Video playedVideo)
+ {
+ playedVideo.PropagatePlayedState(user, true);
+ }
+
return playedToCompletion;
}
diff --git a/MediaBrowser.Controller/Entities/BaseItem.cs b/MediaBrowser.Controller/Entities/BaseItem.cs
index c69e24f876..84ba560900 100644
--- a/MediaBrowser.Controller/Entities/BaseItem.cs
+++ b/MediaBrowser.Controller/Entities/BaseItem.cs
@@ -1115,17 +1115,15 @@ namespace MediaBrowser.Controller.Entities
}
}
- return result.OrderBy(i =>
- {
- if (i.VideoType == VideoType.VideoFile)
- {
- return 0;
- }
+ // The source belonging to the item being queried sorts first so it is the default the client plays.
+ var selfId = Id.ToString("N", CultureInfo.InvariantCulture);
- return 1;
- }).ThenBy(i => i.Video3DFormat.HasValue ? 1 : 0)
- .ThenByDescending(i => i, new MediaSourceWidthComparator())
- .ToArray();
+ return result
+ .OrderByDescending(i => string.Equals(i.Id, selfId, StringComparison.OrdinalIgnoreCase))
+ .ThenBy(i => i.VideoType == VideoType.VideoFile ? 0 : 1)
+ .ThenBy(i => i.Video3DFormat.HasValue ? 1 : 0)
+ .ThenByDescending(i => i, new MediaSourceWidthComparator())
+ .ToArray();
}
protected virtual IEnumerable<(BaseItem Item, MediaSourceType MediaSourceType)> GetAllItemsForMediaSources()
@@ -2114,12 +2112,23 @@ namespace MediaBrowser.Controller.Entities
// I think it is okay to do this here.
// if this is only called when a user is manually forcing something to un-played
// then it probably is what we want to do...
+ ResetPlayedState(data);
+
+ UserDataManager.SaveUserData(user, this, data, UserDataSaveReason.TogglePlayed, CancellationToken.None);
+ }
+
+ ///
+ /// Clears the played state on the supplied user data.
+ ///
+ /// The user data to reset.
+ protected static void ResetPlayedState(UserItemData data)
+ {
+ ArgumentNullException.ThrowIfNull(data);
+
data.PlayCount = 0;
data.PlaybackPositionTicks = 0;
data.LastPlayedDate = null;
data.Played = false;
-
- UserDataManager.SaveUserData(user, this, data, UserDataSaveReason.TogglePlayed, CancellationToken.None);
}
///
diff --git a/MediaBrowser.Controller/Entities/Video.cs b/MediaBrowser.Controller/Entities/Video.cs
index e7a5672ebd..168ef7d817 100644
--- a/MediaBrowser.Controller/Entities/Video.cs
+++ b/MediaBrowser.Controller/Entities/Video.cs
@@ -34,11 +34,11 @@ namespace MediaBrowser.Controller.Entities
{
public Video()
{
- AdditionalParts = Array.Empty();
- LocalAlternateVersions = Array.Empty();
- SubtitleFiles = Array.Empty();
- AudioFiles = Array.Empty();
- LinkedAlternateVersions = Array.Empty();
+ AdditionalParts = [];
+ LocalAlternateVersions = [];
+ SubtitleFiles = [];
+ AudioFiles = [];
+ LinkedAlternateVersions = [];
}
[JsonIgnore]
@@ -335,6 +335,92 @@ namespace MediaBrowser.Controller.Entities
PresentationUniqueKey = CreatePresentationUniqueKey();
}
+ ///
+ /// Marks the played status of this video and propagates it to its alternate versions.
+ ///
+ /// The user.
+ /// The date played.
+ /// if set to true [reset position].
+ public override void MarkPlayed(User user, DateTime? datePlayed, bool resetPosition)
+ {
+ base.MarkPlayed(user, datePlayed, resetPosition);
+ PropagatePlayedState(user, true, resetPosition);
+ }
+
+ ///
+ /// Marks this video unplayed and propagates the change to its alternate versions.
+ ///
+ /// The user.
+ public override void MarkUnplayed(User user)
+ {
+ base.MarkUnplayed(user);
+
+ // MarkUnplayed always clears the position on this video, so reset the versions too.
+ PropagatePlayedState(user, false, true);
+ }
+
+ ///
+ /// Propagates the played status to every alternate version of this video.
+ ///
+ /// The user.
+ /// The played status to apply to the alternate versions.
+ /// When marking played, controls whether each version's resume point
+ /// is also reset (true ) or left untouched (false ). Ignored when marking unplayed,
+ /// which always fully resets every version.
+ public void PropagatePlayedState(User user, bool played, bool resetPosition = true)
+ {
+ ArgumentNullException.ThrowIfNull(user);
+
+ if (!PrimaryVersionId.HasValue && LinkedAlternateVersions.Length == 0 && !HasLocalAlternateVersions)
+ {
+ return;
+ }
+
+ foreach (var (item, _) in GetAllItemsForMediaSources())
+ {
+ if (item.Id.Equals(Id) || item is not Video)
+ {
+ continue;
+ }
+
+ if (played)
+ {
+ var dto = new UpdateUserItemDataDto { Played = true };
+ if (resetPosition)
+ {
+ dto.PlaybackPositionTicks = 0;
+ }
+
+ // SaveUserData only writes the fields set on the DTO, so play count and other state are preserved.
+ UserDataManager.SaveUserData(user, item, dto, UserDataSaveReason.TogglePlayed);
+ }
+ else
+ {
+ var data = UserDataManager.GetUserData(user, item);
+ if (data is null)
+ {
+ continue;
+ }
+
+ ResetPlayedState(data);
+ UserDataManager.SaveUserData(user, item, data, UserDataSaveReason.TogglePlayed, CancellationToken.None);
+ }
+ }
+ }
+
+ ///
+ /// Gets the alternate version of this video that matches the supplied item id.
+ ///
+ /// The version item id (the playback media source id).
+ /// The matching version, or null when the id is not a version of this video.
+ public Video GetAlternateVersion(Guid itemId)
+ {
+ return GetAllItemsForMediaSources()
+ .Select(i => i.Item)
+ .OfType()
+ .FirstOrDefault(i => i.Id.Equals(itemId));
+ }
+
public override string CreatePresentationUniqueKey()
{
if (PrimaryVersionId.HasValue)
@@ -644,37 +730,32 @@ namespace MediaBrowser.Controller.Entities
protected override IEnumerable<(BaseItem Item, MediaSourceType MediaSourceType)> GetAllItemsForMediaSources()
{
- var list = new List<(BaseItem, MediaSourceType)>
- {
- (this, MediaSourceType.Default)
- };
-
- list.AddRange(
- LibraryManager.GetLinkedAlternateVersions(this)
- .Select(i => ((BaseItem)i, MediaSourceType.Grouping)));
-
- if (PrimaryVersionId.HasValue)
- {
- if (LibraryManager.GetItemById(PrimaryVersionId.Value) is Video primary)
- {
- var existingIds = list.Select(i => i.Item1.Id).ToList();
- list.Add((primary, MediaSourceType.Grouping));
- list.AddRange(LibraryManager.GetLinkedAlternateVersions(primary).Where(i => !existingIds.Contains(i.Id)).Select(i => ((BaseItem)i, MediaSourceType.Grouping)));
- }
- }
+ var primary = PrimaryVersionId.HasValue
+ ? LibraryManager.GetItemById(PrimaryVersionId.Value) as Video
+ : null;
+
+ // This video and its linked alternates, when this is itself an alternate, the primary and the primary's linked alternates.
+ var grouped = new[] { ((BaseItem)this, MediaSourceType.Default) }
+ .Concat(LibraryManager.GetLinkedAlternateVersions(this).Select(i => ((BaseItem)i, MediaSourceType.Grouping)))
+ .Concat(primary is null
+ ? []
+ : LibraryManager.GetLinkedAlternateVersions(primary).Prepend(primary).Select(i => ((BaseItem)i, MediaSourceType.Grouping)))
+ .ToList();
- var localAlternates = list
- .SelectMany(i =>
- {
- return i.Item1 is Video video ? LibraryManager.GetLocalAlternateVersionIds(video) : Enumerable.Empty();
- })
+ // The local (file-based) alternate versions of every grouped item.
+ var localAlternates = grouped
+ .Select(i => i.Item1)
+ .OfType()
+ .SelectMany(LibraryManager.GetLocalAlternateVersionIds)
.Select(LibraryManager.GetItemById)
.Where(i => i is not null)
- .ToList();
+ .Select(i => (i, MediaSourceType.Default));
- list.AddRange(localAlternates.Select(i => (i, MediaSourceType.Default)));
-
- return list;
+ // Deduplicate
+ return grouped
+ .Concat(localAlternates)
+ .DistinctBy(i => i.Item1.Id)
+ .ToList();
}
}
}
diff --git a/MediaBrowser.Controller/Library/IUserDataManager.cs b/MediaBrowser.Controller/Library/IUserDataManager.cs
index 798812bf1f..0b4229c784 100644
--- a/MediaBrowser.Controller/Library/IUserDataManager.cs
+++ b/MediaBrowser.Controller/Library/IUserDataManager.cs
@@ -80,5 +80,13 @@ namespace MediaBrowser.Controller.Library
/// New playstate.
/// True if playstate was updated.
bool UpdatePlayState(BaseItem item, UserItemData data, long? reportedPositionTicks);
+
+ ///
+ /// Clears any stored audio and subtitle stream selections for the given user/item pair.
+ /// Used when the user has opted out of remembering selections.
+ ///
+ /// The user.
+ /// The item.
+ void ResetPlaybackStreamSelections(User user, BaseItem item);
}
}
diff --git a/tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs b/tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs
index 8af176138c..2ee95f076b 100644
--- a/tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs
+++ b/tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs
@@ -1,6 +1,15 @@
using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Reflection;
+using System.Threading;
+using Jellyfin.Database.Implementations.Entities;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.LiveTv;
+using MediaBrowser.Controller.MediaSegments;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Entities;
using MediaBrowser.Model.MediaInfo;
using Moq;
using Xunit;
@@ -108,4 +117,199 @@ public class BaseItemTests
Assert.Equal(expectedPrimary, video.GetMediaSourceName(video, commonPrefix));
Assert.Equal(expectedAlt, videoAlt.GetMediaSourceName(videoAlt, commonPrefix));
}
+
+ [Fact]
+ public void GetAlternateVersion_ReturnsMatchingLocalVersion()
+ {
+ var (primary, alt1, alt2) = SetupVersionGroup();
+
+ Assert.Same(alt1, primary.GetAlternateVersion(alt1.Id));
+ Assert.Same(alt2, primary.GetAlternateVersion(alt2.Id));
+ Assert.Same(primary, primary.GetAlternateVersion(primary.Id));
+ Assert.Null(primary.GetAlternateVersion(Guid.NewGuid()));
+ }
+
+ [Fact]
+ public void PropagatePlayedState_MarksAlternateVersions_AndResetsPositionByDefault()
+ {
+ var (primary, alt1, alt2) = SetupVersionGroup();
+
+ var saved = CaptureSaves();
+
+ var user = new User("test", "default", "default");
+ primary.PropagatePlayedState(user, true);
+
+ // Both alternate versions are marked played, the primary (self) is not, and the position is
+ // reset so a watched version does not linger in "Continue Watching".
+ Assert.Equal(2, saved.Count);
+ Assert.DoesNotContain(saved, e => e.ItemId.Equals(primary.Id));
+ Assert.Contains(saved, e => e.ItemId.Equals(alt1.Id));
+ Assert.Contains(saved, e => e.ItemId.Equals(alt2.Id));
+ Assert.All(saved, e =>
+ {
+ Assert.True(e.Dto.Played.GetValueOrDefault());
+ Assert.Equal(0, e.Dto.PlaybackPositionTicks);
+ });
+ }
+
+ [Fact]
+ public void PropagatePlayedState_WithoutReset_LeavesPositionUntouched()
+ {
+ var (primary, _, _) = SetupVersionGroup();
+
+ var saved = CaptureSaves();
+
+ primary.PropagatePlayedState(new User("test", "default", "default"), true, resetPosition: false);
+
+ Assert.Equal(2, saved.Count);
+ Assert.All(saved, e =>
+ {
+ Assert.True(e.Dto.Played.GetValueOrDefault());
+ Assert.Null(e.Dto.PlaybackPositionTicks);
+ });
+ }
+
+ [Fact]
+ public void PropagatePlayedState_Unwatched_ClearsAllWatchedStateOnVersions()
+ {
+ var (primary, alt1, alt2) = SetupVersionGroup();
+
+ // Each alternate starts out watched, with a play count, resume point and last-played date.
+ var existing = new Dictionary
+ {
+ [alt1.Id] = new UserItemData { Key = "alt1", Played = true, PlayCount = 3, PlaybackPositionTicks = 1000, LastPlayedDate = new DateTime(2020, 1, 1, 0, 0, 0, DateTimeKind.Utc) },
+ [alt2.Id] = new UserItemData { Key = "alt2", Played = true, PlayCount = 1, PlaybackPositionTicks = 500, LastPlayedDate = new DateTime(2021, 2, 2, 0, 0, 0, DateTimeKind.Utc) },
+ };
+
+ var saved = new List();
+ var userDataManager = new Mock();
+ userDataManager.Setup(x => x.GetUserData(It.IsAny(), It.IsAny()))
+ .Returns((User _, BaseItem item) => existing.GetValueOrDefault(item.Id));
+ userDataManager
+ .Setup(x => x.SaveUserData(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()))
+ .Callback((_, _, data, _, _) => saved.Add(data));
+ BaseItem.UserDataManager = userDataManager.Object;
+
+ primary.PropagatePlayedState(new User("test", "default", "default"), false);
+
+ // Every alternate is fully reset to an unwatched state, mirroring MarkUnplayed: the played flag,
+ // play count, resume point and last-played date are all cleared so no watched state lingers.
+ Assert.Equal(2, saved.Count);
+ Assert.All(saved, d =>
+ {
+ Assert.False(d.Played);
+ Assert.Equal(0, d.PlayCount);
+ Assert.Equal(0, d.PlaybackPositionTicks);
+ Assert.Null(d.LastPlayedDate);
+ });
+ }
+
+ private static List<(Guid ItemId, UpdateUserItemDataDto Dto)> CaptureSaves()
+ {
+ var saved = new List<(Guid ItemId, UpdateUserItemDataDto Dto)>();
+ var userDataManager = new Mock();
+ userDataManager
+ .Setup(x => x.SaveUserData(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()))
+ .Callback((_, item, dto, _) => saved.Add((item.Id, dto)));
+ BaseItem.UserDataManager = userDataManager.Object;
+ return saved;
+ }
+
+ [Fact]
+ public void PropagatePlayedState_SingleVersion_DoesNothing()
+ {
+ var solo = new Video { Id = Guid.NewGuid(), Path = "/Movies/Solo/Solo.mkv" };
+
+ var mediaSourceManager = new Mock();
+ mediaSourceManager.Setup(x => x.GetPathProtocol(It.IsAny())).Returns(MediaProtocol.File);
+ var libraryManager = new Mock();
+ libraryManager.Setup(x => x.GetLocalAlternateVersionIds(It.IsAny())).Returns(Array.Empty());
+ libraryManager.Setup(x => x.GetLinkedAlternateVersions(It.IsAny())).Returns(Array.Empty());
+ BaseItem.MediaSourceManager = mediaSourceManager.Object;
+ BaseItem.LibraryManager = libraryManager.Object;
+
+ var userDataManager = new Mock();
+ BaseItem.UserDataManager = userDataManager.Object;
+
+ solo.PropagatePlayedState(new User("test", "default", "default"), true);
+
+ userDataManager.Verify(
+ x => x.SaveUserData(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()),
+ Times.Never);
+ }
+
+ private static (Video Primary, Video Alt1, Video Alt2) SetupVersionGroup()
+ {
+ var primary = new Video { Id = Guid.NewGuid(), Path = "/Movies/Movie/Movie.mkv" };
+ var alt1 = new Video { Id = Guid.NewGuid(), Path = "/Movies/Movie/Movie - 1080p.mkv", PrimaryVersionId = primary.Id };
+ var alt2 = new Video { Id = Guid.NewGuid(), Path = "/Movies/Movie/Movie - 4K.mkv", PrimaryVersionId = primary.Id };
+
+ // 2160p primary, 1080p alternates: width is only the ordering tiebreaker, set so it would place
+ // the primary first — letting the tests confirm the queried version's own source still wins.
+ var widths = new Dictionary { [primary.Id] = 3840, [alt1.Id] = 1920, [alt2.Id] = 1920 };
+ var mediaSourceManager = new Mock();
+ mediaSourceManager.Setup(x => x.GetPathProtocol(It.IsAny())).Returns(MediaProtocol.File);
+ mediaSourceManager.Setup(x => x.GetMediaStreams(It.IsAny()))
+ .Returns((Guid id) => new List { new MediaStream { Type = MediaStreamType.Video, Width = widths.GetValueOrDefault(id) } });
+ mediaSourceManager.Setup(x => x.GetMediaAttachments(It.IsAny())).Returns(new List());
+
+ var segmentManager = new Mock();
+ segmentManager.Setup(x => x.IsTypeSupported(It.IsAny())).Returns(false);
+ BaseItem.MediaSegmentManager = segmentManager.Object;
+
+ var libraryManager = new Mock();
+ libraryManager.Setup(x => x.GetLinkedAlternateVersions(It.IsAny())).Returns(Array.Empty());
+ libraryManager.Setup(x => x.GetLocalAlternateVersionIds(primary)).Returns(new[] { alt1.Id, alt2.Id });
+ libraryManager.Setup(x => x.GetLocalAlternateVersionIds(alt1)).Returns(Array.Empty());
+ libraryManager.Setup(x => x.GetLocalAlternateVersionIds(alt2)).Returns(Array.Empty());
+ libraryManager.Setup(x => x.GetItemById(alt1.Id)).Returns(alt1);
+ libraryManager.Setup(x => x.GetItemById(alt2.Id)).Returns(alt2);
+ libraryManager.Setup(x => x.GetItemById(primary.Id)).Returns(primary);
+
+ var recordingsManager = new Mock();
+ recordingsManager.Setup(x => x.GetActiveRecordingInfo(It.IsAny())).Returns((ActiveRecordingInfo?)null);
+ Video.RecordingsManager = recordingsManager.Object;
+
+ BaseItem.MediaSourceManager = mediaSourceManager.Object;
+ BaseItem.LibraryManager = libraryManager.Object;
+
+ return (primary, alt1, alt2);
+ }
+
+ [Fact]
+ public void GetMediaSources_DefaultsToTheQueriedVersionsOwnSource()
+ {
+ var (primary, alt1, _) = SetupVersionGroup();
+
+ // Resuming the 1080p alternate must default to the 1080p source, not the higher-resolution
+ // 2160p primary that the width ordering would otherwise place first.
+ Assert.Equal(alt1.Id.ToString("N"), alt1.GetMediaSources(false)[0].Id);
+
+ // Opening the primary still defaults to the primary's own (here highest-resolution) source.
+ Assert.Equal(primary.Id.ToString("N"), primary.GetMediaSources(false)[0].Id);
+ }
+
+ [Fact]
+ public void GetAllItemsForMediaSources_FromAnyVersion_HasNoDuplicates()
+ {
+ var (primary, alt1, alt2) = SetupVersionGroup();
+
+ var method = typeof(Video).GetMethod("GetAllItemsForMediaSources", BindingFlags.Instance | BindingFlags.NonPublic);
+ Assert.NotNull(method);
+
+ // Each version must surface exactly once, regardless of which member the list is built from.
+ // Building from an alternate previously re-added that alternate as a "local alternate" of the
+ // primary, producing a duplicate entry in the version dropdown.
+ foreach (var source in new[] { primary, alt1, alt2 })
+ {
+ var items = (IEnumerable<(BaseItem Item, MediaSourceType MediaSourceType)>)method!.Invoke(source, null)!;
+ var ids = items.Select(i => i.Item.Id).ToList();
+
+ Assert.Equal(3, ids.Count);
+ Assert.Equal(ids.Count, ids.Distinct().Count());
+ Assert.Contains(primary.Id, ids);
+ Assert.Contains(alt1.Id, ids);
+ Assert.Contains(alt2.Id, ids);
+ }
+ }
}
--
cgit v1.2.3
From 09723bd123b767b7d99157faa86df0fc305c87ea Mon Sep 17 00:00:00 2001
From: Shadowghost
Date: Sun, 7 Jun 2026 22:37:34 +0200
Subject: Aggregate alternate versions via GetAllVersions
---
MediaBrowser.Controller/Entities/BaseItem.cs | 31 +++++++++++++++-------
MediaBrowser.Controller/Entities/Video.cs | 18 ++++++++++---
.../Entities/BaseItemTests.cs | 7 +++++
3 files changed, 42 insertions(+), 14 deletions(-)
(limited to 'MediaBrowser.Controller')
diff --git a/MediaBrowser.Controller/Entities/BaseItem.cs b/MediaBrowser.Controller/Entities/BaseItem.cs
index 84ba560900..0a3fdbc1ab 100644
--- a/MediaBrowser.Controller/Entities/BaseItem.cs
+++ b/MediaBrowser.Controller/Entities/BaseItem.cs
@@ -87,9 +87,7 @@ namespace MediaBrowser.Controller.Entities
Model.Entities.ExtraType.Short
};
- // Separators the naming layer treats as version delimiters (Emby.Naming VideoFlagDelimiters),
- // used when stripping the shared prefix from an alternate version's media source name.
- private static readonly char[] VersionSeparators = [' ', '-', '_', '.'];
+ private static readonly char[] VersionDelimiters = ['-', '_', '.'];
private string _sortName;
@@ -1235,13 +1233,13 @@ namespace MediaBrowser.Controller.Entities
// Prefer the suffix that differs from the other versions: strip the prefix shared by
// all sibling files. This works regardless of folder layout, so it also labels episode
// versions that share a season folder (e.g. "Greyscale" instead of the full
- // "Show - S01E02 - Title - Greyscale"). The prefix is already retreated to a separator
+ // "Show - S01E02 - Title - Greyscale"). The prefix is already retreated to a delimiter
// boundary (see GetCommonVersionPrefix).
if (!string.IsNullOrEmpty(commonPrefix)
&& displayName.Length > commonPrefix.Length
&& displayName.StartsWith(commonPrefix, StringComparison.OrdinalIgnoreCase))
{
- var name = displayName.AsSpan(commonPrefix.Length).TrimStart(VersionSeparators);
+ var name = displayName.AsSpan(commonPrefix.Length).TrimStart([' ', .. VersionDelimiters]);
if (!name.IsWhiteSpace())
{
terms.Add(name.ToString());
@@ -1255,7 +1253,7 @@ namespace MediaBrowser.Controller.Entities
var containingFolderName = System.IO.Path.GetFileName(ContainingFolderPath);
if (displayName.Length > containingFolderName.Length && displayName.StartsWith(containingFolderName, StringComparison.OrdinalIgnoreCase))
{
- var name = displayName.AsSpan(containingFolderName.Length).TrimStart(VersionSeparators);
+ var name = displayName.AsSpan(containingFolderName.Length).TrimStart([' ', .. VersionDelimiters]);
if (!name.IsWhiteSpace())
{
terms.Add(name.ToString());
@@ -1341,11 +1339,14 @@ namespace MediaBrowser.Controller.Entities
///
/// Computes the case-insensitive longest common prefix of the supplied version file names,
- /// retreated to the last separator boundary. Retreating keeps the differing suffix intact:
+ /// retreated to the last delimiter boundary. Retreating keeps the differing suffix intact:
/// it avoids slicing through a word every version shares (e.g. "Grey" in "Greyscale" and
/// "Greyish") while still trimming the common part when every version is suffixed (e.g.
- /// "- Greyscale" / "- Colorized"). The separators mirror the version delimiters recognised by
- /// the naming layer (Emby.Naming VideoFlagDelimiters).
+ /// "- Greyscale" / "- Colorized"). It prefers a structural delimiter ('-', '_', '.') so a
+ /// token shared by the descriptors but separated only by spaces (e.g. a common "2160p ") is
+ /// kept in the label, falling back to a space only when no structural delimiter is shared. The
+ /// separators mirror the version delimiters recognised by the naming layer (Emby.Naming
+ /// VideoFlagDelimiters).
///
/// The version file names without extension; must contain at least one entry.
/// The shared prefix retreated to a separator boundary, or an empty string when none is shared.
@@ -1379,12 +1380,22 @@ namespace MediaBrowser.Controller.Entities
if (!prefixIsWholeName)
{
+ // Retreat to the last structural delimiter ('-', '_', '.').
var cut = prefix.Length;
- while (cut > 0 && Array.IndexOf(VersionSeparators, prefix[cut - 1]) < 0)
+ while (cut > 0 && Array.IndexOf(VersionDelimiters, prefix[cut - 1]) < 0)
{
cut--;
}
+ if (cut == 0)
+ {
+ cut = prefix.Length;
+ while (cut > 0 && prefix[cut - 1] != ' ')
+ {
+ cut--;
+ }
+ }
+
prefix = prefix[..cut];
}
diff --git a/MediaBrowser.Controller/Entities/Video.cs b/MediaBrowser.Controller/Entities/Video.cs
index 168ef7d817..f48de0869a 100644
--- a/MediaBrowser.Controller/Entities/Video.cs
+++ b/MediaBrowser.Controller/Entities/Video.cs
@@ -408,6 +408,19 @@ namespace MediaBrowser.Controller.Entities
}
}
+ ///
+ /// Gets this video together with all of its alternate versions (local and linked and, when this
+ /// is itself an alternate, the primary and the primary's other versions), deduplicated.
+ ///
+ /// This video and every alternate version of it.
+ public IReadOnlyList GetAllVersions()
+ {
+ return GetAllItemsForMediaSources()
+ .Select(i => i.Item)
+ .OfType()
+ .ToList();
+ }
+
///
/// Gets the alternate version of this video that matches the supplied item id.
///
@@ -415,10 +428,7 @@ namespace MediaBrowser.Controller.Entities
/// The matching version, or null when the id is not a version of this video.
public Video GetAlternateVersion(Guid itemId)
{
- return GetAllItemsForMediaSources()
- .Select(i => i.Item)
- .OfType()
- .FirstOrDefault(i => i.Id.Equals(itemId));
+ return GetAllVersions().FirstOrDefault(i => i.Id.Equals(itemId));
}
public override string CreatePresentationUniqueKey()
diff --git a/tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs b/tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs
index 2ee95f076b..664821815b 100644
--- a/tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs
+++ b/tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs
@@ -82,6 +82,13 @@ public class BaseItemTests
[InlineData("Movie (2020).UHD", "Movie (2020).1080p", "UHD", "1080p")]
// Resolution variants that share leading digits must retreat to the separator, not yield "p"/"i".
[InlineData("Movie - 1080p", "Movie - 1080i", "1080p", "1080i")]
+ // A token shared by the descriptors but separated only by spaces (the resolution) must stay in the
+ // label: retreat to the '-' delimiter, not the interior space, so the resolution is kept.
+ [InlineData(
+ "movie (2020) - 2160p Extended",
+ "movie (2020) - 2160p Original",
+ "2160p Extended",
+ "2160p Original")]
// Bracketed version labels: the opening bracket is kept in the label.
[InlineData(
"Blade Runner (1982) [Final Cut] [1080p HEVC AAC]",
--
cgit v1.2.3
From 63990d6a2d113ce3d48d819d4cc351ebdcf2bff6 Mon Sep 17 00:00:00 2001
From: Shadowghost
Date: Sun, 7 Jun 2026 22:37:34 +0200
Subject: Surface extras across all versions
---
MediaBrowser.Controller/Entities/BaseItem.cs | 13 +++++++++++--
MediaBrowser.Controller/Entities/Video.cs | 13 +++++++++++++
2 files changed, 24 insertions(+), 2 deletions(-)
(limited to 'MediaBrowser.Controller')
diff --git a/MediaBrowser.Controller/Entities/BaseItem.cs b/MediaBrowser.Controller/Entities/BaseItem.cs
index 0a3fdbc1ab..49a4ed4bf6 100644
--- a/MediaBrowser.Controller/Entities/BaseItem.cs
+++ b/MediaBrowser.Controller/Entities/BaseItem.cs
@@ -2854,6 +2854,15 @@ namespace MediaBrowser.Controller.Entities
return LibraryManager.Sort(GetExtras(user).Where(e => e.ExtraType == Model.Entities.ExtraType.ThemeVideo), user, orderBy).ToArray();
}
+ ///
+ /// Gets the ids of the items whose owned extras belong to this item.
+ ///
+ /// An array containing the owner ids.
+ protected virtual Guid[] GetExtraOwnerIds()
+ {
+ return [Id];
+ }
+
///
/// Get all extras associated with this item, sorted by .
///
@@ -2863,7 +2872,7 @@ namespace MediaBrowser.Controller.Entities
{
return LibraryManager.GetItemList(new InternalItemsQuery(user)
{
- OwnerIds = [Id],
+ OwnerIds = GetExtraOwnerIds(),
OrderBy = [(ItemSortBy.SortName, SortOrder.Ascending)]
});
}
@@ -2878,7 +2887,7 @@ namespace MediaBrowser.Controller.Entities
{
return LibraryManager.GetItemList(new InternalItemsQuery(user)
{
- OwnerIds = [Id],
+ OwnerIds = GetExtraOwnerIds(),
ExtraTypes = extraTypes.ToArray(),
OrderBy = [(ItemSortBy.SortName, SortOrder.Ascending)]
});
diff --git a/MediaBrowser.Controller/Entities/Video.cs b/MediaBrowser.Controller/Entities/Video.cs
index f48de0869a..943530a61a 100644
--- a/MediaBrowser.Controller/Entities/Video.cs
+++ b/MediaBrowser.Controller/Entities/Video.cs
@@ -738,6 +738,19 @@ namespace MediaBrowser.Controller.Entities
}).FirstOrDefault();
}
+ ///
+ /// Gets the ids of the items whose owned extras belong to this item.
+ /// Extras are linked to a single version but need tp be surfaced for all versions.
+ ///
+ /// An array containing the owner ids.
+ protected override Guid[] GetExtraOwnerIds()
+ {
+ return GetAllItemsForMediaSources()
+ .Select(i => i.Item.Id)
+ .Distinct()
+ .ToArray();
+ }
+
protected override IEnumerable<(BaseItem Item, MediaSourceType MediaSourceType)> GetAllItemsForMediaSources()
{
var primary = PrimaryVersionId.HasValue
--
cgit v1.2.3
From e64cc73f881fc9def52f9e362a5094dbdbbcb7fb Mon Sep 17 00:00:00 2001
From: Shadowghost
Date: Sun, 7 Jun 2026 22:37:34 +0200
Subject: Mark only linked alternate versions as grouped media sources
---
MediaBrowser.Controller/Entities/Video.cs | 15 +++++++++++++--
1 file changed, 13 insertions(+), 2 deletions(-)
(limited to 'MediaBrowser.Controller')
diff --git a/MediaBrowser.Controller/Entities/Video.cs b/MediaBrowser.Controller/Entities/Video.cs
index 943530a61a..34929e0591 100644
--- a/MediaBrowser.Controller/Entities/Video.cs
+++ b/MediaBrowser.Controller/Entities/Video.cs
@@ -254,7 +254,7 @@ namespace MediaBrowser.Controller.Entities
private int GetMediaSourceCount(HashSet callstack = null)
{
- callstack ??= new();
+ callstack ??= [];
if (PrimaryVersionId.HasValue)
{
var item = LibraryManager.GetItemById(PrimaryVersionId.Value);
@@ -757,12 +757,23 @@ namespace MediaBrowser.Controller.Entities
? LibraryManager.GetItemById(PrimaryVersionId.Value) as Video
: null;
+ var primaryLinked = primary is null
+ ? []
+ : LibraryManager.GetLinkedAlternateVersions(primary).ToList();
+
+ // Grouping marks user-merged (splittable) sources. The primary is only such a source when
+ // this video is linked onto it; for local (file-based) alternates the primary is just
+ // another default source.
+ var primaryType = primaryLinked.Any(i => i.Id.Equals(Id))
+ ? MediaSourceType.Grouping
+ : MediaSourceType.Default;
+
// This video and its linked alternates, when this is itself an alternate, the primary and the primary's linked alternates.
var grouped = new[] { ((BaseItem)this, MediaSourceType.Default) }
.Concat(LibraryManager.GetLinkedAlternateVersions(this).Select(i => ((BaseItem)i, MediaSourceType.Grouping)))
.Concat(primary is null
? []
- : LibraryManager.GetLinkedAlternateVersions(primary).Prepend(primary).Select(i => ((BaseItem)i, MediaSourceType.Grouping)))
+ : primaryLinked.Select(i => ((BaseItem)i, MediaSourceType.Grouping)).Prepend(((BaseItem)primary, primaryType)))
.ToList();
// The local (file-based) alternate versions of every grouped item.
--
cgit v1.2.3
From c693047467e2f454b4ceae7269ceb97df5e71bba Mon Sep 17 00:00:00 2001
From: nyanmisaka
Date: Tue, 9 Jun 2026 20:18:17 +0800
Subject: Fix duplicate trickplay images for interlaced videos
Signed-off-by: nyanmisaka
---
.../MediaEncoding/EncodingHelper.cs | 66 ++++++++++------------
1 file changed, 31 insertions(+), 35 deletions(-)
(limited to 'MediaBrowser.Controller')
diff --git a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs
index ff8d84d45e..320e65231c 100644
--- a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs
+++ b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs
@@ -444,6 +444,13 @@ namespace MediaBrowser.Controller.MediaEncoding
|| state.VideoStream.VideoRangeType == VideoRangeType.HLG);
}
+ private static bool IsDeinterlaceAvailable(EncodingJobInfo state)
+ {
+ var doDeintH264 = state.DeInterlace("h264", true) || state.DeInterlace("avc", true);
+ var doDeintHevc = state.DeInterlace("h265", true) || state.DeInterlace("hevc", true);
+ return doDeintH264 || doDeintHevc;
+ }
+
private bool IsVideoStreamHevcRext(EncodingJobInfo state)
{
var videoStream = state.VideoStream;
@@ -3850,9 +3857,7 @@ namespace MediaBrowser.Controller.MediaEncoding
var isVaapiEncoder = vidEncoder.Contains("vaapi", StringComparison.OrdinalIgnoreCase);
var isV4l2Encoder = vidEncoder.Contains("h264_v4l2m2m", StringComparison.OrdinalIgnoreCase);
- var doDeintH264 = state.DeInterlace("h264", true) || state.DeInterlace("avc", true);
- var doDeintHevc = state.DeInterlace("h265", true) || state.DeInterlace("hevc", true);
- var doDeintH2645 = doDeintH264 || doDeintHevc;
+ var doDeintH2645 = IsDeinterlaceAvailable(state);
var doToneMap = IsSwTonemapAvailable(state, options);
var requireDoviReshaping = doToneMap && state.VideoStream.VideoRangeType == VideoRangeType.DOVI;
@@ -4004,9 +4009,7 @@ namespace MediaBrowser.Controller.MediaEncoding
var isCuInCuOut = isNvDecoder && isNvencEncoder;
var doubleRateDeint = options.DeinterlaceDoubleRate && (state.VideoStream?.ReferenceFrameRate ?? 60) <= 30;
- var doDeintH264 = state.DeInterlace("h264", true) || state.DeInterlace("avc", true);
- var doDeintHevc = state.DeInterlace("h265", true) || state.DeInterlace("hevc", true);
- var doDeintH2645 = doDeintH264 || doDeintHevc;
+ var doDeintH2645 = IsDeinterlaceAvailable(state);
var doCuTonemap = IsHwTonemapAvailable(state, options);
var hasSubs = state.SubtitleStream is not null && ShouldEncodeSubtitle(state);
@@ -4215,9 +4218,7 @@ namespace MediaBrowser.Controller.MediaEncoding
var isMjpegEncoder = vidEncoder.Contains("mjpeg", StringComparison.OrdinalIgnoreCase);
var isDxInDxOut = isD3d11vaDecoder && isAmfEncoder;
- var doDeintH264 = state.DeInterlace("h264", true) || state.DeInterlace("avc", true);
- var doDeintHevc = state.DeInterlace("h265", true) || state.DeInterlace("hevc", true);
- var doDeintH2645 = doDeintH264 || doDeintHevc;
+ var doDeintH2645 = IsDeinterlaceAvailable(state);
var doOclTonemap = IsHwTonemapAvailable(state, options);
var hasSubs = state.SubtitleStream is not null && ShouldEncodeSubtitle(state);
@@ -4463,9 +4464,7 @@ namespace MediaBrowser.Controller.MediaEncoding
var isMjpegEncoder = vidEncoder.Contains("mjpeg", StringComparison.OrdinalIgnoreCase);
var isQsvInQsvOut = isHwDecoder && isQsvEncoder;
- var doDeintH264 = state.DeInterlace("h264", true) || state.DeInterlace("avc", true);
- var doDeintHevc = state.DeInterlace("h265", true) || state.DeInterlace("hevc", true);
- var doDeintH2645 = doDeintH264 || doDeintHevc;
+ var doDeintH2645 = IsDeinterlaceAvailable(state);
var doVppTonemap = IsIntelVppTonemapAvailable(state, options);
var doOclTonemap = !doVppTonemap && IsHwTonemapAvailable(state, options);
var doTonemap = doVppTonemap || doOclTonemap;
@@ -4757,12 +4756,10 @@ namespace MediaBrowser.Controller.MediaEncoding
var isMjpegEncoder = vidEncoder.Contains("mjpeg", StringComparison.OrdinalIgnoreCase);
var isQsvInQsvOut = isHwDecoder && isQsvEncoder;
- var doDeintH264 = state.DeInterlace("h264", true) || state.DeInterlace("avc", true);
- var doDeintHevc = state.DeInterlace("h265", true) || state.DeInterlace("hevc", true);
var doVaVppTonemap = IsIntelVppTonemapAvailable(state, options);
var doOclTonemap = !doVaVppTonemap && IsHwTonemapAvailable(state, options);
var doTonemap = doVaVppTonemap || doOclTonemap;
- var doDeintH2645 = doDeintH264 || doDeintHevc;
+ var doDeintH2645 = IsDeinterlaceAvailable(state);
var hasSubs = state.SubtitleStream is not null && ShouldEncodeSubtitle(state);
var hasTextSubs = hasSubs && state.SubtitleStream.IsTextSubtitleStream;
@@ -5088,12 +5085,10 @@ namespace MediaBrowser.Controller.MediaEncoding
var isMjpegEncoder = vidEncoder.Contains("mjpeg", StringComparison.OrdinalIgnoreCase);
var isVaInVaOut = isVaapiDecoder && isVaapiEncoder;
- var doDeintH264 = state.DeInterlace("h264", true) || state.DeInterlace("avc", true);
- var doDeintHevc = state.DeInterlace("h265", true) || state.DeInterlace("hevc", true);
var doVaVppTonemap = isVaapiDecoder && IsIntelVppTonemapAvailable(state, options);
var doOclTonemap = !doVaVppTonemap && IsHwTonemapAvailable(state, options);
var doTonemap = doVaVppTonemap || doOclTonemap;
- var doDeintH2645 = doDeintH264 || doDeintHevc;
+ var doDeintH2645 = IsDeinterlaceAvailable(state);
var hasSubs = state.SubtitleStream is not null && ShouldEncodeSubtitle(state);
var hasTextSubs = hasSubs && state.SubtitleStream.IsTextSubtitleStream;
@@ -5325,10 +5320,8 @@ namespace MediaBrowser.Controller.MediaEncoding
var isSwEncoder = !isVaapiEncoder;
var isMjpegEncoder = vidEncoder.Contains("mjpeg", StringComparison.OrdinalIgnoreCase);
- var doDeintH264 = state.DeInterlace("h264", true) || state.DeInterlace("avc", true);
- var doDeintHevc = state.DeInterlace("h265", true) || state.DeInterlace("hevc", true);
var doVkTonemap = IsVulkanHwTonemapAvailable(state, options);
- var doDeintH2645 = doDeintH264 || doDeintHevc;
+ var doDeintH2645 = IsDeinterlaceAvailable(state);
var hasSubs = state.SubtitleStream is not null && ShouldEncodeSubtitle(state);
var hasTextSubs = hasSubs && state.SubtitleStream.IsTextSubtitleStream;
@@ -5565,9 +5558,7 @@ namespace MediaBrowser.Controller.MediaEncoding
var isi965Driver = _mediaEncoder.IsVaapiDeviceInteli965;
var isAmdDriver = _mediaEncoder.IsVaapiDeviceAmd;
- var doDeintH264 = state.DeInterlace("h264", true) || state.DeInterlace("avc", true);
- var doDeintHevc = state.DeInterlace("h265", true) || state.DeInterlace("hevc", true);
- var doDeintH2645 = doDeintH264 || doDeintHevc;
+ var doDeintH2645 = IsDeinterlaceAvailable(state);
var doOclTonemap = IsHwTonemapAvailable(state, options);
var hasSubs = state.SubtitleStream is not null && ShouldEncodeSubtitle(state);
@@ -5798,9 +5789,7 @@ namespace MediaBrowser.Controller.MediaEncoding
var reqMaxH = state.BaseRequest.MaxHeight;
var threeDFormat = state.MediaSource.Video3DFormat;
- var doDeintH264 = state.DeInterlace("h264", true) || state.DeInterlace("avc", true);
- var doDeintHevc = state.DeInterlace("h265", true) || state.DeInterlace("hevc", true);
- var doDeintH2645 = doDeintH264 || doDeintHevc;
+ var doDeintH2645 = IsDeinterlaceAvailable(state);
var doVtTonemap = IsVideoToolboxTonemapAvailable(state, options);
var doMetalTonemap = !doVtTonemap && IsHwTonemapAvailable(state, options);
var usingHwSurface = isVtDecoder && (_mediaEncoder.EncoderVersion >= _minFFmpegWorkingVtHwSurface);
@@ -5999,9 +5988,7 @@ namespace MediaBrowser.Controller.MediaEncoding
&& (vidEncoder.Contains("h264", StringComparison.OrdinalIgnoreCase)
|| vidEncoder.Contains("hevc", StringComparison.OrdinalIgnoreCase));
- var doDeintH264 = state.DeInterlace("h264", true) || state.DeInterlace("avc", true);
- var doDeintHevc = state.DeInterlace("h265", true) || state.DeInterlace("hevc", true);
- var doDeintH2645 = doDeintH264 || doDeintHevc;
+ var doDeintH2645 = IsDeinterlaceAvailable(state);
var doOclTonemap = IsHwTonemapAvailable(state, options);
var hasSubs = state.SubtitleStream is not null && ShouldEncodeSubtitle(state);
@@ -6265,12 +6252,21 @@ namespace MediaBrowser.Controller.MediaEncoding
overlayFilters?.RemoveAll(string.IsNullOrEmpty);
var framerate = GetFramerateParam(state);
- if (framerate.HasValue)
+ if (mainFilters is not null && framerate.HasValue)
{
- mainFilters.Insert(0, string.Format(
- CultureInfo.InvariantCulture,
- "fps={0}",
- framerate.Value));
+ var doDeintH2645 = IsDeinterlaceAvailable(state);
+ var fpsFilter = string.Format(CultureInfo.InvariantCulture, "fps={0}", framerate.Value);
+
+ // For filter chain containing the deinterlace filter,
+ // place the fps filter at the end to preserve temporal info.
+ if (doDeintH2645)
+ {
+ mainFilters.Add(fpsFilter);
+ }
+ else
+ {
+ mainFilters.Insert(0, fpsFilter);
+ }
}
var mainStr = string.Empty;
--
cgit v1.2.3
From 0874a26131a5a1e9d62fb7231acbaf9eb921b5c8 Mon Sep 17 00:00:00 2001
From: Shadowghost
Date: Tue, 9 Jun 2026 23:23:03 +0200
Subject: Coalesce alternate-version progress onto primary in resume filter
---
Emby.Server.Implementations/Dto/DtoService.cs | 19 ++-
.../Library/MediaSourceManager.cs | 72 +++++++++-
.../Library/UserDataManager.cs | 70 ++++++++++
.../Item/BaseItemRepository.TranslateQuery.cs | 18 ++-
.../Library/IUserDataManager.cs | 18 +++
.../Library/VersionResumeData.cs | 30 +++++
MediaBrowser.Model/Dto/MediaSourceInfo.cs | 5 +
.../Library/VersionResumeDataTests.cs | 44 ++++++
.../Item/AlternateVersionQueryTranslationTests.cs | 147 +++++++++++++++++++++
.../Library/MediaSourceManagerTests.cs | 110 +++++++++++++++
10 files changed, 525 insertions(+), 8 deletions(-)
create mode 100644 MediaBrowser.Controller/Library/VersionResumeData.cs
create mode 100644 tests/Jellyfin.Controller.Tests/Library/VersionResumeDataTests.cs
create mode 100644 tests/Jellyfin.Server.Implementations.Tests/Item/AlternateVersionQueryTranslationTests.cs
(limited to 'MediaBrowser.Controller')
diff --git a/Emby.Server.Implementations/Dto/DtoService.cs b/Emby.Server.Implementations/Dto/DtoService.cs
index c9c31ebee4..2bf478953e 100644
--- a/Emby.Server.Implementations/Dto/DtoService.cs
+++ b/Emby.Server.Implementations/Dto/DtoService.cs
@@ -167,9 +167,13 @@ namespace Emby.Server.Implementations.Dto
// Batch-fetch user data for all items
Dictionary? userDataBatch = null;
+ IReadOnlyDictionary? resumeDataBatch = null;
if (user is not null && options.EnableUserData)
{
userDataBatch = _userDataRepository.GetUserDataBatch(accessibleItems, user);
+
+ // For items with alternate versions, the most recently played version drives resume.
+ resumeDataBatch = _userDataRepository.GetResumeUserDataBatch(accessibleItems, user);
}
// Pre-compute collection folders once to avoid N+1 queries in CanDelete
@@ -248,7 +252,8 @@ namespace Emby.Server.Implementations.Dto
allCollectionFolders,
childCountBatch,
playedCountBatch,
- artistsBatch);
+ artistsBatch,
+ resumeDataBatch?.GetValueOrDefault(item.Id));
if (item is LiveTvChannel tvChannel)
{
@@ -309,7 +314,8 @@ namespace Emby.Server.Implementations.Dto
List? allCollectionFolders = null,
Dictionary? childCountBatch = null,
Dictionary? playedCountBatch = null,
- IReadOnlyDictionary? artistsBatch = null)
+ IReadOnlyDictionary? artistsBatch = null,
+ VersionResumeData? resumeData = null)
{
var dto = new BaseItemDto
{
@@ -353,7 +359,8 @@ namespace Emby.Server.Implementations.Dto
options,
userData,
childCountBatch,
- playedCountBatch);
+ playedCountBatch,
+ resumeData);
}
if (item is IHasMediaSources
@@ -538,7 +545,8 @@ namespace Emby.Server.Implementations.Dto
DtoOptions options,
UserItemData? userData = null,
Dictionary? childCountBatch = null,
- Dictionary? playedCountBatch = null)
+ Dictionary? playedCountBatch = null,
+ VersionResumeData? resumeData = null)
{
if (item.IsFolder)
{
@@ -600,6 +608,9 @@ namespace Emby.Server.Implementations.Dto
// Use pre-fetched user data
dto.UserData = GetUserItemDataDto(userData, item.Id);
item.FillUserDataDtoValues(dto.UserData, userData, dto, user, options);
+
+ // For items with alternate versions, the most recently played version drives resume.
+ resumeData?.ApplyTo(dto.UserData);
}
else
{
diff --git a/Emby.Server.Implementations/Library/MediaSourceManager.cs b/Emby.Server.Implementations/Library/MediaSourceManager.cs
index 9ccfefa86e..4a520cad0d 100644
--- a/Emby.Server.Implementations/Library/MediaSourceManager.cs
+++ b/Emby.Server.Implementations/Library/MediaSourceManager.cs
@@ -229,7 +229,11 @@ namespace Emby.Server.Implementations.Library
list.Add(source);
}
- return SortMediaSources(list).ToArray();
+ var preferredId = mediaSources.Count > 0 && Guid.TryParse(mediaSources[0].Id, out var topSourceId)
+ ? topSourceId
+ : item.Id;
+
+ return SortMediaSources(list, preferredId).ToArray();
}
/// >
@@ -400,6 +404,72 @@ namespace Emby.Server.Implementations.Library
source.SupportsDirectStream = user.HasPermission(PermissionKind.EnablePlaybackRemuxing);
}
}
+
+ sources = SetAlternateVersionResumeStates(item, sources, user);
+ }
+
+ return sources;
+ }
+
+ ///
+ /// Populates each source's own playback position for the user and, when the queried item is a
+ /// primary, moves the most recently played version to the front so that resuming without an
+ /// explicit source selection plays the version that was last watched. A directly queried
+ /// alternate version keeps its own source first.
+ ///
+ /// The queried item.
+ /// The item's media sources.
+ /// The user.
+ /// The media sources, reordered when a version drives resume.
+ private IReadOnlyList SetAlternateVersionResumeStates(BaseItem item, IReadOnlyList sources, User user)
+ {
+ // For a video, multiple sources means alternate versions.
+ if (item is not Video video || sources.Count < 2)
+ {
+ return sources;
+ }
+
+ var versions = video.GetAllVersions();
+ if (versions.Count < 2)
+ {
+ return sources;
+ }
+
+ var userDataByVersion = _userDataManager.GetUserDataBatch(versions, user);
+ var dataBySourceId = new Dictionary(versions.Count, StringComparer.OrdinalIgnoreCase);
+ foreach (var version in versions)
+ {
+ if (userDataByVersion.TryGetValue(version.Id, out var data))
+ {
+ dataBySourceId[version.Id.ToString("N", CultureInfo.InvariantCulture)] = data;
+ }
+ }
+
+ MediaSourceInfo resumeSource = null;
+ UserItemData resumeData = null;
+ foreach (var source in sources)
+ {
+ if (source.Id is null
+ || !dataBySourceId.TryGetValue(source.Id, out var data)
+ || data.PlaybackPositionTicks <= 0)
+ {
+ continue;
+ }
+
+ source.PlaybackPositionTicks = data.PlaybackPositionTicks;
+
+ if (resumeData is null || (data.LastPlayedDate ?? DateTime.MinValue) > (resumeData.LastPlayedDate ?? DateTime.MinValue))
+ {
+ resumeSource = source;
+ resumeData = data;
+ }
+ }
+
+ if (resumeSource is not null && !video.PrimaryVersionId.HasValue && !ReferenceEquals(sources[0], resumeSource))
+ {
+ var reordered = new List(sources.Count) { resumeSource };
+ reordered.AddRange(sources.Where(s => !ReferenceEquals(s, resumeSource)));
+ return reordered;
}
return sources;
diff --git a/Emby.Server.Implementations/Library/UserDataManager.cs b/Emby.Server.Implementations/Library/UserDataManager.cs
index 962dd6fda8..4fda853bb1 100644
--- a/Emby.Server.Implementations/Library/UserDataManager.cs
+++ b/Emby.Server.Implementations/Library/UserDataManager.cs
@@ -247,6 +247,72 @@ namespace Emby.Server.Implementations.Library
return result;
}
+ ///
+ public VersionResumeData? GetResumeUserData(User user, BaseItem item)
+ {
+ return GetResumeUserDataBatch([item], user).GetValueOrDefault(item.Id);
+ }
+
+ ///
+ public IReadOnlyDictionary GetResumeUserDataBatch(IReadOnlyList items, User user)
+ {
+ ArgumentNullException.ThrowIfNull(user);
+
+ var result = new Dictionary();
+ List<(Guid PrimaryId, IReadOnlyList Versions)>? versionGroups = null;
+ List? allVersions = null;
+
+ foreach (var item in items)
+ {
+ // Only primary items aggregate over their versions; a directly queried version keeps its own data.
+ if (item is not Video video
+ || video.PrimaryVersionId.HasValue
+ || (video.LinkedAlternateVersions.Length == 0 && !video.HasLocalAlternateVersions))
+ {
+ continue;
+ }
+
+ var versions = video.GetAllVersions();
+ if (versions.Count < 2)
+ {
+ continue;
+ }
+
+ (versionGroups ??= []).Add((item.Id, versions));
+ (allVersions ??= []).AddRange(versions);
+ }
+
+ if (versionGroups is null)
+ {
+ return result;
+ }
+
+ var userDataByVersion = GetUserDataBatch(allVersions!.DistinctBy(i => i.Id).ToList(), user);
+
+ foreach (var (primaryId, versions) in versionGroups)
+ {
+ Video? resumeVersion = null;
+ UserItemData? resumeData = null;
+ foreach (var version in versions)
+ {
+ if (userDataByVersion.TryGetValue(version.Id, out var data)
+ && data.PlaybackPositionTicks > 0
+ && (resumeData is null || (data.LastPlayedDate ?? DateTime.MinValue) > (resumeData.LastPlayedDate ?? DateTime.MinValue)))
+ {
+ resumeVersion = version;
+ resumeData = data;
+ }
+ }
+
+ if (resumeData is not null)
+ {
+ result[primaryId] = new VersionResumeData(resumeData, resumeVersion!.RunTimeTicks);
+ }
+ }
+
+ return result;
+ }
+
///
/// Gets the internal key.
///
@@ -281,6 +347,10 @@ namespace Emby.Server.Implementations.Library
var dto = GetUserItemDataDto(userData, item.Id);
item.FillUserDataDtoValues(dto, userData, itemDto, user, options);
+
+ // For an item with alternate versions, surface the most recently played version's resume point.
+ GetResumeUserData(user, item)?.ApplyTo(dto);
+
return dto;
}
diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs
index 22f7cc3550..a9856f2e76 100644
--- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs
+++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs
@@ -519,7 +519,6 @@ public sealed partial class BaseItemRepository
// In-progress user data rows; alternate versions track their own progress.
var inProgress = context.UserData
.Where(ud => ud.UserId == userId && ud.PlaybackPositionTicks > 0);
- var resumableItemIds = inProgress.Select(ud => ud.ItemId);
if (hasSeries)
{
@@ -544,13 +543,26 @@ public sealed partial class BaseItemRepository
.Where(s => s.HasInProgress || (s.HasPlayed && s.HasUnplayed))
.Select(s => s.SeriesId);
+ // Non-series items: resumable if the item or any of its alternate versions has
+ // PlaybackPositionTicks > 0. Alternate versions (PrimaryVersionId set) are excluded
+ // from the base query, so coalesce their progress onto the primary's id.
+ var resumableMovieIds = context.BaseItems
+ .Where(bi => bi.UserData!.Any(ud => ud.UserId == userId && ud.PlaybackPositionTicks > 0))
+ .Select(bi => bi.PrimaryVersionId ?? bi.Id);
+
baseQuery = baseQuery.Where(e =>
(e.Type == seriesTypeName && resumableSeriesIds.Contains(e.Id) == isResumable)
- || (e.Type != seriesTypeName && resumableItemIds.Contains(e.Id) == isResumable));
+ || (e.Type != seriesTypeName && resumableMovieIds.Contains(e.Id) == isResumable));
}
else
{
- baseQuery = baseQuery.Where(e => resumableItemIds.Contains(e.Id) == isResumable);
+ // Resumable if the item or any of its alternate versions has PlaybackPositionTicks > 0.
+ // Alternate versions (PrimaryVersionId set) are excluded from the base query, so
+ // coalesce their progress onto the primary's id.
+ var resumableMovieIds = context.BaseItems
+ .Where(bi => bi.UserData!.Any(ud => ud.UserId == userId && ud.PlaybackPositionTicks > 0))
+ .Select(bi => bi.PrimaryVersionId ?? bi.Id);
+ baseQuery = baseQuery.Where(e => resumableMovieIds.Contains(e.Id) == isResumable);
}
if (isResumable)
diff --git a/MediaBrowser.Controller/Library/IUserDataManager.cs b/MediaBrowser.Controller/Library/IUserDataManager.cs
index 0b4229c784..2ee8845346 100644
--- a/MediaBrowser.Controller/Library/IUserDataManager.cs
+++ b/MediaBrowser.Controller/Library/IUserDataManager.cs
@@ -62,6 +62,24 @@ namespace MediaBrowser.Controller.Library
/// A dictionary mapping item IDs to their user data.
Dictionary GetUserDataBatch(IReadOnlyList items, User user);
+ ///
+ /// Gets the user data that should drive resume for a multi-version item: the data of the most
+ /// recently played alternate version (including the item itself) that has a resume point.
+ ///
+ /// The user.
+ /// The item.
+ /// The resume version's data, or null when the item has no versions or none has a resume point.
+ VersionResumeData? GetResumeUserData(User user, BaseItem item);
+
+ ///
+ /// Gets the resume-driving user data for multiple items in a single batch operation.
+ /// See .
+ ///
+ /// The items to get resume data for.
+ /// The user.
+ /// A dictionary mapping item ids to their resume version's data; items without one are omitted.
+ IReadOnlyDictionary GetResumeUserDataBatch(IReadOnlyList items, User user);
+
///
/// Gets the user data dto.
///
diff --git a/MediaBrowser.Controller/Library/VersionResumeData.cs b/MediaBrowser.Controller/Library/VersionResumeData.cs
new file mode 100644
index 0000000000..ca8a499d97
--- /dev/null
+++ b/MediaBrowser.Controller/Library/VersionResumeData.cs
@@ -0,0 +1,30 @@
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Model.Dto;
+
+namespace MediaBrowser.Controller.Library
+{
+ ///
+ /// The user data of the alternate version that should drive resume for a multi-version item.
+ ///
+ /// The resume version's user data.
+ /// The resume version's runtime, used for the progress percentage.
+ public record VersionResumeData(UserItemData UserData, long? RunTimeTicks)
+ {
+ ///
+ /// Applies the resume version's playback state to the supplied user data dto, so that an item
+ /// whose most recent progress lives on an alternate version still reports that progress.
+ ///
+ /// The user data dto to update.
+ public void ApplyTo(UserItemDataDto dto)
+ {
+ dto.PlaybackPositionTicks = UserData.PlaybackPositionTicks;
+ dto.Played = UserData.Played;
+ dto.LastPlayedDate = UserData.LastPlayedDate;
+
+ if (RunTimeTicks > 0 && UserData.PlaybackPositionTicks > 0)
+ {
+ dto.PlayedPercentage = 100.0 * UserData.PlaybackPositionTicks / RunTimeTicks.Value;
+ }
+ }
+ }
+}
diff --git a/MediaBrowser.Model/Dto/MediaSourceInfo.cs b/MediaBrowser.Model/Dto/MediaSourceInfo.cs
index 75ccdcf276..017e26ef59 100644
--- a/MediaBrowser.Model/Dto/MediaSourceInfo.cs
+++ b/MediaBrowser.Model/Dto/MediaSourceInfo.cs
@@ -55,6 +55,11 @@ namespace MediaBrowser.Model.Dto
public long? RunTimeTicks { get; set; }
+ ///
+ /// Gets or sets the playback position for this specific source.
+ ///
+ public long? PlaybackPositionTicks { get; set; }
+
public bool ReadAtNativeFramerate { get; set; }
public bool IgnoreDts { get; set; }
diff --git a/tests/Jellyfin.Controller.Tests/Library/VersionResumeDataTests.cs b/tests/Jellyfin.Controller.Tests/Library/VersionResumeDataTests.cs
new file mode 100644
index 0000000000..5b0f003019
--- /dev/null
+++ b/tests/Jellyfin.Controller.Tests/Library/VersionResumeDataTests.cs
@@ -0,0 +1,44 @@
+using System;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Model.Dto;
+using Xunit;
+
+namespace Jellyfin.Controller.Tests.Library;
+
+public class VersionResumeDataTests
+{
+ [Fact]
+ public void ApplyTo_OverridesResumeFieldsAndPercentage()
+ {
+ var lastPlayed = new DateTime(2026, 1, 2, 0, 0, 0, DateTimeKind.Utc);
+ var resume = new VersionResumeData(
+ new UserItemData { Key = "version", PlaybackPositionTicks = 25, Played = true, LastPlayedDate = lastPlayed },
+ RunTimeTicks: 100);
+
+ var dto = new UserItemDataDto { Key = "primary", PlaybackPositionTicks = 1, Played = false, PlayedPercentage = 1 };
+
+ resume.ApplyTo(dto);
+
+ Assert.Equal(25, dto.PlaybackPositionTicks);
+ Assert.True(dto.Played);
+ Assert.Equal(lastPlayed, dto.LastPlayedDate);
+
+ // The percentage is based on the resume version's own runtime, not the primary's.
+ Assert.NotNull(dto.PlayedPercentage);
+ Assert.Equal(25.0, dto.PlayedPercentage.Value, 5);
+ }
+
+ [Fact]
+ public void ApplyTo_WithoutRuntime_LeavesPercentageUntouched()
+ {
+ var resume = new VersionResumeData(new UserItemData { Key = "version", PlaybackPositionTicks = 25 }, null);
+ var dto = new UserItemDataDto { Key = "primary", PlayedPercentage = 42 };
+
+ resume.ApplyTo(dto);
+
+ Assert.Equal(25, dto.PlaybackPositionTicks);
+ Assert.NotNull(dto.PlayedPercentage);
+ Assert.Equal(42.0, dto.PlayedPercentage.Value, 5);
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/AlternateVersionQueryTranslationTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/AlternateVersionQueryTranslationTests.cs
new file mode 100644
index 0000000000..e6e591a20e
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Item/AlternateVersionQueryTranslationTests.cs
@@ -0,0 +1,147 @@
+#pragma warning disable RS0030 // Do not use banned APIs: Guid == is required inside EF expression trees to mirror the production query shapes.
+
+using System;
+using System.Linq;
+using Jellyfin.Database.Implementations;
+using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Database.Implementations.Locking;
+using Jellyfin.Database.Providers.Sqlite;
+using Microsoft.Data.Sqlite;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging.Abstractions;
+using Xunit;
+
+namespace Jellyfin.Server.Implementations.Tests.Item;
+
+///
+/// Verifies that the alternate-version-aware query shapes used by the resume filter
+/// (BaseItemRepository.TranslateQuery) and the DatePlayed ordering (OrderMapper) translate
+/// and evaluate correctly on the SQLite provider.
+///
+public sealed class AlternateVersionQueryTranslationTests : IDisposable
+{
+ private readonly SqliteConnection _connection;
+ private readonly DbContextOptions _dbOptions;
+
+ public AlternateVersionQueryTranslationTests()
+ {
+ _connection = new SqliteConnection("Data Source=:memory:");
+ _connection.Open();
+
+ _dbOptions = new DbContextOptionsBuilder()
+ .UseSqlite(_connection)
+ .Options;
+
+ using var ctx = CreateDbContext();
+ ctx.Database.EnsureCreated();
+ }
+
+ [Fact]
+ public void ResumeFilter_VersionProgress_SurfacesPrimary()
+ {
+ Guid userId, primaryId, otherId;
+
+ using (var ctx = CreateDbContext())
+ {
+ (userId, primaryId, otherId) = Seed(ctx);
+ }
+
+ using (var ctx = CreateDbContext())
+ {
+ // Mirrors the resumable filter in BaseItemRepository.TranslateQuery: progress on any
+ // version coalesces onto the primary's id.
+ var resumableMovieIds = ctx.BaseItems
+ .Where(bi => bi.UserData!.Any(ud => ud.UserId == userId && ud.PlaybackPositionTicks > 0))
+ .Select(bi => bi.PrimaryVersionId ?? bi.Id);
+
+ // Scope to the seeded items; EnsureCreated also seeds a placeholder row.
+ var seededIds = new[] { primaryId, otherId };
+
+ var resumable = ctx.BaseItems
+ .Where(e => seededIds.Contains(e.Id) && e.PrimaryVersionId == null)
+ .Where(e => resumableMovieIds.Contains(e.Id))
+ .Select(e => e.Id)
+ .ToList();
+
+ Assert.Equal([primaryId], resumable);
+
+ // The inverse (not-resumable) direction must exclude the primary as well.
+ var notResumable = ctx.BaseItems
+ .Where(e => seededIds.Contains(e.Id) && e.PrimaryVersionId == null)
+ .Where(e => resumableMovieIds.Contains(e.Id) == false)
+ .Select(e => e.Id)
+ .ToList();
+
+ Assert.Equal([otherId], notResumable);
+ }
+ }
+
+ [Fact]
+ public void DatePlayedOrdering_VersionProgress_SortsPrimaryByVersionDate()
+ {
+ Guid userId, primaryId, otherId;
+
+ using (var ctx = CreateDbContext())
+ {
+ (userId, primaryId, otherId) = Seed(ctx);
+ }
+
+ using (var ctx = CreateDbContext())
+ {
+ // Scope to the seeded items; EnsureCreated also seeds a placeholder row.
+ var seededIds = new[] { primaryId, otherId };
+
+ // Mirrors the DatePlayed mapping in OrderMapper.
+ var ordered = ctx.BaseItems
+ .Where(e => seededIds.Contains(e.Id) && e.PrimaryVersionId == null)
+ .OrderByDescending(e => ctx.UserData
+ .Where(w => w.UserId == userId && (w.ItemId == e.Id || w.Item!.PrimaryVersionId == e.Id))
+ .Max(f => f.LastPlayedDate))
+ .Select(e => e.Id)
+ .ToList();
+
+ // The movie whose only progress is on its alternate version sorts before the unplayed one.
+ Assert.Equal([primaryId, otherId], ordered);
+ }
+ }
+
+ private static (Guid UserId, Guid PrimaryId, Guid OtherId) Seed(JellyfinDbContext ctx)
+ {
+ var user = new User("test", "auth-provider", "reset-provider");
+ ctx.Users.Add(user);
+
+ var primary = new BaseItemEntity { Id = Guid.NewGuid(), Type = "MediaBrowser.Controller.Entities.Movies.Movie" };
+ var version = new BaseItemEntity { Id = Guid.NewGuid(), Type = "MediaBrowser.Controller.Entities.Movies.Movie", PrimaryVersionId = primary.Id };
+ var other = new BaseItemEntity { Id = Guid.NewGuid(), Type = "MediaBrowser.Controller.Entities.Movies.Movie" };
+ ctx.BaseItems.AddRange(primary, version, other);
+
+ // Progress only on the alternate version.
+ ctx.UserData.Add(new UserData
+ {
+ ItemId = version.Id,
+ Item = version,
+ UserId = user.Id,
+ User = user,
+ CustomDataKey = version.Id.ToString("N"),
+ PlaybackPositionTicks = 1000,
+ LastPlayedDate = new DateTime(2026, 1, 2, 0, 0, 0, DateTimeKind.Utc)
+ });
+
+ ctx.SaveChanges();
+ return (user.Id, primary.Id, other.Id);
+ }
+
+ private JellyfinDbContext CreateDbContext()
+ {
+ return new JellyfinDbContext(
+ _dbOptions,
+ NullLogger.Instance,
+ new SqliteDatabaseProvider(null!, NullLogger.Instance),
+ new NoLockBehavior(NullLogger.Instance));
+ }
+
+ public void Dispose()
+ {
+ _connection.Dispose();
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/MediaSourceManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/MediaSourceManagerTests.cs
index facdb2bc2e..b788fb304e 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Library/MediaSourceManagerTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/Library/MediaSourceManagerTests.cs
@@ -1,4 +1,6 @@
using System;
+using System.Collections.Generic;
+using System.Linq;
using AutoFixture;
using AutoFixture.AutoMoq;
using Castle.Components.DictionaryAdapter;
@@ -7,6 +9,8 @@ using Emby.Server.Implementations.Library;
using Jellyfin.Database.Implementations.Entities;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.LiveTv;
+using MediaBrowser.Controller.MediaSegments;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Globalization;
@@ -144,5 +148,111 @@ namespace Jellyfin.Server.Implementations.Tests.Library
_mediaSourceManager.SetDefaultAudioAndSubtitleStreamIndices(_item, mediaInfo, _user);
Assert.Equal(expectedIndex, mediaInfo.DefaultAudioStreamIndex);
}
+
+ [Fact]
+ public void GetStaticMediaSources_PrimaryQueried_PopulatesPerVersionPositionsAndDefaultsToMostRecent()
+ {
+ var (primary, alt1, alt2) = SetupVersionGroup();
+ SetupUserDataBatch(new Dictionary
+ {
+ [alt1.Id] = new UserItemData { Key = "alt1", PlaybackPositionTicks = 10, LastPlayedDate = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc) },
+ [alt2.Id] = new UserItemData { Key = "alt2", PlaybackPositionTicks = 20, LastPlayedDate = new DateTime(2026, 1, 2, 0, 0, 0, DateTimeKind.Utc) }
+ });
+
+ var sources = _mediaSourceManager.GetStaticMediaSources(primary, false, _user);
+
+ // Each version carries its own resume point; the primary has none.
+ Assert.Equal((long?)10, sources.First(s => s.Id == alt1.Id.ToString("N")).PlaybackPositionTicks);
+ Assert.Equal((long?)20, sources.First(s => s.Id == alt2.Id.ToString("N")).PlaybackPositionTicks);
+ Assert.Null(sources.First(s => s.Id == primary.Id.ToString("N")).PlaybackPositionTicks);
+
+ // The most recently played version is the default source, so resuming plays the right file.
+ Assert.Equal(alt2.Id.ToString("N"), sources[0].Id);
+ }
+
+ [Fact]
+ public void GetStaticMediaSources_AlternateQueried_KeepsOwnSourceFirst()
+ {
+ var (primary, alt1, alt2) = SetupVersionGroup();
+ SetupUserDataBatch(new Dictionary
+ {
+ [alt2.Id] = new UserItemData { Key = "alt2", PlaybackPositionTicks = 20, LastPlayedDate = new DateTime(2026, 1, 2, 0, 0, 0, DateTimeKind.Utc) }
+ });
+
+ var sources = _mediaSourceManager.GetStaticMediaSources(alt1, false, _user);
+
+ // An explicitly opened version keeps its own source first, even when a sibling was
+ // played more recently, but the sibling's resume point is still populated.
+ Assert.Equal(alt1.Id.ToString("N"), sources[0].Id);
+ Assert.Equal((long?)20, sources.First(s => s.Id == alt2.Id.ToString("N")).PlaybackPositionTicks);
+ Assert.Equal(3, sources.Count);
+ }
+
+ [Fact]
+ public void GetStaticMediaSources_NoProgress_KeepsQueriedItemFirst()
+ {
+ var (primary, _, _) = SetupVersionGroup();
+ SetupUserDataBatch([]);
+
+ var sources = _mediaSourceManager.GetStaticMediaSources(primary, false, _user);
+
+ Assert.Equal(primary.Id.ToString("N"), sources[0].Id);
+ Assert.All(sources, s => Assert.Null(s.PlaybackPositionTicks));
+ }
+
+ [Fact]
+ public void GetStaticMediaSources_NoUser_DoesNotTouchUserData()
+ {
+ var (primary, _, _) = SetupVersionGroup();
+
+ var sources = _mediaSourceManager.GetStaticMediaSources(primary, false);
+
+ Assert.Equal(primary.Id.ToString("N"), sources[0].Id);
+ _mockUserDataManager.Verify(x => x.GetUserDataBatch(It.IsAny>(), It.IsAny()), Times.Never);
+ }
+
+ private void SetupUserDataBatch(Dictionary userData)
+ {
+ _mockUserDataManager
+ .Setup(x => x.GetUserDataBatch(It.IsAny>(), It.IsAny()))
+ .Returns((IReadOnlyList items, User _) => items
+ .Where(i => userData.ContainsKey(i.Id))
+ .ToDictionary(i => i.Id, i => userData[i.Id]));
+ }
+
+ private static (Video Primary, Video Alt1, Video Alt2) SetupVersionGroup()
+ {
+ var primary = new Video { Id = Guid.NewGuid(), Path = "/Movies/Movie/Movie.mkv" };
+ var alt1 = new Video { Id = Guid.NewGuid(), Path = "/Movies/Movie/Movie - 1080p.mkv", PrimaryVersionId = primary.Id };
+ var alt2 = new Video { Id = Guid.NewGuid(), Path = "/Movies/Movie/Movie - 4K.mkv", PrimaryVersionId = primary.Id };
+
+ // BaseItem.GetMediaSources runs against the static service locators.
+ var mediaSourceManager = new Mock();
+ mediaSourceManager.Setup(x => x.GetPathProtocol(It.IsAny())).Returns(MediaProtocol.File);
+ mediaSourceManager.Setup(x => x.GetMediaStreams(It.IsAny())).Returns(new List());
+ mediaSourceManager.Setup(x => x.GetMediaAttachments(It.IsAny())).Returns(new List());
+
+ var segmentManager = new Mock();
+ segmentManager.Setup(x => x.IsTypeSupported(It.IsAny())).Returns(false);
+
+ var libraryManager = new Mock();
+ libraryManager.Setup(x => x.GetLinkedAlternateVersions(It.IsAny())).Returns(Array.Empty());
+ libraryManager.Setup(x => x.GetLocalAlternateVersionIds(primary)).Returns(new[] { alt1.Id, alt2.Id });
+ libraryManager.Setup(x => x.GetLocalAlternateVersionIds(alt1)).Returns(Array.Empty());
+ libraryManager.Setup(x => x.GetLocalAlternateVersionIds(alt2)).Returns(Array.Empty());
+ libraryManager.Setup(x => x.GetItemById(primary.Id)).Returns(primary);
+ libraryManager.Setup(x => x.GetItemById(alt1.Id)).Returns(alt1);
+ libraryManager.Setup(x => x.GetItemById(alt2.Id)).Returns(alt2);
+
+ var recordingsManager = new Mock();
+ recordingsManager.Setup(x => x.GetActiveRecordingInfo(It.IsAny())).Returns((ActiveRecordingInfo?)null);
+
+ BaseItem.MediaSegmentManager = segmentManager.Object;
+ BaseItem.MediaSourceManager = mediaSourceManager.Object;
+ BaseItem.LibraryManager = libraryManager.Object;
+ Video.RecordingsManager = recordingsManager.Object;
+
+ return (primary, alt1, alt2);
+ }
}
}
--
cgit v1.2.3
From a9dc8f6f742e71720aad2c402a08087f7a7d5368 Mon Sep 17 00:00:00 2001
From: Shadowghost
Date: Sat, 13 Jun 2026 16:37:19 +0200
Subject: Always apply recursive when filters are requested
---
Jellyfin.Api/Controllers/ItemsController.cs | 439 ++++++++++-----------
.../Entities/InternalItemsQuery.cs | 96 +++++
MediaBrowser.Controller/Entities/UserRootFolder.cs | 8 +-
3 files changed, 322 insertions(+), 221 deletions(-)
(limited to 'MediaBrowser.Controller')
diff --git a/Jellyfin.Api/Controllers/ItemsController.cs b/Jellyfin.Api/Controllers/ItemsController.cs
index 5f23f2fcee..c52a6cd7dc 100644
--- a/Jellyfin.Api/Controllers/ItemsController.cs
+++ b/Jellyfin.Api/Controllers/ItemsController.cs
@@ -321,24 +321,21 @@ public class ItemsController : BaseJellyfinApiController
recursive = true;
includeItemTypes = [BaseItemKind.Playlist];
}
- else if (folder is ICollectionFolder)
+ else if (folder is ICollectionFolder && includeItemTypes.Length == 0)
{
- if (includeItemTypes.Length == 0)
+ includeItemTypes = collectionType switch
{
- includeItemTypes = collectionType switch
- {
- CollectionType.boxsets => [BaseItemKind.BoxSet],
- null => [BaseItemKind.Movie, BaseItemKind.Series],
- _ => []
- };
- }
+ CollectionType.boxsets => [BaseItemKind.BoxSet],
+ null => [BaseItemKind.Movie, BaseItemKind.Series],
+ _ => []
+ };
+ }
- // When the client doesn't specify recursive/includeItemTypes, force the query
- // through the database path where all filters (IsHD, genres, etc.) are applied.
- if (includeItemTypes.Length > 0)
- {
- recursive ??= true;
- }
+ // includeItemTypes on a library lists its contents recursively rather than just its
+ // immediate children, so default to a recursive query when the client didn't choose.
+ if (folder is ICollectionFolder && includeItemTypes.Length > 0)
+ {
+ recursive ??= true;
}
if (item is not UserRootFolder
@@ -351,246 +348,248 @@ public class ItemsController : BaseJellyfinApiController
return Unauthorized($"{user.Username} is not permitted to access Library {item.Name}.");
}
- if ((recursive.HasValue && recursive.Value) || ids.Length != 0 || item is not UserRootFolder)
- {
- // Use search providers when searchTerm is provided. Providers return only IDs and scores;
- // items are loaded server-side via folder.GetItems below, which applies user-access filtering.
- Dictionary? searchResultScores = null;
- Guid[] itemIds = ids;
-
- if (!string.IsNullOrWhiteSpace(searchTerm))
- {
- var searchProviderQuery = new SearchProviderQuery
- {
- SearchTerm = searchTerm,
- UserId = userId,
- IncludeItemTypes = includeItemTypes,
- ExcludeItemTypes = excludeItemTypes,
- MediaTypes = mediaTypes,
- Limit = limit.HasValue ? limit.Value * 3 : null,
- ParentId = parentId
- };
+ // Build the query up front so the dispatch below can decide the path from it.
+ // Use search providers when searchTerm is provided. Providers return only IDs and scores;
+ // items are loaded server-side via folder.GetItems below, which applies user-access filtering.
+ Dictionary? searchResultScores = null;
+ Guid[] itemIds = ids;
- var searchResults = await _searchManager.GetSearchResultsAsync(searchProviderQuery, HttpContext.RequestAborted).ConfigureAwait(false);
- if (searchResults.Count > 0)
- {
- searchResultScores = searchResults.ToDictionary(r => r.ItemId, r => r.Score);
- itemIds = ids.Length > 0
- ? ids.Concat(searchResultScores.Keys).Distinct().ToArray()
- : searchResultScores.Keys.ToArray();
- }
- }
-
- var query = new InternalItemsQuery(user)
+ if (!string.IsNullOrWhiteSpace(searchTerm))
+ {
+ var searchProviderQuery = new SearchProviderQuery
{
- IsPlayed = isPlayed,
- MediaTypes = mediaTypes,
+ SearchTerm = searchTerm,
+ UserId = userId,
IncludeItemTypes = includeItemTypes,
ExcludeItemTypes = excludeItemTypes,
- Recursive = recursive ?? false,
- OrderBy = RequestHelpers.GetOrderBy(sortBy, sortOrder),
- IsFavorite = isFavorite,
- Limit = searchResultScores is null ? limit : null,
- StartIndex = searchResultScores is null ? startIndex : null,
- IsMissing = isMissing,
- IsUnaired = isUnaired,
- CollapseBoxSetItems = collapseBoxSetItems,
- NameLessThan = nameLessThan,
- NameStartsWith = nameStartsWith,
- NameStartsWithOrGreater = nameStartsWithOrGreater,
- HasImdbId = hasImdbId,
- IsPlaceHolder = isPlaceHolder,
- IsLocked = isLocked,
- MinWidth = minWidth,
- MinHeight = minHeight,
- MaxWidth = maxWidth,
- MaxHeight = maxHeight,
- Is3D = is3D,
- HasTvdbId = hasTvdbId,
- HasTmdbId = hasTmdbId,
- IsMovie = isMovie,
- IsSeries = isSeries,
- IsNews = isNews,
- IsKids = isKids,
- IsSports = isSports,
- HasOverview = hasOverview,
- HasOfficialRating = hasOfficialRating,
- HasParentalRating = hasParentalRating,
- HasSpecialFeature = hasSpecialFeature,
- HasSubtitles = hasSubtitles,
- HasThemeSong = hasThemeSong,
- HasThemeVideo = hasThemeVideo,
- HasTrailer = hasTrailer,
- IsHD = isHd,
- Is4K = is4K,
- Tags = tags,
- OfficialRatings = officialRatings,
- Genres = genres,
- ArtistIds = artistIds,
- AlbumArtistIds = albumArtistIds,
- ContributingArtistIds = contributingArtistIds,
- GenreIds = genreIds,
- StudioIds = studioIds,
- Person = person,
- PersonIds = personIds,
- PersonTypes = personTypes,
- Years = years,
- ImageTypes = imageTypes,
- VideoTypes = videoTypes,
- AdjacentTo = adjacentTo,
- ItemIds = itemIds,
- MinCommunityRating = minCommunityRating,
- MinCriticRating = minCriticRating,
- ParentId = parentId ?? Guid.Empty,
- IndexNumber = indexNumber,
- ParentIndexNumber = parentIndexNumber,
- EnableTotalRecordCount = enableTotalRecordCount,
- ExcludeItemIds = excludeItemIds,
- DtoOptions = dtoOptions,
- SearchTerm = searchResultScores is null ? searchTerm : null,
- MinDateLastSaved = minDateLastSaved?.ToUniversalTime(),
- MinDateLastSavedForUser = minDateLastSavedForUser?.ToUniversalTime(),
- MinPremiereDate = minPremiereDate?.ToUniversalTime(),
- MaxPremiereDate = maxPremiereDate?.ToUniversalTime(),
- AudioLanguages = audioLanguages,
- SubtitleLanguages = subtitleLanguages,
- LinkedChildAncestorIds = linkedChildAncestorIds,
+ MediaTypes = mediaTypes,
+ Limit = limit.HasValue ? limit.Value * 3 : null,
+ ParentId = parentId
};
- if (ids.Length != 0 || !string.IsNullOrWhiteSpace(searchTerm))
+ var searchResults = await _searchManager.GetSearchResultsAsync(searchProviderQuery, HttpContext.RequestAborted).ConfigureAwait(false);
+ if (searchResults.Count > 0)
{
- query.CollapseBoxSetItems = false;
+ searchResultScores = searchResults.ToDictionary(r => r.ItemId, r => r.Score);
+ itemIds = ids.Length > 0
+ ? ids.Concat(searchResultScores.Keys).Distinct().ToArray()
+ : searchResultScores.Keys.ToArray();
}
+ }
+
+ var query = new InternalItemsQuery(user)
+ {
+ IsPlayed = isPlayed,
+ MediaTypes = mediaTypes,
+ IncludeItemTypes = includeItemTypes,
+ ExcludeItemTypes = excludeItemTypes,
+ Recursive = recursive ?? false,
+ OrderBy = RequestHelpers.GetOrderBy(sortBy, sortOrder),
+ IsFavorite = isFavorite,
+ Limit = searchResultScores is null ? limit : null,
+ StartIndex = searchResultScores is null ? startIndex : null,
+ IsMissing = isMissing,
+ IsUnaired = isUnaired,
+ CollapseBoxSetItems = collapseBoxSetItems,
+ NameLessThan = nameLessThan,
+ NameStartsWith = nameStartsWith,
+ NameStartsWithOrGreater = nameStartsWithOrGreater,
+ HasImdbId = hasImdbId,
+ IsPlaceHolder = isPlaceHolder,
+ IsLocked = isLocked,
+ MinWidth = minWidth,
+ MinHeight = minHeight,
+ MaxWidth = maxWidth,
+ MaxHeight = maxHeight,
+ Is3D = is3D,
+ HasTvdbId = hasTvdbId,
+ HasTmdbId = hasTmdbId,
+ IsMovie = isMovie,
+ IsSeries = isSeries,
+ IsNews = isNews,
+ IsKids = isKids,
+ IsSports = isSports,
+ HasOverview = hasOverview,
+ HasOfficialRating = hasOfficialRating,
+ HasParentalRating = hasParentalRating,
+ HasSpecialFeature = hasSpecialFeature,
+ HasSubtitles = hasSubtitles,
+ HasThemeSong = hasThemeSong,
+ HasThemeVideo = hasThemeVideo,
+ HasTrailer = hasTrailer,
+ IsHD = isHd,
+ Is4K = is4K,
+ Tags = tags,
+ OfficialRatings = officialRatings,
+ Genres = genres,
+ ArtistIds = artistIds,
+ AlbumArtistIds = albumArtistIds,
+ ContributingArtistIds = contributingArtistIds,
+ GenreIds = genreIds,
+ StudioIds = studioIds,
+ Person = person,
+ PersonIds = personIds,
+ PersonTypes = personTypes,
+ Years = years,
+ ImageTypes = imageTypes,
+ VideoTypes = videoTypes,
+ AdjacentTo = adjacentTo,
+ ItemIds = itemIds,
+ MinCommunityRating = minCommunityRating,
+ MinCriticRating = minCriticRating,
+ ParentId = parentId ?? Guid.Empty,
+ IndexNumber = indexNumber,
+ ParentIndexNumber = parentIndexNumber,
+ EnableTotalRecordCount = enableTotalRecordCount,
+ ExcludeItemIds = excludeItemIds,
+ DtoOptions = dtoOptions,
+ SearchTerm = searchResultScores is null ? searchTerm : null,
+ MinDateLastSaved = minDateLastSaved?.ToUniversalTime(),
+ MinDateLastSavedForUser = minDateLastSavedForUser?.ToUniversalTime(),
+ MinPremiereDate = minPremiereDate?.ToUniversalTime(),
+ MaxPremiereDate = maxPremiereDate?.ToUniversalTime(),
+ AudioLanguages = audioLanguages,
+ SubtitleLanguages = subtitleLanguages,
+ LinkedChildAncestorIds = linkedChildAncestorIds,
+ };
+
+ if (ids.Length != 0 || !string.IsNullOrWhiteSpace(searchTerm))
+ {
+ query.CollapseBoxSetItems = false;
+ }
- if (query.SubtitleLanguages.Count > 0 && query.HasSubtitles.HasValue)
+ if (query.SubtitleLanguages.Count > 0 && query.HasSubtitles.HasValue)
+ {
+ if (query.HasSubtitles.Value)
{
- if (query.HasSubtitles.Value)
- {
- // if we check for specific subtitles we don't need a separate check for subtitle existence
- query.HasSubtitles = null;
- }
- else
- {
- // if we search for items without subtitles, we don't need to check for subtitles of a specific language
- query.SubtitleLanguages = [];
- }
+ // if we check for specific subtitles we don't need a separate check for subtitle existence
+ query.HasSubtitles = null;
}
-
- // for filter values that rely on media streams, we need to include alternative and linked versions
- if (query.HasSubtitles.HasValue
- || query.SubtitleLanguages.Count > 0
- || query.AudioLanguages.Count > 0
- || query.Is3D.HasValue
- || query.IsHD.HasValue
- || query.Is4K.HasValue
- || query.VideoTypes.Length > 0
- )
+ else
{
- query.IncludeOwnedItems = true;
+ // if we search for items without subtitles, we don't need to check for subtitles of a specific language
+ query.SubtitleLanguages = [];
}
+ }
- query.ApplyFilters(filters);
+ // for filter values that rely on media streams, we need to include alternative and linked versions
+ if (query.HasSubtitles.HasValue
+ || query.SubtitleLanguages.Count > 0
+ || query.AudioLanguages.Count > 0
+ || query.Is3D.HasValue
+ || query.IsHD.HasValue
+ || query.Is4K.HasValue
+ || query.VideoTypes.Length > 0
+ )
+ {
+ query.IncludeOwnedItems = true;
+ }
- // Filter by Series Status
- if (seriesStatus.Length != 0)
- {
- query.SeriesStatuses = seriesStatus;
- }
+ query.ApplyFilters(filters);
- // Exclude Blocked Unrated Items
- var blockedUnratedItems = user?.GetPreferenceValues(PreferenceKind.BlockUnratedItems);
- if (blockedUnratedItems is not null)
- {
- query.BlockUnratedItems = blockedUnratedItems;
- }
+ // Filter by Series Status
+ if (seriesStatus.Length != 0)
+ {
+ query.SeriesStatuses = seriesStatus;
+ }
- // ExcludeLocationTypes
- if (excludeLocationTypes.Any(t => t == LocationType.Virtual))
- {
- query.IsVirtualItem = false;
- }
+ // Exclude Blocked Unrated Items
+ var blockedUnratedItems = user?.GetPreferenceValues(PreferenceKind.BlockUnratedItems);
+ if (blockedUnratedItems is not null)
+ {
+ query.BlockUnratedItems = blockedUnratedItems;
+ }
- if (locationTypes.Length > 0 && locationTypes.Length < 4)
- {
- query.IsVirtualItem = locationTypes.Contains(LocationType.Virtual);
- }
+ // ExcludeLocationTypes
+ if (excludeLocationTypes.Any(t => t == LocationType.Virtual))
+ {
+ query.IsVirtualItem = false;
+ }
- // Min official rating
- if (!string.IsNullOrWhiteSpace(minOfficialRating))
- {
- query.MinParentalRating = _localization.GetRatingScore(minOfficialRating);
- }
+ if (locationTypes.Length > 0 && locationTypes.Length < 4)
+ {
+ query.IsVirtualItem = locationTypes.Contains(LocationType.Virtual);
+ }
- // Max official rating
- if (!string.IsNullOrWhiteSpace(maxOfficialRating))
- {
- query.MaxParentalRating = _localization.GetRatingScore(maxOfficialRating);
- }
+ // Min official rating
+ if (!string.IsNullOrWhiteSpace(minOfficialRating))
+ {
+ query.MinParentalRating = _localization.GetRatingScore(minOfficialRating);
+ }
+
+ // Max official rating
+ if (!string.IsNullOrWhiteSpace(maxOfficialRating))
+ {
+ query.MaxParentalRating = _localization.GetRatingScore(maxOfficialRating);
+ }
- // Artists
- if (artists.Length != 0)
+ // Artists
+ if (artists.Length != 0)
+ {
+ query.ArtistIds = artists.Select(i =>
{
- query.ArtistIds = artists.Select(i =>
+ try
{
- try
- {
- return _libraryManager.GetArtist(i, new DtoOptions(false));
- }
- catch
- {
- return null;
- }
- }).Where(i => i is not null).Select(i => i!.Id).ToArray();
- }
+ return _libraryManager.GetArtist(i, new DtoOptions(false));
+ }
+ catch
+ {
+ return null;
+ }
+ }).Where(i => i is not null).Select(i => i!.Id).ToArray();
+ }
- // ExcludeArtistIds
- if (excludeArtistIds.Length != 0)
- {
- query.ExcludeArtistIds = excludeArtistIds;
- }
+ // ExcludeArtistIds
+ if (excludeArtistIds.Length != 0)
+ {
+ query.ExcludeArtistIds = excludeArtistIds;
+ }
- if (albumIds.Length != 0)
- {
- query.AlbumIds = albumIds;
- }
+ if (albumIds.Length != 0)
+ {
+ query.AlbumIds = albumIds;
+ }
- // Albums
- if (albums.Length != 0)
+ // Albums
+ if (albums.Length != 0)
+ {
+ query.AlbumIds = albums.SelectMany(i =>
{
- query.AlbumIds = albums.SelectMany(i =>
- {
- return _libraryManager.GetItemIds(new InternalItemsQuery { IncludeItemTypes = [BaseItemKind.MusicAlbum], Name = i, Limit = 1 });
- }).ToArray();
- }
+ return _libraryManager.GetItemIds(new InternalItemsQuery { IncludeItemTypes = [BaseItemKind.MusicAlbum], Name = i, Limit = 1 });
+ }).ToArray();
+ }
- // Studios
- if (studios.Length != 0)
+ // Studios
+ if (studios.Length != 0)
+ {
+ query.StudioIds = studios.Select(i =>
{
- query.StudioIds = studios.Select(i =>
+ try
{
- try
- {
- return _libraryManager.GetStudio(i);
- }
- catch
- {
- return null;
- }
- }).Where(i => i is not null).Select(i => i!.Id).ToArray();
- }
-
- // Apply default sorting if none requested
- if (query.OrderBy.Count == 0)
- {
- // Albums by artist
- if (query.ArtistIds.Length > 0 && query.IncludeItemTypes.Length == 1 && query.IncludeItemTypes[0] == BaseItemKind.MusicAlbum)
+ return _libraryManager.GetStudio(i);
+ }
+ catch
{
- query.OrderBy = [(ItemSortBy.ProductionYear, SortOrder.Descending), (ItemSortBy.SortName, SortOrder.Ascending)];
+ return null;
}
+ }).Where(i => i is not null).Select(i => i!.Id).ToArray();
+ }
+
+ // Apply default sorting if none requested
+ if (query.OrderBy.Count == 0)
+ {
+ // Albums by artist
+ if (query.ArtistIds.Length > 0 && query.IncludeItemTypes.Length == 1 && query.IncludeItemTypes[0] == BaseItemKind.MusicAlbum)
+ {
+ query.OrderBy = [(ItemSortBy.ProductionYear, SortOrder.Descending), (ItemSortBy.SortName, SortOrder.Ascending)];
}
+ }
- query.Parent = null;
+ query.Parent = null;
+ // At the user root an unfiltered, non-recursive request is a plain listing of the user's libraries
+ if ((recursive.HasValue && recursive.Value) || ids.Length != 0 || item is not UserRootFolder || query.HasFilters)
+ {
// folder.GetItems applies user-access filtering via the InternalItemsQuery's User.
result = folder.GetItems(query);
if (searchResultScores is not null && searchResultScores.Count > 0)
diff --git a/MediaBrowser.Controller/Entities/InternalItemsQuery.cs b/MediaBrowser.Controller/Entities/InternalItemsQuery.cs
index 422c40ce5d..3b1f6a961f 100644
--- a/MediaBrowser.Controller/Entities/InternalItemsQuery.cs
+++ b/MediaBrowser.Controller/Entities/InternalItemsQuery.cs
@@ -72,6 +72,102 @@ namespace MediaBrowser.Controller.Entities
}
}
+ ///
+ /// Gets a value indicating whether the query carries any criteria that narrows the
+ /// result set, as opposed to user context, pagination, sorting or DTO options.
+ ///
+ public bool HasFilters =>
+ IncludeItemTypes.Length > 0
+ || ExcludeItemTypes.Length > 0
+ || Genres.Count > 0
+ || GenreIds.Count > 0
+ || Years.Length > 0
+ || Tags.Length > 0
+ || ExcludeTags.Length > 0
+ || OfficialRatings.Length > 0
+ || StudioIds.Length > 0
+ || ArtistIds.Length > 0
+ || AlbumArtistIds.Length > 0
+ || ContributingArtistIds.Length > 0
+ || ExcludeArtistIds.Length > 0
+ || AlbumIds.Length > 0
+ || PersonIds.Length > 0
+ || PersonTypes.Length > 0
+ || MediaTypes.Length > 0
+ || VideoTypes.Length > 0
+ || ImageTypes.Length > 0
+ || SeriesStatuses.Length > 0
+ || ItemIds.Length > 0
+ || ExcludeItemIds.Length > 0
+ || AudioLanguages.Count > 0
+ || SubtitleLanguages.Count > 0
+ || LinkedChildAncestorIds.Length > 0
+ || AncestorIds.Length > 0
+ || IsFavorite.HasValue
+ || IsFavoriteOrLiked.HasValue
+ || IsLiked.HasValue
+ || IsPlayed.HasValue
+ || IsResumable.HasValue
+ || IsFolder.HasValue
+ || IsMissing.HasValue
+ || IsUnaired.HasValue
+ || IsSpecialSeason.HasValue
+ || Is3D.HasValue
+ || IsHD.HasValue
+ || Is4K.HasValue
+ || IsLocked.HasValue
+ || IsPlaceHolder.HasValue
+ || IsMovie.HasValue
+ || IsSports.HasValue
+ || IsKids.HasValue
+ || IsNews.HasValue
+ || IsSeries.HasValue
+ || IsAiring.HasValue
+ || IsVirtualItem.HasValue
+ || HasImdbId.HasValue
+ || HasTmdbId.HasValue
+ || HasTvdbId.HasValue
+ || HasOverview.HasValue
+ || HasOfficialRating.HasValue
+ || HasParentalRating.HasValue
+ || HasThemeSong.HasValue
+ || HasThemeVideo.HasValue
+ || HasSubtitles.HasValue
+ || HasSpecialFeature.HasValue
+ || HasTrailer.HasValue
+ || HasChapterImages.HasValue
+ || MinCriticRating.HasValue
+ || MinCommunityRating.HasValue
+ || MinParentalRating is not null
+ || MinIndexNumber.HasValue
+ || MinParentAndIndexNumber.HasValue
+ || IndexNumber.HasValue
+ || ParentIndexNumber.HasValue
+ || AiredDuringSeason.HasValue
+ || MinWidth.HasValue
+ || MinHeight.HasValue
+ || MaxWidth.HasValue
+ || MaxHeight.HasValue
+ || MinPremiereDate.HasValue
+ || MaxPremiereDate.HasValue
+ || MinStartDate.HasValue
+ || MaxStartDate.HasValue
+ || MinEndDate.HasValue
+ || MaxEndDate.HasValue
+ || MinDateCreated.HasValue
+ || MinDateLastSaved.HasValue
+ || MinDateLastSavedForUser.HasValue
+ || AdjacentTo.HasValue
+ || !string.IsNullOrEmpty(NameStartsWith)
+ || !string.IsNullOrEmpty(NameStartsWithOrGreater)
+ || !string.IsNullOrEmpty(NameLessThan)
+ || !string.IsNullOrEmpty(NameContains)
+ || !string.IsNullOrEmpty(MinSortName)
+ || !string.IsNullOrEmpty(Name)
+ || !string.IsNullOrEmpty(Person)
+ || !string.IsNullOrEmpty(SearchTerm)
+ || !string.IsNullOrEmpty(Path);
+
public bool Recursive { get; set; }
public int? StartIndex { get; set; }
diff --git a/MediaBrowser.Controller/Entities/UserRootFolder.cs b/MediaBrowser.Controller/Entities/UserRootFolder.cs
index deed3631b8..d5be997b84 100644
--- a/MediaBrowser.Controller/Entities/UserRootFolder.cs
+++ b/MediaBrowser.Controller/Entities/UserRootFolder.cs
@@ -69,8 +69,14 @@ namespace MediaBrowser.Controller.Entities
protected override QueryResult GetItemsInternal(InternalItemsQuery query)
{
- if (query.Recursive)
+ // The user root holds no items of its own - a plain listing returns the user's
+ // views. But a request carrying any filter is a search across the libraries, so
+ // resolve it through the recursive query path even when Recursive wasn't set;
+ // otherwise the filters would be silently dropped. Recursive is set so the
+ // downstream query (ancestor/top-parent scoping) treats it as a recursive search.
+ if (query.Recursive || query.HasFilters)
{
+ query.Recursive = true;
return QueryRecursive(query);
}
--
cgit v1.2.3
From 1dd5a850807bc4269fce66d2d4191e67027285c8 Mon Sep 17 00:00:00 2001
From: Shadowghost
Date: Mon, 15 Jun 2026 09:29:24 +0200
Subject: Extend TranscodingReason reporting
---
.../MediaEncoding/EncodingHelper.cs | 77 ++++++++++++++--------
.../MediaEncoding/EncodingJobInfo.cs | 9 +++
MediaBrowser.Model/Dlna/StreamBuilder.cs | 8 +++
3 files changed, 65 insertions(+), 29 deletions(-)
(limited to 'MediaBrowser.Controller')
diff --git a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs
index 320e65231c..650eaa404e 100644
--- a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs
+++ b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs
@@ -25,6 +25,7 @@ using MediaBrowser.Model.Dlna;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.MediaInfo;
+using MediaBrowser.Model.Session;
using Microsoft.Extensions.Configuration;
using IConfigurationManager = MediaBrowser.Common.Configuration.IConfigurationManager;
@@ -2611,56 +2612,66 @@ namespace MediaBrowser.Controller.MediaEncoding
}
public bool CanStreamCopyAudio(EncodingJobInfo state, MediaStream audioStream, IEnumerable supportedAudioCodecs)
+ => CanStreamCopyAudio(state, audioStream, supportedAudioCodecs, out _);
+
+ ///
+ /// Determines whether the given audio stream can be stream-copied and, regardless of the outcome,
+ /// reports the codec/parameter incompatibilities that would force a re-encode via .
+ ///
+ /// The encoding job state.
+ /// The source audio stream.
+ /// The audio codecs the target supports.
+ /// The codec/parameter incompatibilities preventing a copy, or 0 if the stream is copy-compatible.
+ /// true if the audio stream can be stream-copied; otherwise, false .
+ public bool CanStreamCopyAudio(EncodingJobInfo state, MediaStream audioStream, IEnumerable supportedAudioCodecs, out TranscodeReason failureReasons)
{
var request = state.BaseRequest;
- if (!request.AllowAudioStreamCopy)
- {
- return false;
- }
+ // Policy-independent compatibility check, so the reasons are reported even when a policy gate is what ultimately prevents the copy.
+ failureReasons = GetAudioStreamCopyFailureReasons(state, audioStream, supportedAudioCodecs);
+
+ return request.AllowAudioStreamCopy
+ && request.EnableAutoStreamCopy
+ && failureReasons == 0;
+ }
+
+ private static TranscodeReason GetAudioStreamCopyFailureReasons(EncodingJobInfo state, MediaStream audioStream, IEnumerable supportedAudioCodecs)
+ {
+ var request = state.BaseRequest;
+ TranscodeReason reasons = 0;
var maxBitDepth = state.GetRequestedAudioBitDepth(audioStream.Codec);
if (maxBitDepth.HasValue
&& audioStream.BitDepth.HasValue
&& audioStream.BitDepth.Value > maxBitDepth.Value)
{
- return false;
+ reasons |= TranscodeReason.AudioBitDepthNotSupported;
}
// Source and target codecs must match
if (string.IsNullOrEmpty(audioStream.Codec)
|| !supportedAudioCodecs.Contains(audioStream.Codec, StringComparison.OrdinalIgnoreCase))
{
- return false;
+ reasons |= TranscodeReason.AudioCodecNotSupported;
}
// Channels must fall within requested value
var channels = state.GetRequestedAudioChannels(audioStream.Codec);
- if (channels.HasValue)
+ if (channels.HasValue
+ && (!audioStream.Channels.HasValue
+ || audioStream.Channels.Value <= 0
+ || audioStream.Channels.Value > channels.Value))
{
- if (!audioStream.Channels.HasValue || audioStream.Channels.Value <= 0)
- {
- return false;
- }
-
- if (audioStream.Channels.Value > channels.Value)
- {
- return false;
- }
+ reasons |= TranscodeReason.AudioChannelsNotSupported;
}
// Sample rate must fall within requested value
- if (request.AudioSampleRate.HasValue)
+ if (request.AudioSampleRate.HasValue
+ && (!audioStream.SampleRate.HasValue
+ || audioStream.SampleRate.Value <= 0
+ || audioStream.SampleRate.Value > request.AudioSampleRate.Value))
{
- if (!audioStream.SampleRate.HasValue || audioStream.SampleRate.Value <= 0)
- {
- return false;
- }
-
- if (audioStream.SampleRate.Value > request.AudioSampleRate.Value)
- {
- return false;
- }
+ reasons |= TranscodeReason.AudioSampleRateNotSupported;
}
// Audio bitrate must fall within requested value
@@ -2668,10 +2679,10 @@ namespace MediaBrowser.Controller.MediaEncoding
&& audioStream.BitRate.HasValue
&& audioStream.BitRate.Value > request.AudioBitRate.Value)
{
- return false;
+ reasons |= TranscodeReason.AudioBitrateNotSupported;
}
- return request.EnableAutoStreamCopy;
+ return reasons;
}
public int GetVideoBitrateParamValue(BaseEncodingJobOptions request, MediaStream videoStream, string outputVideoCodec)
@@ -7217,8 +7228,9 @@ namespace MediaBrowser.Controller.MediaEncoding
&& !IsCopyCodec(state.OutputVideoCodec)
&& options.HlsAudioSeekStrategy is HlsAudioSeekStrategy.TranscodeAudio;
+ TranscodeReason audioCopyFailureReasons = 0;
if (state.AudioStream is not null
- && CanStreamCopyAudio(state, state.AudioStream, state.SupportedAudioCodecs)
+ && CanStreamCopyAudio(state, state.AudioStream, state.SupportedAudioCodecs, out audioCopyFailureReasons)
&& !preventHlsAudioCopy)
{
state.OutputAudioCodec = "copy";
@@ -7232,6 +7244,13 @@ namespace MediaBrowser.Controller.MediaEncoding
{
state.OutputAudioCodec = "copy";
}
+ else if (state.AudioStream is not null && !IsCopyCodec(state.OutputAudioCodec))
+ {
+ // Audio is actually being re-encoded although the playback determination may have considered the source copyable.
+ // Only carry the primary "cannot be passed through" cause - the codec mismatch.
+ // Bitrate/channels/sample-rate/bit-depth copy refusals are consequences of the chosen transcode target.
+ state.AddTranscodeReason(audioCopyFailureReasons & TranscodeReason.AudioCodecNotSupported);
+ }
}
}
diff --git a/MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs b/MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs
index 3a1897a244..314cd32903 100644
--- a/MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs
+++ b/MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs
@@ -515,6 +515,15 @@ namespace MediaBrowser.Controller.MediaEncoding
public int HlsListSize => 0;
+ ///
+ /// Adds the specified reason(s) to .
+ ///
+ /// The transcode reason(s) to add.
+ public void AddTranscodeReason(TranscodeReason reason)
+ {
+ _transcodeReasons = TranscodeReasons | reason;
+ }
+
private int? GetMediaStreamCount(MediaStreamType type, int limit)
{
var count = MediaSource.GetStreamCount(type);
diff --git a/MediaBrowser.Model/Dlna/StreamBuilder.cs b/MediaBrowser.Model/Dlna/StreamBuilder.cs
index d875bbe8ed..59f97d8c7c 100644
--- a/MediaBrowser.Model/Dlna/StreamBuilder.cs
+++ b/MediaBrowser.Model/Dlna/StreamBuilder.cs
@@ -951,6 +951,10 @@ namespace MediaBrowser.Model.Dlna
}
playlistItem.VideoCodecs = videoCodecs;
+ if (videoStream is not null && !ContainerHelper.ContainsContainer(videoCodecs, false, videoStream.Codec))
+ {
+ playlistItem.TranscodeReasons |= TranscodeReason.VideoCodecNotSupported;
+ }
// Copy video codec options as a starting point, this applies to transcode and direct-stream
playlistItem.MaxFramerate = videoStream?.ReferenceFrameRate;
@@ -999,6 +1003,10 @@ namespace MediaBrowser.Model.Dlna
var directAudioFailures = audioStreamWithSupportedCodec is null ? default : GetCompatibilityAudioCodec(options, item, container ?? string.Empty, audioStreamWithSupportedCodec, null, true, false);
playlistItem.TranscodeReasons |= directAudioFailures;
+ if (audioStream is not null && audioStreamWithSupportedCodec is null)
+ {
+ playlistItem.TranscodeReasons |= TranscodeReason.AudioCodecNotSupported;
+ }
var directAudioStreamSatisfied = audioStreamWithSupportedCodec is not null && !channelsExceedsLimit
&& directAudioFailures == 0;
--
cgit v1.2.3
From 0fb042b7403ebd7578b696aba35ba0c582ccf6ba Mon Sep 17 00:00:00 2001
From: Shadowghost
Date: Fri, 19 Jun 2026 21:51:57 +0200
Subject: Surface the played version for resume
---
.../Library/UserDataManager.cs | 7 ++-
.../Item/BaseItemRepository.TranslateQuery.cs | 59 ++++++++++++----------
.../Item/BaseItemRepository.cs | 8 +++
.../Library/VersionResumeData.cs | 20 ++++----
.../Library/VersionResumeDataTests.cs | 30 ++++++-----
.../Item/AlternateVersionQueryTranslationTests.cs | 36 +++++++------
6 files changed, 90 insertions(+), 70 deletions(-)
(limited to 'MediaBrowser.Controller')
diff --git a/Emby.Server.Implementations/Library/UserDataManager.cs b/Emby.Server.Implementations/Library/UserDataManager.cs
index bedaacee33..61372f8b56 100644
--- a/Emby.Server.Implementations/Library/UserDataManager.cs
+++ b/Emby.Server.Implementations/Library/UserDataManager.cs
@@ -329,22 +329,21 @@ namespace Emby.Server.Implementations.Library
foreach (var (primaryId, versions) in versionGroups)
{
- Video? resumeVersion = null;
UserItemData? resumeData = null;
foreach (var version in versions)
{
+ // Consider both in-progress and completed versions so a finished alternate still marks the primary as played.
if (userDataByVersion.TryGetValue(version.Id, out var data)
- && data.PlaybackPositionTicks > 0
+ && (data.PlaybackPositionTicks > 0 || data.Played)
&& (resumeData is null || (data.LastPlayedDate ?? DateTime.MinValue) > (resumeData.LastPlayedDate ?? DateTime.MinValue)))
{
- resumeVersion = version;
resumeData = data;
}
}
if (resumeData is not null)
{
- result[primaryId] = new VersionResumeData(resumeData, resumeVersion!.RunTimeTicks);
+ result[primaryId] = new VersionResumeData(resumeData);
}
}
diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs
index 96d6d2eaff..c234f333af 100644
--- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs
+++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs
@@ -515,15 +515,15 @@ public sealed partial class BaseItemRepository
var hasSeries = filter.IncludeItemTypes.Contains(BaseItemKind.Series);
var userId = filter.User!.Id;
var isResumable = filter.IsResumable.Value;
+ var seriesTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Series];
// In-progress user data rows; alternate versions track their own progress.
var inProgress = context.UserData
.Where(ud => ud.UserId == userId && ud.PlaybackPositionTicks > 0);
+ IQueryable? resumableSeriesIds = null;
if (hasSeries)
{
- var seriesTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Series];
-
// Aggregate per series in a single GROUP BY pass, instead of three full scans.
var seriesEpisodeStats = context.BaseItems
.AsNoTracking()
@@ -539,39 +539,41 @@ public sealed partial class BaseItemRepository
// A series is resumable if it has an in-progress episode,
// or if it has both played and unplayed episodes (partially watched).
- var resumableSeriesIds = seriesEpisodeStats
+ resumableSeriesIds = seriesEpisodeStats
.Where(s => s.HasInProgress || (s.HasPlayed && s.HasUnplayed))
.Select(s => s.SeriesId);
-
- // Non-series items: resumable if the item or any of its alternate versions has
- // PlaybackPositionTicks > 0. Alternate versions (PrimaryVersionId set) are excluded
- // from the base query, so coalesce their progress onto the primary's id.
- var resumableMovieIds = inProgress
- .Join(context.BaseItems, ud => ud.ItemId, bi => bi.Id, (ud, bi) => bi.PrimaryVersionId ?? bi.Id);
-
- baseQuery = baseQuery.Where(e =>
- (e.Type == seriesTypeName && resumableSeriesIds.Contains(e.Id) == isResumable)
- || (e.Type != seriesTypeName && resumableMovieIds.Contains(e.Id) == isResumable));
- }
- else
- {
- // Resumable if the item or any of its alternate versions has PlaybackPositionTicks > 0.
- // Alternate versions (PrimaryVersionId set) are excluded from the base query, so
- // coalesce their progress onto the primary's id.
- var resumableMovieIds = inProgress
- .Join(context.BaseItems, ud => ud.ItemId, bi => bi.Id, (ud, bi) => bi.PrimaryVersionId ?? bi.Id);
- baseQuery = baseQuery.Where(e => resumableMovieIds.Contains(e.Id) == isResumable);
}
if (isResumable)
{
- // Multi-version items surface as the version that was actually played.
+ // Resume queries surface the version that was actually played, which may be an alternate.
+ // Match each version on its own progress rather than coalescing onto the primary.
+ var inProgressIds = inProgress.Select(ud => ud.ItemId);
+
+ baseQuery = hasSeries
+ ? baseQuery.Where(e =>
+ (e.Type == seriesTypeName && resumableSeriesIds!.Contains(e.Id))
+ || (e.Type != seriesTypeName && inProgressIds.Contains(e.Id)))
+ : baseQuery.Where(e => inProgressIds.Contains(e.Id));
+
// When several versions of the same item are in progress, keep only the most recently played one.
- baseQuery = baseQuery.Where(e => !context.BaseItems
+ baseQuery = baseQuery.Where(e => e.Type == seriesTypeName || !context.BaseItems
.Where(s => s.Id != e.Id && (s.PrimaryVersionId ?? s.Id) == (e.PrimaryVersionId ?? e.Id))
.Any(s => inProgress.Where(su => su.ItemId == s.Id).Max(su => su.LastPlayedDate)
> inProgress.Where(eu => eu.ItemId == e.Id).Max(eu => eu.LastPlayedDate)));
}
+ else
+ {
+ // Not-resumable queries operate on primaries only.
+ var resumableMovieIds = inProgress
+ .Join(context.BaseItems, ud => ud.ItemId, bi => bi.Id, (ud, bi) => bi.PrimaryVersionId ?? bi.Id);
+
+ baseQuery = hasSeries
+ ? baseQuery.Where(e =>
+ (e.Type == seriesTypeName && !resumableSeriesIds!.Contains(e.Id))
+ || (e.Type != seriesTypeName && !resumableMovieIds.Contains(e.Id)))
+ : baseQuery.Where(e => !resumableMovieIds.Contains(e.Id));
+ }
}
if (filter.ArtistIds.Length > 0)
@@ -758,10 +760,13 @@ public sealed partial class BaseItemRepository
}
else if (filter.OwnerIds.Length == 0 && filter.ExtraTypes.Length == 0 && !filter.IncludeOwnedItems)
{
- // Exclude alternate versions and owned non-extra items from general queries.
- // Alternate versions have PrimaryVersionId set (pointing to their primary).
+ // Exclude owned non-extra items from general queries.
// Extras (trailers, etc.) have OwnerId set but also have ExtraType set - keep those.
- baseQuery = baseQuery.Where(e => e.PrimaryVersionId == null && (e.OwnerId == null || e.ExtraType != null));
+ // Alternate versions (PrimaryVersionId set) are normally excluded too, but resume queries
+ // keep them so the actually-played version can surface instead of collapsing onto the primary.
+ baseQuery = filter.IsResumable == true
+ ? baseQuery.Where(e => e.OwnerId == null || e.ExtraType != null)
+ : baseQuery.Where(e => e.PrimaryVersionId == null && (e.OwnerId == null || e.ExtraType != null));
}
if (filter.OwnerIds.Length > 0)
diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs
index 94dedaeba8..57041276b7 100644
--- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs
+++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs
@@ -167,6 +167,14 @@ public sealed partial class BaseItemRepository
return false;
}
+ // Resume queries surface the actually-played version (which may be an alternate sharing the
+ // primary's presentation key). The resumable filter already keeps one version per group, so
+ // presentation-key grouping must not collapse the surfaced version back onto the primary.
+ if (query.IsResumable == true)
+ {
+ return false;
+ }
+
if (query.GroupBySeriesPresentationUniqueKey)
{
return false;
diff --git a/MediaBrowser.Controller/Library/VersionResumeData.cs b/MediaBrowser.Controller/Library/VersionResumeData.cs
index ca8a499d97..455fe739ce 100644
--- a/MediaBrowser.Controller/Library/VersionResumeData.cs
+++ b/MediaBrowser.Controller/Library/VersionResumeData.cs
@@ -1,29 +1,29 @@
+using System;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Model.Dto;
namespace MediaBrowser.Controller.Library
{
///
- /// The user data of the alternate version that should drive resume for a multi-version item.
+ /// The user data of the most recently played alternate version that should drive the completion state of a multi-version item.
///
/// The resume version's user data.
- /// The resume version's runtime, used for the progress percentage.
- public record VersionResumeData(UserItemData UserData, long? RunTimeTicks)
+ public record VersionResumeData(UserItemData UserData)
{
///
- /// Applies the resume version's playback state to the supplied user data dto, so that an item
- /// whose most recent progress lives on an alternate version still reports that progress.
+ /// Merges the most recently played version's completion state into the supplied user data dto.
+ /// Only completion (played) propagates to the primary; the in-progress resume position stays on
+ /// the version that owns it, which is surfaced directly (e.g. in resume queries) so that playback
+ /// always targets the correct version rather than resuming the primary at another version's offset.
///
/// The user data dto to update.
public void ApplyTo(UserItemDataDto dto)
{
- dto.PlaybackPositionTicks = UserData.PlaybackPositionTicks;
- dto.Played = UserData.Played;
- dto.LastPlayedDate = UserData.LastPlayedDate;
+ dto.Played = dto.Played || UserData.Played;
- if (RunTimeTicks > 0 && UserData.PlaybackPositionTicks > 0)
+ if ((UserData.LastPlayedDate ?? DateTime.MinValue) > (dto.LastPlayedDate ?? DateTime.MinValue))
{
- dto.PlayedPercentage = 100.0 * UserData.PlaybackPositionTicks / RunTimeTicks.Value;
+ dto.LastPlayedDate = UserData.LastPlayedDate;
}
}
}
diff --git a/tests/Jellyfin.Controller.Tests/Library/VersionResumeDataTests.cs b/tests/Jellyfin.Controller.Tests/Library/VersionResumeDataTests.cs
index 5b0f003019..8642ab07f8 100644
--- a/tests/Jellyfin.Controller.Tests/Library/VersionResumeDataTests.cs
+++ b/tests/Jellyfin.Controller.Tests/Library/VersionResumeDataTests.cs
@@ -9,36 +9,40 @@ namespace Jellyfin.Controller.Tests.Library;
public class VersionResumeDataTests
{
[Fact]
- public void ApplyTo_OverridesResumeFieldsAndPercentage()
+ public void ApplyTo_PropagatesCompletionButNotPosition()
{
var lastPlayed = new DateTime(2026, 1, 2, 0, 0, 0, DateTimeKind.Utc);
var resume = new VersionResumeData(
- new UserItemData { Key = "version", PlaybackPositionTicks = 25, Played = true, LastPlayedDate = lastPlayed },
- RunTimeTicks: 100);
+ new UserItemData { Key = "version", PlaybackPositionTicks = 25, Played = true, LastPlayedDate = lastPlayed });
var dto = new UserItemDataDto { Key = "primary", PlaybackPositionTicks = 1, Played = false, PlayedPercentage = 1 };
resume.ApplyTo(dto);
- Assert.Equal(25, dto.PlaybackPositionTicks);
+ // Completion state propagates to the primary...
Assert.True(dto.Played);
Assert.Equal(lastPlayed, dto.LastPlayedDate);
- // The percentage is based on the resume version's own runtime, not the primary's.
- Assert.NotNull(dto.PlayedPercentage);
- Assert.Equal(25.0, dto.PlayedPercentage.Value, 5);
+ // ...but the in-progress resume position stays on the version that owns it.
+ Assert.Equal(1, dto.PlaybackPositionTicks);
+ Assert.Equal(1.0, dto.PlayedPercentage);
}
[Fact]
- public void ApplyTo_WithoutRuntime_LeavesPercentageUntouched()
+ public void ApplyTo_DoesNotUnsetExistingPlayedOrRegressLastPlayed()
{
- var resume = new VersionResumeData(new UserItemData { Key = "version", PlaybackPositionTicks = 25 }, null);
- var dto = new UserItemDataDto { Key = "primary", PlayedPercentage = 42 };
+ var primaryLastPlayed = new DateTime(2026, 1, 5, 0, 0, 0, DateTimeKind.Utc);
+ var versionLastPlayed = new DateTime(2026, 1, 2, 0, 0, 0, DateTimeKind.Utc);
+ var resume = new VersionResumeData(
+ new UserItemData { Key = "version", Played = false, LastPlayedDate = versionLastPlayed });
+
+ var dto = new UserItemDataDto { Key = "primary", Played = true, LastPlayedDate = primaryLastPlayed };
resume.ApplyTo(dto);
- Assert.Equal(25, dto.PlaybackPositionTicks);
- Assert.NotNull(dto.PlayedPercentage);
- Assert.Equal(42.0, dto.PlayedPercentage.Value, 5);
+ // A not-yet-completed version must not clear the primary's own completion, and the more recent
+ // LastPlayedDate is kept.
+ Assert.True(dto.Played);
+ Assert.Equal(primaryLastPlayed, dto.LastPlayedDate);
}
}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/AlternateVersionQueryTranslationTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/AlternateVersionQueryTranslationTests.cs
index 823ff566a7..1f0de153a0 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Item/AlternateVersionQueryTranslationTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/Item/AlternateVersionQueryTranslationTests.cs
@@ -37,39 +37,43 @@ public sealed class AlternateVersionQueryTranslationTests : IDisposable
}
[Fact]
- public void ResumeFilter_VersionProgress_SurfacesPrimary()
+ public void ResumeFilter_VersionProgress_SurfacesPlayedVersion()
{
- Guid userId, primaryId, otherId;
+ Guid userId, primaryId, versionId, otherId;
using (var ctx = CreateDbContext())
{
- (userId, primaryId, otherId) = Seed(ctx);
+ (userId, primaryId, versionId, otherId) = Seed(ctx);
}
using (var ctx = CreateDbContext())
{
- // Mirrors the resumable filter in BaseItemRepository.TranslateQuery: progress on any
- // version coalesces onto the primary's id.
var inProgress = ctx.UserData
.Where(ud => ud.UserId == userId && ud.PlaybackPositionTicks > 0);
- var resumableMovieIds = inProgress
- .Join(ctx.BaseItems, ud => ud.ItemId, bi => bi.Id, (ud, bi) => bi.PrimaryVersionId ?? bi.Id);
// Scope to the seeded items; EnsureCreated also seeds a placeholder row.
- var seededIds = new[] { primaryId, otherId };
+ var seededIds = new[] { primaryId, versionId, otherId };
+ // Mirrors the resumable=true filter in BaseItemRepository.TranslateQuery.
+ var inProgressIds = inProgress.Select(ud => ud.ItemId);
var resumable = ctx.BaseItems
- .Where(e => seededIds.Contains(e.Id) && e.PrimaryVersionId == null)
- .Where(e => resumableMovieIds.Contains(e.Id))
+ .Where(e => seededIds.Contains(e.Id))
+ .Where(e => inProgressIds.Contains(e.Id))
+ .Where(e => !ctx.BaseItems
+ .Where(s => s.Id != e.Id && (s.PrimaryVersionId ?? s.Id) == (e.PrimaryVersionId ?? e.Id))
+ .Any(s => inProgress.Where(su => su.ItemId == s.Id).Max(su => su.LastPlayedDate)
+ > inProgress.Where(eu => eu.ItemId == e.Id).Max(eu => eu.LastPlayedDate)))
.Select(e => e.Id)
.ToList();
- Assert.Equal([primaryId], resumable);
+ Assert.Equal([versionId], resumable);
- // The inverse (not-resumable) direction must exclude the primary as well.
+ // The not-resumable direction keeps primaries only.
+ var resumableMovieIds = inProgress
+ .Join(ctx.BaseItems, ud => ud.ItemId, bi => bi.Id, (ud, bi) => bi.PrimaryVersionId ?? bi.Id);
var notResumable = ctx.BaseItems
.Where(e => seededIds.Contains(e.Id) && e.PrimaryVersionId == null)
- .Where(e => resumableMovieIds.Contains(e.Id) == false)
+ .Where(e => !resumableMovieIds.Contains(e.Id))
.Select(e => e.Id)
.ToList();
@@ -84,7 +88,7 @@ public sealed class AlternateVersionQueryTranslationTests : IDisposable
using (var ctx = CreateDbContext())
{
- (userId, primaryId, otherId) = Seed(ctx);
+ (userId, primaryId, _, otherId) = Seed(ctx);
}
using (var ctx = CreateDbContext())
@@ -106,7 +110,7 @@ public sealed class AlternateVersionQueryTranslationTests : IDisposable
}
}
- private static (Guid UserId, Guid PrimaryId, Guid OtherId) Seed(JellyfinDbContext ctx)
+ private static (Guid UserId, Guid PrimaryId, Guid VersionId, Guid OtherId) Seed(JellyfinDbContext ctx)
{
var user = new User("test", "auth-provider", "reset-provider");
ctx.Users.Add(user);
@@ -129,7 +133,7 @@ public sealed class AlternateVersionQueryTranslationTests : IDisposable
});
ctx.SaveChanges();
- return (user.Id, primary.Id, other.Id);
+ return (user.Id, primary.Id, version.Id, other.Id);
}
private JellyfinDbContext CreateDbContext()
--
cgit v1.2.3
From e4383493a96da86c99516bfdf69ecd609c2dfec2 Mon Sep 17 00:00:00 2001
From: danne
Date: Sat, 13 Jun 2026 22:44:44 +0200
Subject: Fix audio sample rate forced to 48 kHz for non-Opus codecs
GetProgressiveAudioFullCommandLine applied the libopus-only sample rate
quantization to every codec except Opus, inverting the intended guard.
A requested rate such as 44100 Hz was therefore snapped to 48000 Hz for
AAC/MP3/FLAC, while Opus (which actually requires the quantization) was
skipped entirely.
Apply the quantization only when the output codec is Opus, and pass the
requested sample rate through unchanged for all other codecs.
Fixes #17026
Co-Authored-By: Claude Opus 4.8
---
.../MediaEncoding/EncodingHelper.cs | 15 ++++----
.../MediaEncoding/EncodingHelperTests.cs | 45 ++++++++++++++++++++++
2 files changed, 53 insertions(+), 7 deletions(-)
(limited to 'MediaBrowser.Controller')
diff --git a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs
index 650eaa404e..847f4cf187 100644
--- a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs
+++ b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs
@@ -7870,13 +7870,14 @@ namespace MediaBrowser.Controller.MediaEncoding
audioTranscodeParams.Add("-ar " + state.BaseRequest.AudioBitRate);
}
- if (!string.Equals(outputCodec, "opus", StringComparison.OrdinalIgnoreCase))
+ var sampleRate = state.OutputAudioSampleRate;
+ if (sampleRate.HasValue)
{
- // opus only supports specific sampling rates
- var sampleRate = state.OutputAudioSampleRate;
- if (sampleRate.HasValue)
+ var sampleRateValue = sampleRate.Value;
+ if (string.Equals(outputCodec, "opus", StringComparison.OrdinalIgnoreCase))
{
- var sampleRateValue = sampleRate.Value switch
+ // opus only supports specific sampling rates
+ sampleRateValue = sampleRate.Value switch
{
<= 8000 => 8000,
<= 12000 => 12000,
@@ -7884,9 +7885,9 @@ namespace MediaBrowser.Controller.MediaEncoding
<= 24000 => 24000,
_ => 48000
};
-
- audioTranscodeParams.Add("-ar " + sampleRateValue.ToString(CultureInfo.InvariantCulture));
}
+
+ audioTranscodeParams.Add("-ar " + sampleRateValue.ToString(CultureInfo.InvariantCulture));
}
// Copy the movflags from GetProgressiveVideoFullCommandLine
diff --git a/tests/Jellyfin.Controller.Tests/MediaEncoding/EncodingHelperTests.cs b/tests/Jellyfin.Controller.Tests/MediaEncoding/EncodingHelperTests.cs
index d7ae6a8a18..71b6551d0f 100644
--- a/tests/Jellyfin.Controller.Tests/MediaEncoding/EncodingHelperTests.cs
+++ b/tests/Jellyfin.Controller.Tests/MediaEncoding/EncodingHelperTests.cs
@@ -11,6 +11,7 @@ using MediaBrowser.Model.Configuration;
using MediaBrowser.Model.Dlna;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.MediaInfo;
using Moq;
using Xunit;
@@ -203,6 +204,50 @@ public class EncodingHelperTests
}
}
+ [Theory]
+ [InlineData("aac", 44100, 44100)] // non-opus: requested rate must be preserved (issue #17026)
+ [InlineData("aac", 48000, 48000)]
+ [InlineData("mp3", 22050, 22050)]
+ [InlineData("flac", 96000, 96000)]
+ [InlineData("opus", 44100, 48000)] // opus: must snap to a libopus-supported rate
+ [InlineData("opus", 22050, 24000)]
+ [InlineData("opus", 8000, 8000)]
+ public void GetProgressiveAudioFullCommandLine_SampleRate_OnlyClampedForOpus(
+ string audioCodec,
+ int requestedSampleRate,
+ int expectedSampleRate)
+ {
+ var state = BuildAudioState(audioCodec, requestedSampleRate);
+ var args = CreateHelper().GetProgressiveAudioFullCommandLine(state, new EncodingOptions(), "/tmp/out");
+
+ Assert.Contains("-ar " + expectedSampleRate, args, StringComparison.Ordinal);
+ }
+
+ private static EncodingJobInfo BuildAudioState(string audioCodec, int requestedSampleRate)
+ {
+ var audio = new MediaStream { Index = 0, Type = MediaStreamType.Audio, Codec = "flac", SampleRate = 96000 };
+
+ return new EncodingJobInfo(TranscodingJobType.Progressive)
+ {
+ MediaSource = new MediaSourceInfo
+ {
+ Container = "flac",
+ MediaStreams = new List { audio },
+ Path = "/media/track.flac",
+ Protocol = MediaProtocol.File,
+ },
+ AudioStream = audio,
+ OutputAudioCodec = audioCodec,
+ BaseRequest = new VideoRequestDto
+ {
+ AudioCodec = audioCodec,
+ AudioSampleRate = requestedSampleRate,
+ },
+ IsVideoRequest = false,
+ IsInputVideo = false,
+ };
+ }
+
private static EncodingJobInfo BuildState(
MediaStream? subtitle,
SubtitleDeliveryMethod? deliveryMethod,
--
cgit v1.2.3
From 70b45893829feddff5f5e5f89e9087b395454c08 Mon Sep 17 00:00:00 2001
From: Marc Brooks
Date: Mon, 5 Jan 2026 18:41:34 -0600
Subject: Fix Book collections scanning all items
Added static method GetBaseItemKindsForCollectionType in ItemsController (moved from ContentFolderImageProvider to be shared)
Added AudioBook to GetRepresentativeItemTypes for CollectionType.books for consistency
Added GetBooks to GetUserItems for CollectionType.books which gets BaseItemKind.Book and BaseItemKind.AudioBook
Move GetBaseItemKindsForCollectionType to DtoExtensions
Cleaned up the missing null checks and used new collection expressions.
Associate Person to Book and AudioBook for related items.
---
Emby.Server.Implementations/Dto/DtoService.cs | 2 ++
.../Images/CollectionFolderImageProvider.cs | 42 +++-------------------
Jellyfin.Api/Controllers/ItemsController.cs | 3 ++
Jellyfin.Api/Extensions/DtoExtensions.cs | 30 ++++++++++++++++
.../Entities/UserViewBuilder.cs | 14 ++++++++
5 files changed, 53 insertions(+), 38 deletions(-)
(limited to 'MediaBrowser.Controller')
diff --git a/Emby.Server.Implementations/Dto/DtoService.cs b/Emby.Server.Implementations/Dto/DtoService.cs
index 3cd72a8ac1..831419f380 100644
--- a/Emby.Server.Implementations/Dto/DtoService.cs
+++ b/Emby.Server.Implementations/Dto/DtoService.cs
@@ -71,6 +71,8 @@ namespace Emby.Server.Implementations.Dto
{
BaseItemKind.Person, [
BaseItemKind.Audio,
+ BaseItemKind.AudioBook,
+ BaseItemKind.Book,
BaseItemKind.Episode,
BaseItemKind.Movie,
BaseItemKind.LiveTvProgram,
diff --git a/Emby.Server.Implementations/Images/CollectionFolderImageProvider.cs b/Emby.Server.Implementations/Images/CollectionFolderImageProvider.cs
index 095934f896..b701e7eb6d 100644
--- a/Emby.Server.Implementations/Images/CollectionFolderImageProvider.cs
+++ b/Emby.Server.Implementations/Images/CollectionFolderImageProvider.cs
@@ -5,6 +5,7 @@
using System;
using System.Collections.Generic;
using System.IO;
+using Jellyfin.Api.Extensions;
using Jellyfin.Data.Enums;
using Jellyfin.Database.Implementations.Enums;
using MediaBrowser.Common.Configuration;
@@ -14,7 +15,6 @@ using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.IO;
-using MediaBrowser.Model.Querying;
namespace Emby.Server.Implementations.Images
{
@@ -28,38 +28,7 @@ namespace Emby.Server.Implementations.Images
{
var view = (CollectionFolder)item;
var viewType = view.CollectionType;
-
- BaseItemKind[] includeItemTypes;
-
- switch (viewType)
- {
- case CollectionType.movies:
- includeItemTypes = new[] { BaseItemKind.Movie };
- break;
- case CollectionType.tvshows:
- includeItemTypes = new[] { BaseItemKind.Series };
- break;
- case CollectionType.music:
- includeItemTypes = new[] { BaseItemKind.MusicArtist }; // Music albums usually don't have dedicated backdrops, so use artist instead
- break;
- case CollectionType.musicvideos:
- includeItemTypes = new[] { BaseItemKind.MusicVideo };
- break;
- case CollectionType.books:
- includeItemTypes = new[] { BaseItemKind.Book, BaseItemKind.AudioBook };
- break;
- case CollectionType.boxsets:
- includeItemTypes = new[] { BaseItemKind.BoxSet };
- break;
- case CollectionType.homevideos:
- case CollectionType.photos:
- includeItemTypes = new[] { BaseItemKind.Video, BaseItemKind.Photo };
- break;
- default:
- includeItemTypes = new[] { BaseItemKind.Video, BaseItemKind.Audio, BaseItemKind.Photo, BaseItemKind.Movie, BaseItemKind.Series };
- break;
- }
-
+ var includeItemTypes = DtoExtensions.GetBaseItemKindsForCollectionType(viewType);
var recursive = viewType != CollectionType.playlists;
return view.GetItemList(new InternalItemsQuery
@@ -67,12 +36,9 @@ namespace Emby.Server.Implementations.Images
CollapseBoxSetItems = false,
Recursive = recursive,
DtoOptions = new DtoOptions(false),
- ImageTypes = new[] { ImageType.Primary },
+ ImageTypes = [ImageType.Primary],
Limit = 8,
- OrderBy = new[]
- {
- (ItemSortBy.Random, SortOrder.Ascending)
- },
+ OrderBy = [(ItemSortBy.Random, SortOrder.Ascending)],
IncludeItemTypes = includeItemTypes
});
}
diff --git a/Jellyfin.Api/Controllers/ItemsController.cs b/Jellyfin.Api/Controllers/ItemsController.cs
index c52a6cd7dc..e6f59d27ec 100644
--- a/Jellyfin.Api/Controllers/ItemsController.cs
+++ b/Jellyfin.Api/Controllers/ItemsController.cs
@@ -287,6 +287,8 @@ public class ItemsController : BaseJellyfinApiController
QueryResult result;
Guid[] linkedChildAncestorIds = [];
+
+ includeItemTypes ??= [];
if (includeItemTypes.Length == 1
&& (includeItemTypes[0] == BaseItemKind.BoxSet || includeItemTypes[0] == BaseItemKind.Playlist)
&& item is not BoxSet
@@ -314,6 +316,7 @@ public class ItemsController : BaseJellyfinApiController
if (folder is IHasCollectionType hasCollectionType)
{
collectionType = hasCollectionType.CollectionType;
+ includeItemTypes = [.. includeItemTypes.Union(DtoExtensions.GetBaseItemKindsForCollectionType(collectionType))];
}
if (collectionType == CollectionType.playlists)
diff --git a/Jellyfin.Api/Extensions/DtoExtensions.cs b/Jellyfin.Api/Extensions/DtoExtensions.cs
index 9c24be82ea..a6bb4f22dd 100644
--- a/Jellyfin.Api/Extensions/DtoExtensions.cs
+++ b/Jellyfin.Api/Extensions/DtoExtensions.cs
@@ -1,4 +1,5 @@
using System.Collections.Generic;
+using Jellyfin.Data.Enums;
using MediaBrowser.Controller.Dto;
using MediaBrowser.Model.Entities;
@@ -9,6 +10,35 @@ namespace Jellyfin.Api.Extensions;
///
public static class DtoExtensions
{
+ ///
+ /// Gets the BaseItemKind values associated with the specified CollectionType.
+ ///
+ /// The collection type to map to BaseItemKind values.
+ /// An array of BaseItemKind values that correspond to the collection type.
+ public static BaseItemKind[] GetBaseItemKindsForCollectionType(CollectionType? collectionType)
+ {
+ switch (collectionType)
+ {
+ case CollectionType.movies:
+ return [BaseItemKind.Movie];
+ case CollectionType.tvshows:
+ return [BaseItemKind.Series];
+ case CollectionType.music:
+ return [BaseItemKind.MusicAlbum, BaseItemKind.MusicArtist];
+ case CollectionType.musicvideos:
+ return [BaseItemKind.MusicVideo];
+ case CollectionType.books:
+ return [BaseItemKind.Book, BaseItemKind.AudioBook];
+ case CollectionType.boxsets:
+ return [BaseItemKind.BoxSet];
+ case CollectionType.homevideos:
+ case CollectionType.photos:
+ return [BaseItemKind.Video, BaseItemKind.Photo];
+ default:
+ return [BaseItemKind.Video, BaseItemKind.Audio, BaseItemKind.Photo, BaseItemKind.Movie, BaseItemKind.Series];
+ }
+ }
+
///
/// Add additional DtoOptions.
///
diff --git a/MediaBrowser.Controller/Entities/UserViewBuilder.cs b/MediaBrowser.Controller/Entities/UserViewBuilder.cs
index cb05056601..c57ed2faf8 100644
--- a/MediaBrowser.Controller/Entities/UserViewBuilder.cs
+++ b/MediaBrowser.Controller/Entities/UserViewBuilder.cs
@@ -61,6 +61,9 @@ namespace MediaBrowser.Controller.Entities
case CollectionType.folders:
return GetResult(_libraryManager.GetUserRootFolder().GetChildren(user, true), query);
+ case CollectionType.books:
+ return GetBooks(queryParent, user, query);
+
case CollectionType.tvshows:
return GetTvView(queryParent, user, query);
@@ -190,6 +193,17 @@ namespace MediaBrowser.Controller.Entities
return _libraryManager.GetItemsResult(query);
}
+ private QueryResult GetBooks(Folder parent, User user, InternalItemsQuery query)
+ {
+ query.Recursive = true;
+ query.Parent = parent;
+ query.SetUser(user);
+
+ query.IncludeItemTypes = new[] { BaseItemKind.Book, BaseItemKind.AudioBook };
+
+ return _libraryManager.GetItemsResult(query);
+ }
+
private QueryResult GetMovieMovies(Folder parent, User user, InternalItemsQuery query)
{
query.Recursive = true;
--
cgit v1.2.3
From 38f1d9749ee67f18264937807b2f5882e1421557 Mon Sep 17 00:00:00 2001
From: Shadowghost
Date: Thu, 2 Jul 2026 08:49:11 +0200
Subject: Fix review comments
---
Emby.Server.Implementations/Dto/DtoService.cs | 5 +-
.../Library/MediaSourceManager.cs | 25 +++----
.../Library/UserDataManager.cs | 20 ++---
.../Session/SessionManager.cs | 11 ++-
Emby.Server.Implementations/TV/TVSeriesManager.cs | 20 ++---
Jellyfin.Api/Controllers/ItemsController.cs | 8 +-
.../Item/BaseItemRepository.TranslateQuery.cs | 10 ++-
.../Item/OrderMapper.cs | 4 +
.../Library/VersionPlaybackSelector.cs | 59 +++++++++++++++
.../Library/VersionResumeData.cs | 19 ++++-
.../Library/VersionResumeDataTests.cs | 53 ++++++++++++--
.../Item/AlternateVersionQueryTranslationTests.cs | 85 +++++++++++++++++++++-
12 files changed, 259 insertions(+), 60 deletions(-)
create mode 100644 MediaBrowser.Controller/Library/VersionPlaybackSelector.cs
(limited to 'MediaBrowser.Controller')
diff --git a/Emby.Server.Implementations/Dto/DtoService.cs b/Emby.Server.Implementations/Dto/DtoService.cs
index 2bf478953e..9881565cd2 100644
--- a/Emby.Server.Implementations/Dto/DtoService.cs
+++ b/Emby.Server.Implementations/Dto/DtoService.cs
@@ -1272,8 +1272,9 @@ namespace Emby.Server.Implementations.Dto
// Match the per-user filtering of the media sources: versions the user cannot
// access are not selectable, so they must not count towards the badge either.
var mediaSourceCount = user is null
- ? video.MediaSourceCount
- : video.GetAllVersions().Count(v => v.Id.Equals(video.Id) || v.IsVisibleStandalone(user));
+ || (!video.PrimaryVersionId.HasValue && video.LinkedAlternateVersions.Length == 0 && !video.HasLocalAlternateVersions)
+ ? video.MediaSourceCount
+ : video.GetAllVersions().Count(v => v.Id.Equals(video.Id) || v.IsVisibleStandalone(user));
if (mediaSourceCount != 1)
{
dto.MediaSourceCount = mediaSourceCount;
diff --git a/Emby.Server.Implementations/Library/MediaSourceManager.cs b/Emby.Server.Implementations/Library/MediaSourceManager.cs
index c0e45ab6c7..c64833ddaa 100644
--- a/Emby.Server.Implementations/Library/MediaSourceManager.cs
+++ b/Emby.Server.Implementations/Library/MediaSourceManager.cs
@@ -451,26 +451,23 @@ namespace Emby.Server.Implementations.Library
}
}
- MediaSourceInfo resumeSource = null;
- UserItemData resumeData = null;
foreach (var source in sources)
{
- if (source.Id is null
- || !dataBySourceId.TryGetValue(source.Id, out var data)
- || data.PlaybackPositionTicks <= 0)
+ if (source.Id is not null
+ && dataBySourceId.TryGetValue(source.Id, out var data)
+ && data.PlaybackPositionTicks > 0)
{
- continue;
- }
-
- source.PlaybackPositionTicks = data.PlaybackPositionTicks;
-
- if (resumeData is null || (data.LastPlayedDate ?? DateTime.MinValue) > (resumeData.LastPlayedDate ?? DateTime.MinValue))
- {
- resumeSource = source;
- resumeData = data;
+ source.PlaybackPositionTicks = data.PlaybackPositionTicks;
}
}
+ // Reorder only for a resumable (in-progress) version;
+ // a completed version has no position to resume, so it must not be pulled to the front here.
+ var resumeSource = VersionPlaybackSelector.SelectMostRecentlyPlayed(
+ sources,
+ source => source.Id is not null ? dataBySourceId.GetValueOrDefault(source.Id) : null,
+ data => data.PlaybackPositionTicks > 0);
+
if (resumeSource is not null && !video.PrimaryVersionId.HasValue && !ReferenceEquals(sources[0], resumeSource))
{
var reordered = new List(sources.Count) { resumeSource };
diff --git a/Emby.Server.Implementations/Library/UserDataManager.cs b/Emby.Server.Implementations/Library/UserDataManager.cs
index 61372f8b56..40cd2bb69c 100644
--- a/Emby.Server.Implementations/Library/UserDataManager.cs
+++ b/Emby.Server.Implementations/Library/UserDataManager.cs
@@ -329,21 +329,15 @@ namespace Emby.Server.Implementations.Library
foreach (var (primaryId, versions) in versionGroups)
{
- UserItemData? resumeData = null;
- foreach (var version in versions)
- {
- // Consider both in-progress and completed versions so a finished alternate still marks the primary as played.
- if (userDataByVersion.TryGetValue(version.Id, out var data)
- && (data.PlaybackPositionTicks > 0 || data.Played)
- && (resumeData is null || (data.LastPlayedDate ?? DateTime.MinValue) > (resumeData.LastPlayedDate ?? DateTime.MinValue)))
- {
- resumeData = data;
- }
- }
+ // Consider both in-progress and completed versions so a finished alternate still marks the primary as played.
+ var resumeVersion = VersionPlaybackSelector.SelectMostRecentlyPlayed(
+ versions,
+ version => userDataByVersion.GetValueOrDefault(version.Id),
+ data => data.PlaybackPositionTicks > 0 || data.Played);
- if (resumeData is not null)
+ if (resumeVersion is not null)
{
- result[primaryId] = new VersionResumeData(resumeData);
+ result[primaryId] = new VersionResumeData(resumeVersion.Id, userDataByVersion[resumeVersion.Id]);
}
}
diff --git a/Emby.Server.Implementations/Session/SessionManager.cs b/Emby.Server.Implementations/Session/SessionManager.cs
index 6017b7cbf6..f652634c69 100644
--- a/Emby.Server.Implementations/Session/SessionManager.cs
+++ b/Emby.Server.Implementations/Session/SessionManager.cs
@@ -980,14 +980,17 @@ namespace Emby.Server.Implementations.Session
{
_userDataManager.SaveUserData(user, item, data, UserDataSaveReason.PlaybackProgress, CancellationToken.None);
+ // A completed version marks every alternate version played and clears their resume points, so the
+ // whole movie leaves Continue Watching and reads as watched everywhere. (Per-version resume positions
+ // only persist while nothing has been completed yet.)
if (data.Played == true && item is Video playedVideo)
{
playedVideo.PropagatePlayedState(user, true);
}
}
- if ((!user.RememberAudioSelections && data.AudioStreamIndex.HasValue)
- || (!user.RememberSubtitleSelections && data.SubtitleStreamIndex.HasValue))
+ if ((!user.RememberAudioSelections && info.AudioStreamIndex.HasValue)
+ || (!user.RememberSubtitleSelections && info.SubtitleStreamIndex.HasValue))
{
_userDataManager.ResetPlaybackStreamSelections(user, item);
}
@@ -1177,7 +1180,9 @@ namespace Emby.Server.Implementations.Session
_userDataManager.SaveUserData(user, item, data, UserDataSaveReason.PlaybackFinished, CancellationToken.None);
- // A completed version marks all of its alternate versions played; positions stay per-version.
+ // A completed version marks every alternate version played and clears their resume points, so the
+ // whole movie leaves Continue Watching and reads as watched everywhere. (Per-version resume positions
+ // only persist while nothing has been completed yet.)
if (data.Played == true && item is Video playedVideo)
{
playedVideo.PropagatePlayedState(user, true);
diff --git a/Emby.Server.Implementations/TV/TVSeriesManager.cs b/Emby.Server.Implementations/TV/TVSeriesManager.cs
index 9a402a5738..459ad1a17e 100644
--- a/Emby.Server.Implementations/TV/TVSeriesManager.cs
+++ b/Emby.Server.Implementations/TV/TVSeriesManager.cs
@@ -262,19 +262,15 @@ namespace Emby.Server.Implementations.TV
return (null, null);
}
- Video? playedVersion = null;
- DateTime? lastPlayedDate = null;
- foreach (var version in lastWatchedVideo.GetAllVersions())
- {
- var data = _userDataManager.GetUserData(user, version);
- if (data?.LastPlayedDate is { } date && (lastPlayedDate is null || date > lastPlayedDate))
- {
- lastPlayedDate = date;
- playedVersion = version;
- }
- }
+ var versions = lastWatchedVideo.GetAllVersions();
+ var userDataByVersion = _userDataManager.GetUserDataBatch(versions, user);
+
+ var playedVersion = VersionPlaybackSelector.SelectMostRecentlyPlayed(
+ versions,
+ version => userDataByVersion.GetValueOrDefault(version.Id),
+ data => data.LastPlayedDate.HasValue);
- return (playedVersion, lastPlayedDate);
+ return (playedVersion, playedVersion is null ? null : userDataByVersion[playedVersion.Id].LastPlayedDate);
}
///
diff --git a/Jellyfin.Api/Controllers/ItemsController.cs b/Jellyfin.Api/Controllers/ItemsController.cs
index 5f23f2fcee..0c6477cd5b 100644
--- a/Jellyfin.Api/Controllers/ItemsController.cs
+++ b/Jellyfin.Api/Controllers/ItemsController.cs
@@ -963,9 +963,15 @@ public class ItemsController : BaseJellyfinApiController
var excludeItemIds = Array.Empty();
if (excludeActiveSessions)
{
+ // NowPlayingItem.Id is the displayed/primary id, but resume queries surface the actually-played
+ // alternate version's own id. Expand each active session to every version id so an in-progress
+ // alternate is excluded too, instead of leaking back into the resume list.
excludeItemIds = _sessionManager.Sessions
.Where(s => s.UserId.Equals(requestUserId) && s.NowPlayingItem is not null)
- .Select(s => s.NowPlayingItem.Id)
+ .SelectMany(s => _libraryManager.GetItemById(s.NowPlayingItem.Id) is Video video
+ ? video.GetAllVersions().Select(v => v.Id)
+ : [s.NowPlayingItem.Id])
+ .Distinct()
.ToArray();
}
diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs
index c234f333af..ed5c353139 100644
--- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs
+++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs
@@ -556,11 +556,15 @@ public sealed partial class BaseItemRepository
|| (e.Type != seriesTypeName && inProgressIds.Contains(e.Id)))
: baseQuery.Where(e => inProgressIds.Contains(e.Id));
- // When several versions of the same item are in progress, keep only the most recently played one.
+ // When several versions of the same item are in progress, keep only the most recently played one, use id as tiebreaker.
baseQuery = baseQuery.Where(e => e.Type == seriesTypeName || !context.BaseItems
.Where(s => s.Id != e.Id && (s.PrimaryVersionId ?? s.Id) == (e.PrimaryVersionId ?? e.Id))
- .Any(s => inProgress.Where(su => su.ItemId == s.Id).Max(su => su.LastPlayedDate)
- > inProgress.Where(eu => eu.ItemId == e.Id).Max(eu => eu.LastPlayedDate)));
+ .Any(s =>
+ inProgress.Where(su => su.ItemId == s.Id).Max(su => su.LastPlayedDate)
+ > inProgress.Where(eu => eu.ItemId == e.Id).Max(eu => eu.LastPlayedDate)
+ || (inProgress.Where(su => su.ItemId == s.Id).Max(su => su.LastPlayedDate)
+ == inProgress.Where(eu => eu.ItemId == e.Id).Max(eu => eu.LastPlayedDate)
+ && s.Id.CompareTo(e.Id) < 0)));
}
else
{
diff --git a/Jellyfin.Server.Implementations/Item/OrderMapper.cs b/Jellyfin.Server.Implementations/Item/OrderMapper.cs
index eeeeda8193..aac85d0131 100644
--- a/Jellyfin.Server.Implementations/Item/OrderMapper.cs
+++ b/Jellyfin.Server.Implementations/Item/OrderMapper.cs
@@ -38,6 +38,10 @@ public static class OrderMapper
jellyfinDbContext.UserData
.Where(w => w.UserId == query.User.Id && (w.ItemId == e.Id || w.Item!.PrimaryVersionId == e.Id))
.Max(f => f.LastPlayedDate),
+ (ItemSortBy.DatePlayed, null) => e =>
+ jellyfinDbContext.UserData
+ .Where(w => w.ItemId == e.Id || w.Item!.PrimaryVersionId == e.Id)
+ .Max(f => f.LastPlayedDate),
(ItemSortBy.PlayCount, _) => e => e.UserData!.Where(f => f.UserId.Equals(query.User!.Id)).OrderBy(f => f.CustomDataKey).FirstOrDefault()!.PlayCount,
(ItemSortBy.IsFavoriteOrLiked, _) => e => e.UserData!.Where(f => f.UserId.Equals(query.User!.Id)).OrderBy(f => f.CustomDataKey).Select(f => (bool?)f.IsFavorite).FirstOrDefault() ?? false,
(ItemSortBy.IsFolder, _) => e => e.IsFolder,
diff --git a/MediaBrowser.Controller/Library/VersionPlaybackSelector.cs b/MediaBrowser.Controller/Library/VersionPlaybackSelector.cs
new file mode 100644
index 0000000000..1766c50141
--- /dev/null
+++ b/MediaBrowser.Controller/Library/VersionPlaybackSelector.cs
@@ -0,0 +1,59 @@
+using System;
+using System.Collections.Generic;
+using MediaBrowser.Controller.Entities;
+
+namespace MediaBrowser.Controller.Library
+{
+ ///
+ /// Single definition of "which alternate version was most recently played" shared by the resume tile
+ /// ( ), the media-source default ordering and Next Up.
+ /// Each call site declares its own eligibility rule so the intentional differences (resumable-only vs.
+ /// resumable-or-completed) are visible in one place instead of being re-implemented divergently.
+ /// The SQL resume query keeps its own translation of the same rule.
+ ///
+ public static class VersionPlaybackSelector
+ {
+ ///
+ /// Selects the entry whose user data has the greatest ,
+ /// considering only entries that satisfy . On an exact tie the first
+ /// encountered entry wins.
+ ///
+ /// The candidate type (e.g. a version item or a media source).
+ /// The candidates to choose from.
+ /// Resolves the user data for a candidate, or null when it has none.
+ /// Whether a candidate's user data makes it a valid winner.
+ /// The most recently played eligible candidate, or default when none qualify.
+ public static T? SelectMostRecentlyPlayed(
+ IEnumerable items,
+ Func dataSelector,
+ Func isEligible)
+ {
+ ArgumentNullException.ThrowIfNull(items);
+ ArgumentNullException.ThrowIfNull(dataSelector);
+ ArgumentNullException.ThrowIfNull(isEligible);
+
+ T? winner = default;
+ var winnerDate = DateTime.MinValue;
+ var hasWinner = false;
+
+ foreach (var item in items)
+ {
+ var data = dataSelector(item);
+ if (data is null || !isEligible(data))
+ {
+ continue;
+ }
+
+ var date = data.LastPlayedDate ?? DateTime.MinValue;
+ if (!hasWinner || date > winnerDate)
+ {
+ winner = item;
+ winnerDate = date;
+ hasWinner = true;
+ }
+ }
+
+ return winner;
+ }
+ }
+}
diff --git a/MediaBrowser.Controller/Library/VersionResumeData.cs b/MediaBrowser.Controller/Library/VersionResumeData.cs
index 455fe739ce..772e2bf3a7 100644
--- a/MediaBrowser.Controller/Library/VersionResumeData.cs
+++ b/MediaBrowser.Controller/Library/VersionResumeData.cs
@@ -7,14 +7,17 @@ namespace MediaBrowser.Controller.Library
///
/// The user data of the most recently played alternate version that should drive the completion state of a multi-version item.
///
+ /// The id of the version that owns .
/// The resume version's user data.
- public record VersionResumeData(UserItemData UserData)
+ public record VersionResumeData(Guid VersionId, UserItemData UserData)
{
///
/// Merges the most recently played version's completion state into the supplied user data dto.
- /// Only completion (played) propagates to the primary; the in-progress resume position stays on
- /// the version that owns it, which is surfaced directly (e.g. in resume queries) so that playback
- /// always targets the correct version rather than resuming the primary at another version's offset.
+ /// Completion (played) propagates to the primary. An in-progress resume position stays on the version
+ /// that owns it, which is surfaced directly (e.g. in resume queries) so that playback always targets
+ /// the correct version rather than resuming the primary at another version's offset. When the movie was
+ /// finished on a different version, the primary's own stale resume position is cleared so it does not
+ /// render as "watched and resumable" at the same time.
///
/// The user data dto to update.
public void ApplyTo(UserItemDataDto dto)
@@ -25,6 +28,14 @@ namespace MediaBrowser.Controller.Library
{
dto.LastPlayedDate = UserData.LastPlayedDate;
}
+
+ // A different version was finished (played, no resume position of its own) and is the most
+ // recently played: the whole movie is watched.
+ if (!VersionId.Equals(dto.ItemId) && UserData.Played && UserData.PlaybackPositionTicks <= 0)
+ {
+ dto.PlaybackPositionTicks = 0;
+ dto.PlayedPercentage = null;
+ }
}
}
}
diff --git a/tests/Jellyfin.Controller.Tests/Library/VersionResumeDataTests.cs b/tests/Jellyfin.Controller.Tests/Library/VersionResumeDataTests.cs
index 8642ab07f8..7d87d5ee92 100644
--- a/tests/Jellyfin.Controller.Tests/Library/VersionResumeDataTests.cs
+++ b/tests/Jellyfin.Controller.Tests/Library/VersionResumeDataTests.cs
@@ -9,13 +9,14 @@ namespace Jellyfin.Controller.Tests.Library;
public class VersionResumeDataTests
{
[Fact]
- public void ApplyTo_PropagatesCompletionButNotPosition()
+ public void ApplyTo_CompletedOtherVersion_PropagatesCompletionAndClearsStaleResume()
{
var lastPlayed = new DateTime(2026, 1, 2, 0, 0, 0, DateTimeKind.Utc);
var resume = new VersionResumeData(
- new UserItemData { Key = "version", PlaybackPositionTicks = 25, Played = true, LastPlayedDate = lastPlayed });
+ Guid.NewGuid(),
+ new UserItemData { Key = "version", PlaybackPositionTicks = 0, Played = true, LastPlayedDate = lastPlayed });
- var dto = new UserItemDataDto { Key = "primary", PlaybackPositionTicks = 1, Played = false, PlayedPercentage = 1 };
+ var dto = new UserItemDataDto { ItemId = Guid.NewGuid(), Key = "primary", PlaybackPositionTicks = 1, Played = false, PlayedPercentage = 50 };
resume.ApplyTo(dto);
@@ -23,9 +24,48 @@ public class VersionResumeDataTests
Assert.True(dto.Played);
Assert.Equal(lastPlayed, dto.LastPlayedDate);
- // ...but the in-progress resume position stays on the version that owns it.
+ // ...and because the movie was finished on a different version, the primary's own stale resume bar is cleared.
+ Assert.Equal(0, dto.PlaybackPositionTicks);
+ Assert.Null(dto.PlayedPercentage);
+ }
+
+ [Fact]
+ public void ApplyTo_PrimaryOwnProgress_KeepsResumePosition()
+ {
+ var primaryId = Guid.NewGuid();
+ var lastPlayed = new DateTime(2026, 1, 2, 0, 0, 0, DateTimeKind.Utc);
+
+ // The winning version is the primary itself (e.g. rewatching): its resume bar must survive.
+ var resume = new VersionResumeData(
+ primaryId,
+ new UserItemData { Key = "primary", PlaybackPositionTicks = 5, Played = true, LastPlayedDate = lastPlayed });
+
+ var dto = new UserItemDataDto { ItemId = primaryId, Key = "primary", PlaybackPositionTicks = 5, Played = true, PlayedPercentage = 20 };
+
+ resume.ApplyTo(dto);
+
+ Assert.True(dto.Played);
+ Assert.Equal(5, dto.PlaybackPositionTicks);
+ Assert.Equal(20, dto.PlayedPercentage);
+ }
+
+ [Fact]
+ public void ApplyTo_InProgressOtherVersion_KeepsPrimaryResumePosition()
+ {
+ var lastPlayed = new DateTime(2026, 1, 2, 0, 0, 0, DateTimeKind.Utc);
+
+ // A different version that is in-progress (not finished) must not clear the primary's position.
+ var resume = new VersionResumeData(
+ Guid.NewGuid(),
+ new UserItemData { Key = "version", PlaybackPositionTicks = 25, Played = false, LastPlayedDate = lastPlayed });
+
+ var dto = new UserItemDataDto { ItemId = Guid.NewGuid(), Key = "primary", PlaybackPositionTicks = 1, Played = false, PlayedPercentage = 50 };
+
+ resume.ApplyTo(dto);
+
+ Assert.False(dto.Played);
Assert.Equal(1, dto.PlaybackPositionTicks);
- Assert.Equal(1.0, dto.PlayedPercentage);
+ Assert.Equal(50, dto.PlayedPercentage);
}
[Fact]
@@ -34,9 +74,10 @@ public class VersionResumeDataTests
var primaryLastPlayed = new DateTime(2026, 1, 5, 0, 0, 0, DateTimeKind.Utc);
var versionLastPlayed = new DateTime(2026, 1, 2, 0, 0, 0, DateTimeKind.Utc);
var resume = new VersionResumeData(
+ Guid.NewGuid(),
new UserItemData { Key = "version", Played = false, LastPlayedDate = versionLastPlayed });
- var dto = new UserItemDataDto { Key = "primary", Played = true, LastPlayedDate = primaryLastPlayed };
+ var dto = new UserItemDataDto { ItemId = Guid.NewGuid(), Key = "primary", Played = true, LastPlayedDate = primaryLastPlayed };
resume.ApplyTo(dto);
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/AlternateVersionQueryTranslationTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/AlternateVersionQueryTranslationTests.cs
index 1f0de153a0..c8aa14af58 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Item/AlternateVersionQueryTranslationTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/Item/AlternateVersionQueryTranslationTests.cs
@@ -61,8 +61,12 @@ public sealed class AlternateVersionQueryTranslationTests : IDisposable
.Where(e => inProgressIds.Contains(e.Id))
.Where(e => !ctx.BaseItems
.Where(s => s.Id != e.Id && (s.PrimaryVersionId ?? s.Id) == (e.PrimaryVersionId ?? e.Id))
- .Any(s => inProgress.Where(su => su.ItemId == s.Id).Max(su => su.LastPlayedDate)
- > inProgress.Where(eu => eu.ItemId == e.Id).Max(eu => eu.LastPlayedDate)))
+ .Any(s =>
+ inProgress.Where(su => su.ItemId == s.Id).Max(su => su.LastPlayedDate)
+ > inProgress.Where(eu => eu.ItemId == e.Id).Max(eu => eu.LastPlayedDate)
+ || (inProgress.Where(su => su.ItemId == s.Id).Max(su => su.LastPlayedDate)
+ == inProgress.Where(eu => eu.ItemId == e.Id).Max(eu => eu.LastPlayedDate)
+ && s.Id.CompareTo(e.Id) < 0)))
.Select(e => e.Id)
.ToList();
@@ -81,6 +85,46 @@ public sealed class AlternateVersionQueryTranslationTests : IDisposable
}
}
+ [Fact]
+ public void ResumeFilter_TiedLastPlayedDate_KeepsSingleVersion()
+ {
+ Guid userId, primaryId, versionAId, versionBId;
+
+ using (var ctx = CreateDbContext())
+ {
+ (userId, primaryId, versionAId, versionBId) = SeedTiedVersions(ctx);
+ }
+
+ using (var ctx = CreateDbContext())
+ {
+ var inProgress = ctx.UserData
+ .Where(ud => ud.UserId == userId && ud.PlaybackPositionTicks > 0);
+
+ var seededIds = new[] { primaryId, versionAId, versionBId };
+ var inProgressIds = inProgress.Select(ud => ud.ItemId);
+
+ // The exact production dedup, including the Guid.CompareTo tie-break. This asserts the
+ // expression translates on SQLite and that two versions sharing an identical LastPlayedDate
+ // collapse to a single row instead of double-listing the item in Continue Watching.
+ var resumable = ctx.BaseItems
+ .Where(e => seededIds.Contains(e.Id))
+ .Where(e => inProgressIds.Contains(e.Id))
+ .Where(e => !ctx.BaseItems
+ .Where(s => s.Id != e.Id && (s.PrimaryVersionId ?? s.Id) == (e.PrimaryVersionId ?? e.Id))
+ .Any(s =>
+ inProgress.Where(su => su.ItemId == s.Id).Max(su => su.LastPlayedDate)
+ > inProgress.Where(eu => eu.ItemId == e.Id).Max(eu => eu.LastPlayedDate)
+ || (inProgress.Where(su => su.ItemId == s.Id).Max(su => su.LastPlayedDate)
+ == inProgress.Where(eu => eu.ItemId == e.Id).Max(eu => eu.LastPlayedDate)
+ && s.Id.CompareTo(e.Id) < 0)))
+ .Select(e => e.Id)
+ .ToList();
+
+ var survivor = Assert.Single(resumable);
+ Assert.Contains(survivor, new[] { versionAId, versionBId });
+ }
+ }
+
[Fact]
public void DatePlayedOrdering_VersionProgress_SortsPrimaryByVersionDate()
{
@@ -136,6 +180,43 @@ public sealed class AlternateVersionQueryTranslationTests : IDisposable
return (user.Id, primary.Id, version.Id, other.Id);
}
+ private static (Guid UserId, Guid PrimaryId, Guid VersionAId, Guid VersionBId) SeedTiedVersions(JellyfinDbContext ctx)
+ {
+ var user = new User("test", "auth-provider", "reset-provider");
+ ctx.Users.Add(user);
+
+ var primary = new BaseItemEntity { Id = Guid.NewGuid(), Type = "MediaBrowser.Controller.Entities.Movies.Movie" };
+ var versionA = new BaseItemEntity { Id = Guid.NewGuid(), Type = "MediaBrowser.Controller.Entities.Movies.Movie", PrimaryVersionId = primary.Id };
+ var versionB = new BaseItemEntity { Id = Guid.NewGuid(), Type = "MediaBrowser.Controller.Entities.Movies.Movie", PrimaryVersionId = primary.Id };
+ ctx.BaseItems.AddRange(primary, versionA, versionB);
+
+ // Both versions in progress with the exact same LastPlayedDate - the tie that a strict '>' cannot break.
+ var tied = new DateTime(2026, 1, 2, 0, 0, 0, DateTimeKind.Utc);
+ ctx.UserData.Add(new UserData
+ {
+ ItemId = versionA.Id,
+ Item = versionA,
+ UserId = user.Id,
+ User = user,
+ CustomDataKey = versionA.Id.ToString("N"),
+ PlaybackPositionTicks = 1000,
+ LastPlayedDate = tied
+ });
+ ctx.UserData.Add(new UserData
+ {
+ ItemId = versionB.Id,
+ Item = versionB,
+ UserId = user.Id,
+ User = user,
+ CustomDataKey = versionB.Id.ToString("N"),
+ PlaybackPositionTicks = 2000,
+ LastPlayedDate = tied
+ });
+
+ ctx.SaveChanges();
+ return (user.Id, primary.Id, versionA.Id, versionB.Id);
+ }
+
private JellyfinDbContext CreateDbContext()
{
return new JellyfinDbContext(
--
cgit v1.2.3
From 43a152359ebcc6168a1d1d9d21174f14c6b9bd9b Mon Sep 17 00:00:00 2001
From: theguymadmax
Date: Fri, 3 Jul 2026 11:46:16 -0400
Subject: Fix ghost entries when deleting library paths
---
Emby.Server.Implementations/IO/ManagedFileSystem.cs | 2 +-
.../Library/Resolvers/PlaylistResolver.cs | 12 +++++++++++-
MediaBrowser.Controller/Entities/Folder.cs | 17 +++++++++++++----
3 files changed, 25 insertions(+), 6 deletions(-)
(limited to 'MediaBrowser.Controller')
diff --git a/Emby.Server.Implementations/IO/ManagedFileSystem.cs b/Emby.Server.Implementations/IO/ManagedFileSystem.cs
index 199407044b..ede9b27592 100644
--- a/Emby.Server.Implementations/IO/ManagedFileSystem.cs
+++ b/Emby.Server.Implementations/IO/ManagedFileSystem.cs
@@ -691,7 +691,7 @@ namespace Emby.Server.Implementations.IO
}
catch (Exception ex) when (ex is UnauthorizedAccessException or DirectoryNotFoundException or SecurityException)
{
- _logger.LogError(ex, "Failed to enumerate path {Path}", path);
+ _logger.LogWarning("Failed to enumerate path \"{Path}\": {Message}", path, ex.Message);
return Enumerable.Empty();
}
}
diff --git a/Emby.Server.Implementations/Library/Resolvers/PlaylistResolver.cs b/Emby.Server.Implementations/Library/Resolvers/PlaylistResolver.cs
index 14798dda65..74c1f69616 100644
--- a/Emby.Server.Implementations/Library/Resolvers/PlaylistResolver.cs
+++ b/Emby.Server.Implementations/Library/Resolvers/PlaylistResolver.cs
@@ -1,6 +1,7 @@
#nullable disable
using System;
+using System.Collections.Generic;
using System.IO;
using System.Linq;
using Jellyfin.Data.Enums;
@@ -46,7 +47,16 @@ namespace Emby.Server.Implementations.Library.Resolvers
}
// It's a directory-based playlist if the directory contains a playlist file
- var filePaths = Directory.EnumerateFiles(args.Path, "*", new EnumerationOptions { IgnoreInaccessible = true });
+ IEnumerable filePaths;
+ try
+ {
+ filePaths = Directory.EnumerateFiles(args.Path, "*", new EnumerationOptions { IgnoreInaccessible = true });
+ }
+ catch (IOException)
+ {
+ return null;
+ }
+
if (filePaths.Any(f => f.EndsWith(PlaylistXmlSaver.DefaultPlaylistFilename, StringComparison.OrdinalIgnoreCase)))
{
return new Playlist
diff --git a/MediaBrowser.Controller/Entities/Folder.cs b/MediaBrowser.Controller/Entities/Folder.cs
index 25cbcedc5f..b1f7f29bad 100644
--- a/MediaBrowser.Controller/Entities/Folder.cs
+++ b/MediaBrowser.Controller/Entities/Folder.cs
@@ -384,6 +384,7 @@ namespace MediaBrowser.Controller.Entities
cancellationToken.ThrowIfCancellationRequested();
var validChildren = new List();
+ var accessibleChildren = new List();
var validChildrenNeedGeneration = false;
if (IsFileProtocol)
@@ -438,12 +439,19 @@ namespace MediaBrowser.Controller.Entities
{
if (!IsLibraryFolderAccessible(directoryService, child, allowRemoveRoot))
{
+ // Preserve inaccessible items so they aren't treated as removed.
+ if (currentChildren.TryGetValue(child.Id, out var childrenToKeep))
+ {
+ validChildren.Add(childrenToKeep);
+ }
+
continue;
}
if (currentChildren.TryGetValue(child.Id, out BaseItem currentChild))
{
validChildren.Add(currentChild);
+ accessibleChildren.Add(currentChild);
if (currentChild.UpdateFromResolvedItem(child) > ItemUpdateType.None)
{
@@ -480,11 +488,12 @@ namespace MediaBrowser.Controller.Entities
child.SetParent(this);
newItems.Add(child);
validChildren.Add(child);
+ accessibleChildren.Add(child);
}
// That's all the new and changed ones - now see if any have been removed and need cleanup
var itemsRemoved = currentChildren.Values.Except(validChildren).ToList();
- var shouldRemove = !IsRoot || allowRemoveRoot;
+
// If it's an AggregateFolder, don't remove
// Collect replaced primaries for deferred deletion (after CreateItems)
var replacedPrimaries = new List<(Video OldPrimary, Video NewPrimary)>();
@@ -497,7 +506,7 @@ namespace MediaBrowser.Controller.Entities
.Where(p => !string.IsNullOrEmpty(p))
.ToHashSet(StringComparer.OrdinalIgnoreCase);
- if (shouldRemove && itemsRemoved.Count > 0)
+ if (itemsRemoved.Count > 0)
{
foreach (var item in itemsRemoved)
{
@@ -703,7 +712,7 @@ namespace MediaBrowser.Controller.Entities
validChildrenNeedGeneration = false;
}
- await ValidateSubFolders(validChildren.OfType().ToList(), directoryService, innerProgress, cancellationToken).ConfigureAwait(false);
+ await ValidateSubFolders(accessibleChildren.OfType().ToList(), directoryService, innerProgress, cancellationToken).ConfigureAwait(false);
}
if (refreshChildMetadata)
@@ -742,7 +751,7 @@ namespace MediaBrowser.Controller.Entities
validChildren = Children.ToList();
}
- await RefreshMetadataRecursive(validChildren, refreshOptions, recursive, innerProgress, cancellationToken).ConfigureAwait(false);
+ await RefreshMetadataRecursive(accessibleChildren, refreshOptions, recursive, innerProgress, cancellationToken).ConfigureAwait(false);
}
}
}
--
cgit v1.2.3
From 6883cd0969e40ad380d20b5b338c681767bc73f1 Mon Sep 17 00:00:00 2001
From: Enea D'Angiò
Date: Fri, 3 Jul 2026 11:14:11 +0200
Subject: Fix play queue index handling in SyncPlay
Three index bugs in PlayQueueManager, two of which leave
PlayingItemIndex out of bounds, making every subsequent Buffering/Ready
request throw and leaving the group unusable until it empties:
- RemoveFromPlaylist did not compensate for removed items preceding the
playing item: removing the playing item together with earlier items
could select the wrong item or crash with an out-of-bounds index.
- Next/Previous on an empty playlist with RepeatOne/RepeatAll reported
success or set PlayingItemIndex to 0 on an empty list, crashing
downstream in Group and corrupting the index.
- SetPlayingItemByIndex accepted an index equal to the playlist count
(latent off-by-one, callers currently pre-validate).
---
.../SyncPlay/Queue/PlayQueueManager.cs | 30 +++-
.../SyncPlay/PlayQueueManagerTests.cs | 156 +++++++++++++++++++++
2 files changed, 183 insertions(+), 3 deletions(-)
create mode 100644 tests/Jellyfin.Server.Implementations.Tests/SyncPlay/PlayQueueManagerTests.cs
(limited to 'MediaBrowser.Controller')
diff --git a/MediaBrowser.Controller/SyncPlay/Queue/PlayQueueManager.cs b/MediaBrowser.Controller/SyncPlay/Queue/PlayQueueManager.cs
index c0a168192e..f019a368a6 100644
--- a/MediaBrowser.Controller/SyncPlay/Queue/PlayQueueManager.cs
+++ b/MediaBrowser.Controller/SyncPlay/Queue/PlayQueueManager.cs
@@ -272,7 +272,7 @@ namespace MediaBrowser.Controller.SyncPlay.Queue
public void SetPlayingItemByIndex(int playlistIndex)
{
var playlist = GetPlaylistInternal();
- if (playlistIndex < 0 || playlistIndex > playlist.Count)
+ if (playlistIndex < 0 || playlistIndex >= playlist.Count)
{
PlayingItemIndex = NoPlayingItemIndex;
}
@@ -293,6 +293,20 @@ namespace MediaBrowser.Controller.SyncPlay.Queue
{
var playingItem = GetPlayingItem();
+ // Removed items that precede the playing item shift its index as well.
+ var removedBeforePlayingItem = 0;
+ if (playingItem is not null)
+ {
+ var playlist = GetPlaylistInternal();
+ for (var index = 0; index < PlayingItemIndex; index++)
+ {
+ if (playlistItemIds.Contains(playlist[index].PlaylistItemId))
+ {
+ removedBeforePlayingItem++;
+ }
+ }
+ }
+
_sortedPlaylist.RemoveAll(item => playlistItemIds.Contains(item.PlaylistItemId));
_shuffledPlaylist.RemoveAll(item => playlistItemIds.Contains(item.PlaylistItemId));
@@ -303,12 +317,12 @@ namespace MediaBrowser.Controller.SyncPlay.Queue
if (playlistItemIds.Contains(playingItem.PlaylistItemId))
{
// Playing item has been removed, picking previous item.
- PlayingItemIndex--;
+ PlayingItemIndex -= removedBeforePlayingItem + 1;
if (PlayingItemIndex < 0)
{
// Was first element, picking next if available.
// Default to no playing item otherwise.
- PlayingItemIndex = _sortedPlaylist.Count > 0 ? 0 : NoPlayingItemIndex;
+ PlayingItemIndex = GetPlaylistInternal().Count > 0 ? 0 : NoPlayingItemIndex;
}
return true;
@@ -444,6 +458,11 @@ namespace MediaBrowser.Controller.SyncPlay.Queue
/// true if the playing item changed; false otherwise.
public bool Next()
{
+ if (GetPlaylistInternal().Count == 0)
+ {
+ return false;
+ }
+
if (RepeatMode.Equals(GroupRepeatMode.RepeatOne))
{
LastChange = DateTime.UtcNow;
@@ -474,6 +493,11 @@ namespace MediaBrowser.Controller.SyncPlay.Queue
/// true if the playing item changed; false otherwise.
public bool Previous()
{
+ if (GetPlaylistInternal().Count == 0)
+ {
+ return false;
+ }
+
if (RepeatMode.Equals(GroupRepeatMode.RepeatOne))
{
LastChange = DateTime.UtcNow;
diff --git a/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/PlayQueueManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/PlayQueueManagerTests.cs
new file mode 100644
index 0000000000..32685556b2
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/PlayQueueManagerTests.cs
@@ -0,0 +1,156 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using MediaBrowser.Controller.SyncPlay.Queue;
+using MediaBrowser.Model.SyncPlay;
+using Xunit;
+
+namespace Jellyfin.Server.Implementations.Tests.SyncPlay;
+
+public class PlayQueueManagerTests
+{
+ private static PlayQueueManager CreateQueue(int itemCount)
+ {
+ var items = Enumerable.Range(0, itemCount).Select(_ => Guid.NewGuid()).ToList();
+ var queue = new PlayQueueManager();
+ queue.SetPlaylist(items);
+ return queue;
+ }
+
+ [Fact]
+ public void RemoveFromPlaylist_PlayingItemAndPrecedingItemRemoved_PicksPreviousItem()
+ {
+ var queue = CreateQueue(5);
+ queue.SetPlayingItemByIndex(3);
+
+ var playlist = queue.GetPlaylist();
+ var expectedItemId = playlist[2].ItemId;
+ var toRemove = new List { playlist[0].PlaylistItemId, playlist[3].PlaylistItemId };
+ var playingItemRemoved = queue.RemoveFromPlaylist(toRemove);
+
+ Assert.True(playingItemRemoved);
+ Assert.Equal(3, queue.GetPlaylist().Count);
+ Assert.Equal(1, queue.PlayingItemIndex);
+ Assert.Equal(expectedItemId, queue.GetPlayingItemId());
+ }
+
+ [Fact]
+ public void RemoveFromPlaylist_PlayingItemAndAllPrecedingItemsRemoved_PicksFirstRemainingItem()
+ {
+ var queue = CreateQueue(3);
+ queue.SetPlayingItemByIndex(2);
+
+ var playlist = queue.GetPlaylist();
+ var expectedItemId = playlist[1].ItemId;
+ var toRemove = new List { playlist[0].PlaylistItemId, playlist[2].PlaylistItemId };
+ var playingItemRemoved = queue.RemoveFromPlaylist(toRemove);
+
+ Assert.True(playingItemRemoved);
+ Assert.Single(queue.GetPlaylist());
+ Assert.Equal(0, queue.PlayingItemIndex);
+ Assert.Equal(expectedItemId, queue.GetPlayingItemId());
+ }
+
+ [Fact]
+ public void RemoveFromPlaylist_AllItemsRemoved_ResetsPlayingItem()
+ {
+ var queue = CreateQueue(2);
+ queue.SetPlayingItemByIndex(1);
+
+ var toRemove = queue.GetPlaylist().Select(item => item.PlaylistItemId).ToList();
+ var playingItemRemoved = queue.RemoveFromPlaylist(toRemove);
+
+ Assert.True(playingItemRemoved);
+ Assert.Empty(queue.GetPlaylist());
+ Assert.False(queue.IsItemPlaying());
+ Assert.Equal(Guid.Empty, queue.GetPlayingItemPlaylistId());
+ }
+
+ [Fact]
+ public void RemoveFromPlaylist_ShuffleMode_PicksPreviousItem()
+ {
+ var queue = CreateQueue(5);
+ queue.SetShuffleMode(GroupShuffleMode.Shuffle);
+ queue.SetPlayingItemByIndex(3);
+
+ var playlist = queue.GetPlaylist();
+ var expectedItemId = playlist[2].ItemId;
+ var toRemove = new List { playlist[0].PlaylistItemId, playlist[3].PlaylistItemId };
+ var playingItemRemoved = queue.RemoveFromPlaylist(toRemove);
+
+ Assert.True(playingItemRemoved);
+ Assert.Equal(3, queue.GetPlaylist().Count);
+ Assert.Equal(1, queue.PlayingItemIndex);
+ Assert.Equal(expectedItemId, queue.GetPlayingItemId());
+ }
+
+ [Fact]
+ public void RemoveFromPlaylist_PlayingItemNotRemoved_RestoresPlayingItem()
+ {
+ var queue = CreateQueue(3);
+ queue.SetPlayingItemByIndex(2);
+
+ var playlist = queue.GetPlaylist();
+ var expectedItemId = playlist[2].ItemId;
+ var toRemove = new List { playlist[0].PlaylistItemId };
+ var playingItemRemoved = queue.RemoveFromPlaylist(toRemove);
+
+ Assert.False(playingItemRemoved);
+ Assert.Equal(1, queue.PlayingItemIndex);
+ Assert.Equal(expectedItemId, queue.GetPlayingItemId());
+ }
+
+ [Theory]
+ [InlineData(GroupRepeatMode.RepeatNone)]
+ [InlineData(GroupRepeatMode.RepeatOne)]
+ [InlineData(GroupRepeatMode.RepeatAll)]
+ public void Next_EmptyPlaylist_ReturnsFalse(GroupRepeatMode repeatMode)
+ {
+ var queue = new PlayQueueManager();
+ queue.SetRepeatMode(repeatMode);
+
+ Assert.False(queue.Next());
+ Assert.False(queue.IsItemPlaying());
+ Assert.Equal(Guid.Empty, queue.GetPlayingItemPlaylistId());
+ }
+
+ [Theory]
+ [InlineData(GroupRepeatMode.RepeatNone)]
+ [InlineData(GroupRepeatMode.RepeatOne)]
+ [InlineData(GroupRepeatMode.RepeatAll)]
+ public void Previous_EmptyPlaylist_ReturnsFalse(GroupRepeatMode repeatMode)
+ {
+ var queue = new PlayQueueManager();
+ queue.SetRepeatMode(repeatMode);
+
+ Assert.False(queue.Previous());
+ Assert.False(queue.IsItemPlaying());
+ Assert.Equal(Guid.Empty, queue.GetPlayingItemPlaylistId());
+ }
+
+ [Theory]
+ [InlineData(-1)]
+ [InlineData(2)]
+ [InlineData(3)]
+ public void SetPlayingItemByIndex_OutOfBounds_ResetsPlayingItem(int playlistIndex)
+ {
+ var queue = CreateQueue(2);
+
+ queue.SetPlayingItemByIndex(playlistIndex);
+
+ Assert.False(queue.IsItemPlaying());
+ Assert.Equal(Guid.Empty, queue.GetPlayingItemPlaylistId());
+ }
+
+ [Fact]
+ public void SetPlayingItemByIndex_InBounds_SetsPlayingItem()
+ {
+ var queue = CreateQueue(2);
+ var expectedItemId = queue.GetPlaylist()[1].ItemId;
+
+ queue.SetPlayingItemByIndex(1);
+
+ Assert.True(queue.IsItemPlaying());
+ Assert.Equal(expectedItemId, queue.GetPlayingItemId());
+ }
+}
--
cgit v1.2.3
From 9a2fdb35734517d6f5e957538f965c211043c339 Mon Sep 17 00:00:00 2001
From: Shadowghost
Date: Wed, 8 Jul 2026 13:30:36 +0200
Subject: Fix additional parts for non-admins
---
MediaBrowser.Controller/Entities/Video.cs | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
(limited to 'MediaBrowser.Controller')
diff --git a/MediaBrowser.Controller/Entities/Video.cs b/MediaBrowser.Controller/Entities/Video.cs
index 34929e0591..0606fe1870 100644
--- a/MediaBrowser.Controller/Entities/Video.cs
+++ b/MediaBrowser.Controller/Entities/Video.cs
@@ -492,8 +492,8 @@ namespace MediaBrowser.Controller.Entities
public IOrderedEnumerable GetAdditionalParts(User user = null)
{
return GetAdditionalPartIds()
- .Select(i => LibraryManager.GetItemById(i, user))
- .Where(i => i is not null)
+ .Select(i => LibraryManager.GetItemById(i))
+ .Where(i => i is not null && (user is null || i.IsVisible(user)))
.OrderBy(i => i.SortName);
}
--
cgit v1.2.3
From 38813f7d4288813ad8e7582a87d14daa6a129852 Mon Sep 17 00:00:00 2001
From: Shadowghost
Date: Thu, 9 Jul 2026 12:07:13 +0200
Subject: Cleanup PreferEpisodeParentPoster)
---
Emby.Server.Implementations/Dto/DtoService.cs | 2 +-
Jellyfin.Api/Controllers/UserLibraryController.cs | 2 --
MediaBrowser.Controller/Dto/DtoOptions.cs | 7 ------
.../Dto/DtoServiceTests.cs | 26 ++++++++++------------
4 files changed, 13 insertions(+), 24 deletions(-)
(limited to 'MediaBrowser.Controller')
diff --git a/Emby.Server.Implementations/Dto/DtoService.cs b/Emby.Server.Implementations/Dto/DtoService.cs
index c3a9253f29..8cbf42585d 100644
--- a/Emby.Server.Implementations/Dto/DtoService.cs
+++ b/Emby.Server.Implementations/Dto/DtoService.cs
@@ -1385,7 +1385,7 @@ namespace Emby.Server.Implementations.Dto
}
}
- if (options.PreferEpisodeParentPoster)
+ if (options.GetImageLimit(ImageType.Primary) > 0)
{
var episodeSeason = episode.Season;
var seasonPrimaryTag = episodeSeason is not null
diff --git a/Jellyfin.Api/Controllers/UserLibraryController.cs b/Jellyfin.Api/Controllers/UserLibraryController.cs
index 25f781e496..a718035528 100644
--- a/Jellyfin.Api/Controllers/UserLibraryController.cs
+++ b/Jellyfin.Api/Controllers/UserLibraryController.cs
@@ -551,8 +551,6 @@ public class UserLibraryController : BaseJellyfinApiController
var dtoOptions = new DtoOptions { Fields = fields }
.AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
- dtoOptions.PreferEpisodeParentPoster = true;
-
var list = _userViewManager.GetLatestItems(
new LatestItemsQuery
{
diff --git a/MediaBrowser.Controller/Dto/DtoOptions.cs b/MediaBrowser.Controller/Dto/DtoOptions.cs
index d319feb6b2..052626355f 100644
--- a/MediaBrowser.Controller/Dto/DtoOptions.cs
+++ b/MediaBrowser.Controller/Dto/DtoOptions.cs
@@ -81,13 +81,6 @@ namespace MediaBrowser.Controller.Dto
///
public bool AddCurrentProgram { get; set; }
- ///
- /// Gets or sets a value indicating whether an episode's portrait poster (its season's primary
- /// image, falling back to the series') should replace the episode's own (16:9) primary image.
- /// Used by views that render episodes as poster cards, e.g. "Latest".
- ///
- public bool PreferEpisodeParentPoster { get; set; }
-
///
/// Gets a value indicating whether the specified field is populated.
///
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceTests.cs
index 4e5790012f..9c247d54b9 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceTests.cs
@@ -57,14 +57,10 @@ public class DtoServiceTests
}
[Fact]
- public void GetBaseItemDto_PreferEpisodeParentPoster_AttachesSeasonPosterWithoutDroppingEpisodeImage()
+ public void GetBaseItemDto_Episode_AttachesSeasonPosterAsParentPrimaryImage()
{
var (episode, season, _) = BuildEpisode(seasonHasPoster: true);
- var options = new DtoOptions(false)
- {
- PreferEpisodeParentPoster = true,
- Fields = [ItemFields.PrimaryImageAspectRatio]
- };
+ var options = new DtoOptions(false) { Fields = [ItemFields.PrimaryImageAspectRatio] };
var dto = _dtoService.GetBaseItemDto(episode, options);
@@ -80,10 +76,10 @@ public class DtoServiceTests
}
[Fact]
- public void GetBaseItemDto_PreferEpisodeParentPoster_FallsBackToSeriesWhenSeasonHasNoPoster()
+ public void GetBaseItemDto_Episode_ParentPrimaryImageFallsBackToSeriesWhenSeasonHasNoPoster()
{
var (episode, _, series) = BuildEpisode(seasonHasPoster: false);
- var options = new DtoOptions(false) { PreferEpisodeParentPoster = true };
+ var options = new DtoOptions(false);
var dto = _dtoService.GetBaseItemDto(episode, options);
@@ -96,26 +92,28 @@ public class DtoServiceTests
}
[Fact]
- public void GetBaseItemDto_WithoutPreferEpisodeParentPoster_KeepsEpisodePrimary()
+ public void GetBaseItemDto_Episode_WithoutParentPosters_KeepsOnlyEpisodePrimary()
{
- var (episode, _, _) = BuildEpisode(seasonHasPoster: true);
+ var (episode, _, _) = BuildEpisode(seasonHasPoster: false, seriesHasPoster: false);
var options = new DtoOptions(false);
var dto = _dtoService.GetBaseItemDto(episode, options);
- // Default behavior: the episode keeps its own primary and exposes the series poster as a tag.
+ // With no season or series poster there is nothing to attach; the episode keeps its own primary.
Assert.NotNull(dto.ImageTags);
Assert.True(dto.ImageTags.ContainsKey(ImageType.Primary));
- Assert.NotNull(dto.SeriesPrimaryImageTag);
Assert.Null(dto.ParentPrimaryImageItemId);
}
- private (Episode Episode, Season Season, Series Series) BuildEpisode(bool seasonHasPoster)
+ private (Episode Episode, Season Season, Series Series) BuildEpisode(bool seasonHasPoster, bool seriesHasPoster = true)
{
// Non-local (http) paths keep aspect-ratio resolution off the image processor and on the
// item's default ratio, which is portrait (2/3) for Season/Series and 16:9 for Episode.
var series = new Series { Id = Guid.NewGuid(), Name = "Series" };
- series.SetImage(new ItemImageInfo { Type = ImageType.Primary, Path = "http://test/series.jpg" }, 0);
+ if (seriesHasPoster)
+ {
+ series.SetImage(new ItemImageInfo { Type = ImageType.Primary, Path = "http://test/series.jpg" }, 0);
+ }
var season = new Season { Id = Guid.NewGuid(), Name = "Season", SeriesId = series.Id };
if (seasonHasPoster)
--
cgit v1.2.3
From 9e996d612c3ee6779db8802a4c3eb3bcd09fe9c9 Mon Sep 17 00:00:00 2001
From: dkanada
Date: Sun, 12 Jul 2026 19:46:56 +0900
Subject: extract page count from archives and PDFs
---
Directory.Packages.props | 1 +
MediaBrowser.Controller/Entities/Book.cs | 5 --
MediaBrowser.Providers/Manager/MetadataService.cs | 2 +-
.../MediaBrowser.Providers.csproj | 1 +
MediaBrowser.Providers/MediaInfo/ProbeProvider.cs | 54 ++++++++++++++++++++++
5 files changed, 57 insertions(+), 6 deletions(-)
(limited to 'MediaBrowser.Controller')
diff --git a/Directory.Packages.props b/Directory.Packages.props
index f0a655d488..17add2f9c9 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -53,6 +53,7 @@
+
diff --git a/MediaBrowser.Controller/Entities/Book.cs b/MediaBrowser.Controller/Entities/Book.cs
index 5187669373..8559681bdc 100644
--- a/MediaBrowser.Controller/Entities/Book.cs
+++ b/MediaBrowser.Controller/Entities/Book.cs
@@ -13,11 +13,6 @@ namespace MediaBrowser.Controller.Entities
[Common.RequiresSourceSerialisation]
public class Book : BaseItem, IHasLookupInfo, IHasSeries
{
- public Book()
- {
- this.RunTimeTicks = TimeSpan.TicksPerSecond;
- }
-
[JsonIgnore]
public override MediaType MediaType => MediaType.Book;
diff --git a/MediaBrowser.Providers/Manager/MetadataService.cs b/MediaBrowser.Providers/Manager/MetadataService.cs
index 118ccf8679..62827c07b9 100644
--- a/MediaBrowser.Providers/Manager/MetadataService.cs
+++ b/MediaBrowser.Providers/Manager/MetadataService.cs
@@ -1116,7 +1116,7 @@ namespace MediaBrowser.Providers.Manager
{
if (replaceData || !target.RunTimeTicks.HasValue)
{
- if (target is not Audio && target is not Video)
+ if (target is not Audio && target is not Video && target is not Book)
{
target.RunTimeTicks = source.RunTimeTicks;
}
diff --git a/MediaBrowser.Providers/MediaBrowser.Providers.csproj b/MediaBrowser.Providers/MediaBrowser.Providers.csproj
index df51dd8421..2b0f480b1c 100644
--- a/MediaBrowser.Providers/MediaBrowser.Providers.csproj
+++ b/MediaBrowser.Providers/MediaBrowser.Providers.csproj
@@ -21,6 +21,7 @@
+
diff --git a/MediaBrowser.Providers/MediaInfo/ProbeProvider.cs b/MediaBrowser.Providers/MediaInfo/ProbeProvider.cs
index 789df8f061..221c6bff5e 100644
--- a/MediaBrowser.Providers/MediaInfo/ProbeProvider.cs
+++ b/MediaBrowser.Providers/MediaInfo/ProbeProvider.cs
@@ -24,6 +24,8 @@ using MediaBrowser.Model.Globalization;
using MediaBrowser.Model.IO;
using MediaBrowser.Model.MediaInfo;
using Microsoft.Extensions.Logging;
+using PDFtoImage;
+using SharpCompress.Archives;
namespace MediaBrowser.Providers.MediaInfo
{
@@ -37,6 +39,7 @@ namespace MediaBrowser.Providers.MediaInfo
ICustomMetadataProvider,
ICustomMetadataProvider,
ICustomMetadataProvider,
+ ICustomMetadataProvider,
IHasOrder,
IForcedProvider,
IPreRefreshProvider,
@@ -214,6 +217,57 @@ namespace MediaBrowser.Providers.MediaInfo
return FetchAudioInfo(item, options, cancellationToken);
}
+ ///
+ public Task FetchAsync(Book item, MetadataRefreshOptions options, CancellationToken cancellationToken)
+ {
+ if (item.IsVirtualItem || !item.IsFileProtocol)
+ {
+ return _cachedTask;
+ }
+
+ long pageCount;
+ switch (Path.GetExtension(item.Path).ToLowerInvariant())
+ {
+ case ".cb7":
+ case ".cbr":
+ case ".cbt":
+ case ".cbz":
+ using (var stream = File.OpenRead(item.Path))
+ using (var archive = ArchiveFactory.OpenArchive(stream))
+ {
+ pageCount = archive.Entries.Count(e => !e.IsDirectory);
+ }
+
+ break;
+
+#pragma warning disable CA1416
+ case ".pdf":
+ using (var stream = File.OpenRead(item.Path))
+ {
+ pageCount = Conversion.GetPageCount(stream);
+ }
+
+ break;
+#pragma warning restore CA1416
+
+ case ".epub":
+ // TODO process CFI and store as a string when multiple progress types are supported
+ // current progress value is percentage stored as a proportion of one second worth of ticks
+ item.RunTimeTicks = TimeSpan.TicksPerSecond;
+
+ return Task.FromResult(ItemUpdateType.MetadataImport);
+
+ default:
+ return _cachedTask;
+ }
+
+ // TODO use page count without modification when multiple progress types are supported
+ // book players report page count and the web client multiplies that value by 10000 to convert the expected milliseconds into ticks
+ item.RunTimeTicks = pageCount * 10000;
+
+ return Task.FromResult(ItemUpdateType.MetadataImport);
+ }
+
///
/// Fetches video information for an item.
///
--
cgit v1.2.3
From 4503ad295ca69de46c38fbdbebd2a61cc9961aa9 Mon Sep 17 00:00:00 2001
From: nyanmisaka
Date: Wed, 15 Jul 2026 13:47:48 +0800
Subject: Fix format negotiation in hybrid SW decode and CUDA tonemap pipeline
The CUDA hwcontext in FFmpeg 8.1 has added support for 10bit
fully-planar formats, but few CUDA filters support them.
Signed-off-by: nyanmisaka
---
MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
(limited to 'MediaBrowser.Controller')
diff --git a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs
index 847f4cf187..1b0bbe9ea0 100644
--- a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs
+++ b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs
@@ -4054,7 +4054,7 @@ namespace MediaBrowser.Controller.MediaEncoding
mainFilters.Add(swDeintFilter);
}
- var outFormat = doCuTonemap ? "yuv420p10le" : "yuv420p";
+ var outFormat = doCuTonemap ? "p010le" : "yuv420p";
var swScaleFilter = GetSwScaleFilter(state, options, vidEncoder, swpInW, swpInH, threeDFormat, reqW, reqH, reqMaxW, reqMaxH);
// sw scale
mainFilters.Add(swScaleFilter);
--
cgit v1.2.3
From 30f28456de92304825aa9460dda2c12021ba5f6b Mon Sep 17 00:00:00 2001
From: mbastian77
Date: Wed, 15 Jul 2026 12:08:08 +0200
Subject: Add XML docs to lookup info types and remove CS1591 suppressions
---
MediaBrowser.Controller/Providers/BookInfo.cs | 8 ++++++--
MediaBrowser.Controller/Providers/BoxSetInfo.cs | 5 +++--
MediaBrowser.Controller/Providers/IHasLookupInfo.cs | 10 ++++++++--
MediaBrowser.Controller/Providers/IPreRefreshProvider.cs | 5 +++--
MediaBrowser.Controller/Providers/MovieInfo.cs | 5 +++--
MediaBrowser.Controller/Providers/PersonLookupInfo.cs | 5 +++--
MediaBrowser.Controller/Providers/SeriesInfo.cs | 5 +++--
MediaBrowser.Controller/Providers/TrailerInfo.cs | 5 +++--
8 files changed, 32 insertions(+), 16 deletions(-)
(limited to 'MediaBrowser.Controller')
diff --git a/MediaBrowser.Controller/Providers/BookInfo.cs b/MediaBrowser.Controller/Providers/BookInfo.cs
index 3055c5d871..7f8151e534 100644
--- a/MediaBrowser.Controller/Providers/BookInfo.cs
+++ b/MediaBrowser.Controller/Providers/BookInfo.cs
@@ -1,11 +1,15 @@
#nullable disable
-#pragma warning disable CS1591
-
namespace MediaBrowser.Controller.Providers
{
+ ///
+ /// The lookup info for books.
+ ///
public class BookInfo : ItemLookupInfo
{
+ ///
+ /// Gets or sets the name of the series the book belongs to.
+ ///
public string SeriesName { get; set; }
}
}
diff --git a/MediaBrowser.Controller/Providers/BoxSetInfo.cs b/MediaBrowser.Controller/Providers/BoxSetInfo.cs
index f43ea67178..22dbdb959e 100644
--- a/MediaBrowser.Controller/Providers/BoxSetInfo.cs
+++ b/MediaBrowser.Controller/Providers/BoxSetInfo.cs
@@ -1,7 +1,8 @@
-#pragma warning disable CS1591
-
namespace MediaBrowser.Controller.Providers
{
+ ///
+ /// The lookup info for box sets.
+ ///
public class BoxSetInfo : ItemLookupInfo
{
}
diff --git a/MediaBrowser.Controller/Providers/IHasLookupInfo.cs b/MediaBrowser.Controller/Providers/IHasLookupInfo.cs
index 42cb523713..834e173ca3 100644
--- a/MediaBrowser.Controller/Providers/IHasLookupInfo.cs
+++ b/MediaBrowser.Controller/Providers/IHasLookupInfo.cs
@@ -1,10 +1,16 @@
-#pragma warning disable CS1591
-
namespace MediaBrowser.Controller.Providers
{
+ ///
+ /// Interface for items that provide lookup info for metadata providers.
+ ///
+ /// The type of the lookup info.
public interface IHasLookupInfo
where TLookupInfoType : ItemLookupInfo, new()
{
+ ///
+ /// Gets the lookup info.
+ ///
+ /// The lookup info.
TLookupInfoType GetLookupInfo();
}
}
diff --git a/MediaBrowser.Controller/Providers/IPreRefreshProvider.cs b/MediaBrowser.Controller/Providers/IPreRefreshProvider.cs
index 6d98af33e4..668160759f 100644
--- a/MediaBrowser.Controller/Providers/IPreRefreshProvider.cs
+++ b/MediaBrowser.Controller/Providers/IPreRefreshProvider.cs
@@ -1,7 +1,8 @@
-#pragma warning disable CS1591
-
namespace MediaBrowser.Controller.Providers
{
+ ///
+ /// Marker interface for custom metadata providers that run before the regular metadata refresh.
+ ///
public interface IPreRefreshProvider : ICustomMetadataProvider
{
}
diff --git a/MediaBrowser.Controller/Providers/MovieInfo.cs b/MediaBrowser.Controller/Providers/MovieInfo.cs
index 20e6b697ad..a33f8bbfe2 100644
--- a/MediaBrowser.Controller/Providers/MovieInfo.cs
+++ b/MediaBrowser.Controller/Providers/MovieInfo.cs
@@ -1,7 +1,8 @@
-#pragma warning disable CS1591
-
namespace MediaBrowser.Controller.Providers
{
+ ///
+ /// The lookup info for movies.
+ ///
public class MovieInfo : ItemLookupInfo
{
}
diff --git a/MediaBrowser.Controller/Providers/PersonLookupInfo.cs b/MediaBrowser.Controller/Providers/PersonLookupInfo.cs
index 11cb71f902..d0eb5cb825 100644
--- a/MediaBrowser.Controller/Providers/PersonLookupInfo.cs
+++ b/MediaBrowser.Controller/Providers/PersonLookupInfo.cs
@@ -1,7 +1,8 @@
-#pragma warning disable CS1591
-
namespace MediaBrowser.Controller.Providers
{
+ ///
+ /// The lookup info for persons.
+ ///
public class PersonLookupInfo : ItemLookupInfo
{
}
diff --git a/MediaBrowser.Controller/Providers/SeriesInfo.cs b/MediaBrowser.Controller/Providers/SeriesInfo.cs
index 976fa175ad..5ca5f0a534 100644
--- a/MediaBrowser.Controller/Providers/SeriesInfo.cs
+++ b/MediaBrowser.Controller/Providers/SeriesInfo.cs
@@ -1,7 +1,8 @@
-#pragma warning disable CS1591
-
namespace MediaBrowser.Controller.Providers
{
+ ///
+ /// The lookup info for series.
+ ///
public class SeriesInfo : ItemLookupInfo
{
}
diff --git a/MediaBrowser.Controller/Providers/TrailerInfo.cs b/MediaBrowser.Controller/Providers/TrailerInfo.cs
index 630850f9db..c30468db6c 100644
--- a/MediaBrowser.Controller/Providers/TrailerInfo.cs
+++ b/MediaBrowser.Controller/Providers/TrailerInfo.cs
@@ -1,7 +1,8 @@
-#pragma warning disable CS1591
-
namespace MediaBrowser.Controller.Providers
{
+ ///
+ /// The lookup info for trailers.
+ ///
public class TrailerInfo : ItemLookupInfo
{
}
--
cgit v1.2.3
From 0d3cf0169e198b51d90f106317a87116ac5e179e Mon Sep 17 00:00:00 2001
From: mbastian77
Date: Wed, 15 Jul 2026 16:02:12 +0200
Subject: Add XML docs to small channel types and remove CS1591 suppressions
---
.../Channels/ChannelItemResult.cs | 14 ++++++++++++--
MediaBrowser.Controller/Channels/ChannelItemType.cs | 11 +++++++++--
.../Channels/ChannelLatestMediaSearch.cs | 8 ++++++--
.../Channels/ChannelParentalRating.cs | 20 ++++++++++++++++++--
.../Channels/ChannelSearchInfo.cs | 11 +++++++++--
MediaBrowser.Controller/Channels/IHasCacheKey.cs | 7 ++++---
MediaBrowser.Controller/Channels/ISupportsDelete.cs | 16 ++++++++++++++--
.../Channels/ISupportsLatestMedia.cs | 5 +++--
8 files changed, 75 insertions(+), 17 deletions(-)
(limited to 'MediaBrowser.Controller')
diff --git a/MediaBrowser.Controller/Channels/ChannelItemResult.cs b/MediaBrowser.Controller/Channels/ChannelItemResult.cs
index ca7721991d..9557c91964 100644
--- a/MediaBrowser.Controller/Channels/ChannelItemResult.cs
+++ b/MediaBrowser.Controller/Channels/ChannelItemResult.cs
@@ -1,19 +1,29 @@
-#pragma warning disable CS1591
-
using System;
using System.Collections.Generic;
namespace MediaBrowser.Controller.Channels
{
+ ///
+ /// The result of a channel item query.
+ ///
public class ChannelItemResult
{
+ ///
+ /// Initializes a new instance of the class.
+ ///
public ChannelItemResult()
{
Items = Array.Empty();
}
+ ///
+ /// Gets or sets the items.
+ ///
public IReadOnlyList Items { get; set; }
+ ///
+ /// Gets or sets the total record count.
+ ///
public int? TotalRecordCount { get; set; }
}
}
diff --git a/MediaBrowser.Controller/Channels/ChannelItemType.cs b/MediaBrowser.Controller/Channels/ChannelItemType.cs
index 3ce920e236..2608cb4c88 100644
--- a/MediaBrowser.Controller/Channels/ChannelItemType.cs
+++ b/MediaBrowser.Controller/Channels/ChannelItemType.cs
@@ -1,11 +1,18 @@
-#pragma warning disable CS1591
-
namespace MediaBrowser.Controller.Channels
{
+ ///
+ /// The type of a channel item.
+ ///
public enum ChannelItemType
{
+ ///
+ /// The item is a media item.
+ ///
Media = 0,
+ ///
+ /// The item is a folder.
+ ///
Folder = 1
}
}
diff --git a/MediaBrowser.Controller/Channels/ChannelLatestMediaSearch.cs b/MediaBrowser.Controller/Channels/ChannelLatestMediaSearch.cs
index ebbe13763b..c6530814b9 100644
--- a/MediaBrowser.Controller/Channels/ChannelLatestMediaSearch.cs
+++ b/MediaBrowser.Controller/Channels/ChannelLatestMediaSearch.cs
@@ -1,11 +1,15 @@
#nullable disable
-#pragma warning disable CS1591
-
namespace MediaBrowser.Controller.Channels
{
+ ///
+ /// The request for a latest media search in a channel.
+ ///
public class ChannelLatestMediaSearch
{
+ ///
+ /// Gets or sets the user id.
+ ///
public string UserId { get; set; }
}
}
diff --git a/MediaBrowser.Controller/Channels/ChannelParentalRating.cs b/MediaBrowser.Controller/Channels/ChannelParentalRating.cs
index f77d81c166..a5a1ba5bf6 100644
--- a/MediaBrowser.Controller/Channels/ChannelParentalRating.cs
+++ b/MediaBrowser.Controller/Channels/ChannelParentalRating.cs
@@ -1,17 +1,33 @@
-#pragma warning disable CS1591
-
namespace MediaBrowser.Controller.Channels
{
+ ///
+ /// The parental rating of a channel.
+ ///
public enum ChannelParentalRating
{
+ ///
+ /// Suitable for a general audience.
+ ///
GeneralAudience = 0,
+ ///
+ /// Parental guidance suggested (US PG).
+ ///
UsPG = 1,
+ ///
+ /// Parents strongly cautioned (US PG-13).
+ ///
UsPG13 = 2,
+ ///
+ /// Restricted (US R).
+ ///
UsR = 3,
+ ///
+ /// Suitable for adults only.
+ ///
Adult = 4
}
}
diff --git a/MediaBrowser.Controller/Channels/ChannelSearchInfo.cs b/MediaBrowser.Controller/Channels/ChannelSearchInfo.cs
index 990b025bcb..d172b98b25 100644
--- a/MediaBrowser.Controller/Channels/ChannelSearchInfo.cs
+++ b/MediaBrowser.Controller/Channels/ChannelSearchInfo.cs
@@ -1,13 +1,20 @@
#nullable disable
-#pragma warning disable CS1591
-
namespace MediaBrowser.Controller.Channels
{
+ ///
+ /// The request for a search in a channel.
+ ///
public class ChannelSearchInfo
{
+ ///
+ /// Gets or sets the search term.
+ ///
public string SearchTerm { get; set; }
+ ///
+ /// Gets or sets the user id.
+ ///
public string UserId { get; set; }
}
}
diff --git a/MediaBrowser.Controller/Channels/IHasCacheKey.cs b/MediaBrowser.Controller/Channels/IHasCacheKey.cs
index 7d5207c34a..4cdda38bd9 100644
--- a/MediaBrowser.Controller/Channels/IHasCacheKey.cs
+++ b/MediaBrowser.Controller/Channels/IHasCacheKey.cs
@@ -1,14 +1,15 @@
-#pragma warning disable CS1591
-
namespace MediaBrowser.Controller.Channels
{
+ ///
+ /// Interface for channels that provide a cache key.
+ ///
public interface IHasCacheKey
{
///
/// Gets the cache key.
///
/// The user identifier.
- /// System.String.
+ /// The cache key.
string? GetCacheKey(string? userId);
}
}
diff --git a/MediaBrowser.Controller/Channels/ISupportsDelete.cs b/MediaBrowser.Controller/Channels/ISupportsDelete.cs
index 0110bfa7a3..194654ca9e 100644
--- a/MediaBrowser.Controller/Channels/ISupportsDelete.cs
+++ b/MediaBrowser.Controller/Channels/ISupportsDelete.cs
@@ -1,15 +1,27 @@
-#pragma warning disable CS1591
-
using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Controller.Entities;
namespace MediaBrowser.Controller.Channels
{
+ ///
+ /// Interface for channels that support deleting items.
+ ///
public interface ISupportsDelete
{
+ ///
+ /// Gets a value indicating whether the item can be deleted.
+ ///
+ /// The item.
+ /// true if the item can be deleted, false otherwise.
bool CanDelete(BaseItem item);
+ ///
+ /// Deletes the item with the provided id.
+ ///
+ /// The item id.
+ /// The cancellation token.
+ /// A task representing the deletion of the item.
Task DeleteItem(string id, CancellationToken cancellationToken);
}
}
diff --git a/MediaBrowser.Controller/Channels/ISupportsLatestMedia.cs b/MediaBrowser.Controller/Channels/ISupportsLatestMedia.cs
index 1935ec0f5f..82ca45d3ad 100644
--- a/MediaBrowser.Controller/Channels/ISupportsLatestMedia.cs
+++ b/MediaBrowser.Controller/Channels/ISupportsLatestMedia.cs
@@ -1,11 +1,12 @@
-#pragma warning disable CS1591
-
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace MediaBrowser.Controller.Channels
{
+ ///
+ /// Interface for channels that support retrieving the latest media.
+ ///
public interface ISupportsLatestMedia
{
///
--
cgit v1.2.3
From 997093ae3a800ea06e41523d0407de570eaaa4e6 Mon Sep 17 00:00:00 2001
From: mbastian77
Date: Wed, 15 Jul 2026 16:07:56 +0200
Subject: Add XML docs to small entity interfaces and remove CS1591
suppressions
---
MediaBrowser.Controller/Entities/IHasSpecialFeatures.cs | 5 +++--
MediaBrowser.Controller/Entities/IHasStartDate.cs | 8 ++++++--
MediaBrowser.Controller/Entities/IItemByName.cs | 15 ++++++++++++---
.../Entities/ISupportsPlaceHolders.cs | 5 +++--
MediaBrowser.Controller/Entities/SourceType.cs | 16 ++++++++++++++--
5 files changed, 38 insertions(+), 11 deletions(-)
(limited to 'MediaBrowser.Controller')
diff --git a/MediaBrowser.Controller/Entities/IHasSpecialFeatures.cs b/MediaBrowser.Controller/Entities/IHasSpecialFeatures.cs
index f47d2162f7..0cdc8bce03 100644
--- a/MediaBrowser.Controller/Entities/IHasSpecialFeatures.cs
+++ b/MediaBrowser.Controller/Entities/IHasSpecialFeatures.cs
@@ -1,12 +1,13 @@
#nullable disable
-#pragma warning disable CS1591
-
using System;
using System.Collections.Generic;
namespace MediaBrowser.Controller.Entities
{
+ ///
+ /// Interface for items that have special features.
+ ///
public interface IHasSpecialFeatures
{
///
diff --git a/MediaBrowser.Controller/Entities/IHasStartDate.cs b/MediaBrowser.Controller/Entities/IHasStartDate.cs
index dab15eb018..47df09d1ce 100644
--- a/MediaBrowser.Controller/Entities/IHasStartDate.cs
+++ b/MediaBrowser.Controller/Entities/IHasStartDate.cs
@@ -1,11 +1,15 @@
-#pragma warning disable CS1591
-
using System;
namespace MediaBrowser.Controller.Entities
{
+ ///
+ /// Interface for items that have a start date.
+ ///
public interface IHasStartDate
{
+ ///
+ /// Gets or sets the start date.
+ ///
DateTime StartDate { get; set; }
}
}
diff --git a/MediaBrowser.Controller/Entities/IItemByName.cs b/MediaBrowser.Controller/Entities/IItemByName.cs
index 4928bda7a2..756dbecb98 100644
--- a/MediaBrowser.Controller/Entities/IItemByName.cs
+++ b/MediaBrowser.Controller/Entities/IItemByName.cs
@@ -1,19 +1,28 @@
-#pragma warning disable CS1591
-
using System.Collections.Generic;
namespace MediaBrowser.Controller.Entities
{
///
- /// Marker interface.
+ /// Marker interface for items that represent a name, like a genre or a studio.
///
public interface IItemByName
{
+ ///
+ /// Gets the items tagged with this name.
+ ///
+ /// The query.
+ /// The tagged items.
IReadOnlyList GetTaggedItems(InternalItemsQuery query);
}
+ ///
+ /// Interface for by-name items that can also be accessed as a regular library item.
+ ///
public interface IHasDualAccess : IItemByName
{
+ ///
+ /// Gets a value indicating whether the item is accessed by name.
+ ///
bool IsAccessedByName { get; }
}
}
diff --git a/MediaBrowser.Controller/Entities/ISupportsPlaceHolders.cs b/MediaBrowser.Controller/Entities/ISupportsPlaceHolders.cs
index cdda8ea399..0f8904df5c 100644
--- a/MediaBrowser.Controller/Entities/ISupportsPlaceHolders.cs
+++ b/MediaBrowser.Controller/Entities/ISupportsPlaceHolders.cs
@@ -1,7 +1,8 @@
-#pragma warning disable CS1591
-
namespace MediaBrowser.Controller.Entities
{
+ ///
+ /// Interface for items that can be placeholders.
+ ///
public interface ISupportsPlaceHolders
{
///
diff --git a/MediaBrowser.Controller/Entities/SourceType.cs b/MediaBrowser.Controller/Entities/SourceType.cs
index be19e1bdae..97aa22dc04 100644
--- a/MediaBrowser.Controller/Entities/SourceType.cs
+++ b/MediaBrowser.Controller/Entities/SourceType.cs
@@ -1,11 +1,23 @@
-#pragma warning disable CS1591
-
namespace MediaBrowser.Controller.Entities
{
+ ///
+ /// The source of an item.
+ ///
public enum SourceType
{
+ ///
+ /// The item comes from a library.
+ ///
Library = 0,
+
+ ///
+ /// The item comes from a channel.
+ ///
Channel = 1,
+
+ ///
+ /// The item comes from live TV.
+ ///
LiveTV = 2
}
}
--
cgit v1.2.3
From d36c8ebce8593abd9cd32f37a6206b0cde25af8d Mon Sep 17 00:00:00 2001
From: Enea D'Angiò
Date: Thu, 16 Jul 2026 21:03:29 +0200
Subject: Reduce cognitive complexity of RemoveFromPlaylist
---
MediaBrowser.Controller/SyncPlay/Queue/PlayQueueManager.cs | 11 +++--------
1 file changed, 3 insertions(+), 8 deletions(-)
(limited to 'MediaBrowser.Controller')
diff --git a/MediaBrowser.Controller/SyncPlay/Queue/PlayQueueManager.cs b/MediaBrowser.Controller/SyncPlay/Queue/PlayQueueManager.cs
index f019a368a6..9326864d78 100644
--- a/MediaBrowser.Controller/SyncPlay/Queue/PlayQueueManager.cs
+++ b/MediaBrowser.Controller/SyncPlay/Queue/PlayQueueManager.cs
@@ -297,14 +297,9 @@ namespace MediaBrowser.Controller.SyncPlay.Queue
var removedBeforePlayingItem = 0;
if (playingItem is not null)
{
- var playlist = GetPlaylistInternal();
- for (var index = 0; index < PlayingItemIndex; index++)
- {
- if (playlistItemIds.Contains(playlist[index].PlaylistItemId))
- {
- removedBeforePlayingItem++;
- }
- }
+ removedBeforePlayingItem = GetPlaylistInternal()
+ .Take(PlayingItemIndex)
+ .Count(item => playlistItemIds.Contains(item.PlaylistItemId));
}
_sortedPlaylist.RemoveAll(item => playlistItemIds.Contains(item.PlaylistItemId));
--
cgit v1.2.3
From 4fb779920afdcf566f0d50b25ab34b0910fcb570 Mon Sep 17 00:00:00 2001
From: Shadowghost
Date: Fri, 17 Jul 2026 16:32:40 +0200
Subject: Sanitize ClientLog upload filename to prevent path traversal
---
.../ClientEvent/ClientEventLogger.cs | 10 ++-
src/Jellyfin.Extensions/PathHelper.cs | 77 ++++++++++++++++++++++
.../ClientEventLoggerTests.cs | 44 +++++++++++++
3 files changed, 130 insertions(+), 1 deletion(-)
create mode 100644 src/Jellyfin.Extensions/PathHelper.cs
create mode 100644 tests/Jellyfin.Controller.Tests/ClientEventLoggerTests.cs
(limited to 'MediaBrowser.Controller')
diff --git a/MediaBrowser.Controller/ClientEvent/ClientEventLogger.cs b/MediaBrowser.Controller/ClientEvent/ClientEventLogger.cs
index 14dc64dabd..36f0d2195c 100644
--- a/MediaBrowser.Controller/ClientEvent/ClientEventLogger.cs
+++ b/MediaBrowser.Controller/ClientEvent/ClientEventLogger.cs
@@ -1,6 +1,7 @@
using System;
using System.IO;
using System.Threading.Tasks;
+using Jellyfin.Extensions;
namespace MediaBrowser.Controller.ClientEvent
{
@@ -21,8 +22,15 @@ namespace MediaBrowser.Controller.ClientEvent
///
public async Task WriteDocumentAsync(string clientName, string clientVersion, Stream fileContents)
{
- var fileName = $"upload_{clientName}_{clientVersion}_{DateTime.UtcNow:yyyyMMddHHmmss}_{Guid.NewGuid():N}.log";
+ var safeClientName = PathHelper.GetSafeLeafFileName(clientName) ?? "unknown-client";
+ var safeClientVersion = PathHelper.GetSafeLeafFileName(clientVersion) ?? "unknown-version";
+ var fileName = $"upload_{safeClientName}_{safeClientVersion}_{DateTime.UtcNow:yyyyMMddHHmmss}_{Guid.NewGuid():N}.log";
var logFilePath = Path.Combine(_applicationPaths.LogDirectoryPath, fileName);
+ if (!PathHelper.IsContainedIn(_applicationPaths.LogDirectoryPath, logFilePath))
+ {
+ throw new ArgumentException("Path resolved to filename not in log directory");
+ }
+
var fileStream = new FileStream(logFilePath, FileMode.CreateNew, FileAccess.Write, FileShare.None);
await using (fileStream.ConfigureAwait(false))
{
diff --git a/src/Jellyfin.Extensions/PathHelper.cs b/src/Jellyfin.Extensions/PathHelper.cs
new file mode 100644
index 0000000000..f519cbb651
--- /dev/null
+++ b/src/Jellyfin.Extensions/PathHelper.cs
@@ -0,0 +1,77 @@
+using System;
+using System.IO;
+
+namespace Jellyfin.Extensions;
+
+///
+/// Helpers for safely composing filesystem paths from untrusted input.
+///
+///
+/// has two issues that matter in
+/// any code that joins a trusted directory with an externally-supplied name:
+/// it neither normalises .. nor rejects a rooted second argument
+/// (a rooted second arg silently discards the first). Use the helpers below
+/// any time the name comes from media metadata, request input, archive
+/// entries, or any other channel that can be influenced by a third party.
+///
+public static class PathHelper
+{
+ ///
+ /// Reduces a possibly-untrusted file name to a safe leaf-only name with no
+ /// directory components.
+ ///
+ /// The candidate file name.
+ ///
+ /// The leaf component of , or null if
+ /// the input has no usable leaf (empty, . , or .. ).
+ ///
+ public static string? GetSafeLeafFileName(string? fileName)
+ {
+ if (string.IsNullOrEmpty(fileName))
+ {
+ return null;
+ }
+
+ var leaf = Path.GetFileName(fileName);
+ if (string.IsNullOrEmpty(leaf) || leaf == "." || leaf == "..")
+ {
+ return null;
+ }
+
+ return leaf;
+ }
+
+ ///
+ /// Returns whether resolves to a path that
+ /// equals or is contained inside .
+ ///
+ /// The directory the candidate must remain inside.
+ /// The candidate absolute or relative path.
+ /// true if the candidate is inside or equal to root; otherwise false .
+ ///
+ /// Both arguments are resolved via
+ /// so .. segments are collapsed before the comparison. The root is
+ /// compared with a trailing directory separator to prevent prefix
+ /// collisions (e.g. /var/data must not be accepted as a parent of
+ /// /var/dataset ).
+ ///
+ public static bool IsContainedIn(string root, string candidate)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(root);
+ ArgumentException.ThrowIfNullOrEmpty(candidate);
+
+ var fullRoot = Path.GetFullPath(root);
+ var fullCandidate = Path.GetFullPath(candidate);
+
+ if (string.Equals(fullCandidate, fullRoot, StringComparison.Ordinal))
+ {
+ return true;
+ }
+
+ var rootWithSep = fullRoot.EndsWith(Path.DirectorySeparatorChar)
+ ? fullRoot
+ : fullRoot + Path.DirectorySeparatorChar;
+
+ return fullCandidate.StartsWith(rootWithSep, StringComparison.Ordinal);
+ }
+}
diff --git a/tests/Jellyfin.Controller.Tests/ClientEventLoggerTests.cs b/tests/Jellyfin.Controller.Tests/ClientEventLoggerTests.cs
new file mode 100644
index 0000000000..5132e529dd
--- /dev/null
+++ b/tests/Jellyfin.Controller.Tests/ClientEventLoggerTests.cs
@@ -0,0 +1,44 @@
+using System;
+using System.IO;
+using System.Text;
+using System.Threading.Tasks;
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.ClientEvent;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Controller.Tests
+{
+ public class ClientEventLoggerTests
+ {
+ [Theory]
+ [InlineData("../../../../etc/passwd", "1.0")]
+ [InlineData("..\\..\\windows\\system32", "1.0")]
+ [InlineData("normal-client", "../../../etc/passwd")]
+ [InlineData("/absolute/path", "1.0")]
+ public async Task WriteDocumentAsync_TraversalInput_StaysInsideLogDirectory(string clientName, string clientVersion)
+ {
+ var logDir = Path.Combine(Path.GetTempPath(), "jellyfin-clientlog-test-" + Path.GetRandomFileName());
+ Directory.CreateDirectory(logDir);
+ try
+ {
+ var paths = new Mock();
+ paths.Setup(p => p.LogDirectoryPath).Returns(logDir);
+
+ var logger = new ClientEventLogger(paths.Object);
+ using var contents = new MemoryStream(Encoding.UTF8.GetBytes("payload"));
+
+ var fileName = await logger.WriteDocumentAsync(clientName, clientVersion, contents);
+
+ var resolved = Path.GetFullPath(Path.Combine(logDir, fileName));
+ var rootWithSep = Path.GetFullPath(logDir) + Path.DirectorySeparatorChar;
+ Assert.StartsWith(rootWithSep, resolved, StringComparison.Ordinal);
+ Assert.True(File.Exists(resolved));
+ }
+ finally
+ {
+ Directory.Delete(logDir, recursive: true);
+ }
+ }
+ }
+}
--
cgit v1.2.3
From 5a9fb802395d7c8f919a73aca84fea5ae3d79aee Mon Sep 17 00:00:00 2001
From: mbastian77
Date: Sat, 18 Jul 2026 14:07:52 +0200
Subject: Exempt people from the allowed tags visibility check
---
.../Item/BaseItemRepository.QueryBuilding.cs | 6 +++++-
.../Item/BaseItemRepository.TranslateQuery.cs | 4 ++++
MediaBrowser.Controller/Entities/Person.cs | 11 +++++++++++
3 files changed, 20 insertions(+), 1 deletion(-)
(limited to 'MediaBrowser.Controller')
diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs
index decd45ae2c..a4de9feb05 100644
--- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs
+++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs
@@ -447,6 +447,7 @@ public sealed partial class BaseItemRepository
if (filter.IncludeInheritedTags.Length > 0)
{
var includeTags = filter.IncludeInheritedTags.Select(e => e.GetCleanValue()).ToArray();
+ var personTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Person];
var allowedTagItemIds = context.ItemValuesMap
.Where(f => f.ItemValue.Type == ItemValueType.Tags && includeTags.Contains(f.ItemValue.CleanValue))
.Select(f => f.ItemId);
@@ -455,7 +456,10 @@ public sealed partial class BaseItemRepository
allowedTagItemIds.Contains(e.Id)
|| (e.SeriesId.HasValue && allowedTagItemIds.Contains(e.SeriesId.Value))
|| e.Parents!.Any(p => allowedTagItemIds.Contains(p.ParentItemId))
- || (e.TopParentId.HasValue && allowedTagItemIds.Contains(e.TopParentId.Value)));
+ || (e.TopParentId.HasValue && allowedTagItemIds.Contains(e.TopParentId.Value))
+
+ // People don't carry the tags of the media they appear in and would never match
+ || e.Type == personTypeName);
}
// Exclude alternate versions (have PrimaryVersionId set) and owned non-extra items.
diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs
index 52cebccc37..d69a589d65 100644
--- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs
+++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs
@@ -1084,6 +1084,7 @@ public sealed partial class BaseItemRepository
{
var includeTags = filter.IncludeInheritedTags.Select(e => e.GetCleanValue()).ToArray();
var isPlaylistOnlyQuery = includeTypes.Length == 1 && includeTypes.FirstOrDefault() == BaseItemKind.Playlist;
+ var personTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Person];
var allowedTagItemIds = context.ItemValuesMap
.Where(f => f.ItemValue.Type == ItemValueType.Tags && includeTags.Contains(f.ItemValue.CleanValue))
.Select(f => f.ItemId);
@@ -1094,6 +1095,9 @@ public sealed partial class BaseItemRepository
|| e.Parents!.Any(p => allowedTagItemIds.Contains(p.ParentItemId))
|| (e.TopParentId.HasValue && allowedTagItemIds.Contains(e.TopParentId.Value))
+ // People don't carry the tags of the media they appear in and would never match
+ || e.Type == personTypeName
+
// A playlist should be accessible to its owner regardless of allowed tags
|| (isPlaylistOnlyQuery && e.Data!.Contains($"OwnerUserId\":\"{filter.User!.Id:N}\"")));
}
diff --git a/MediaBrowser.Controller/Entities/Person.cs b/MediaBrowser.Controller/Entities/Person.cs
index 5cc4d322f7..14325d971a 100644
--- a/MediaBrowser.Controller/Entities/Person.cs
+++ b/MediaBrowser.Controller/Entities/Person.cs
@@ -5,6 +5,7 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
+using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Extensions;
using MediaBrowser.Controller.Providers;
using Microsoft.Extensions.Logging;
@@ -75,6 +76,16 @@ namespace MediaBrowser.Controller.Entities
return false;
}
+ ///
+ ///
+ /// People don't carry the tags of the media they appear in, so the allowed tags check
+ /// is skipped for them; otherwise no person would be visible to users with allowed tags configured.
+ ///
+ public override bool IsVisible(User user, bool skipAllowedTagsCheck = false)
+ {
+ return base.IsVisible(user, true);
+ }
+
public override bool IsSaveLocalMetadataEnabled()
{
return true;
--
cgit v1.2.3
From 929e1936eb4be924c15f8724ac4a988b4cf25f0c Mon Sep 17 00:00:00 2001
From: Shadowghost
Date: Wed, 22 Jul 2026 08:30:29 +0200
Subject: Apply cleaning logic on ForcedSortName
---
.../20260722120000_RefreshForcedSortNames.cs | 113 +++++++++++++++++++++
MediaBrowser.Controller/Entities/BaseItem.cs | 31 ++++--
.../Entities/BaseItemTests.cs | 31 ++++++
3 files changed, 166 insertions(+), 9 deletions(-)
create mode 100644 Jellyfin.Server/Migrations/Routines/20260722120000_RefreshForcedSortNames.cs
(limited to 'MediaBrowser.Controller')
diff --git a/Jellyfin.Server/Migrations/Routines/20260722120000_RefreshForcedSortNames.cs b/Jellyfin.Server/Migrations/Routines/20260722120000_RefreshForcedSortNames.cs
new file mode 100644
index 0000000000..e9eefc20dc
--- /dev/null
+++ b/Jellyfin.Server/Migrations/Routines/20260722120000_RefreshForcedSortNames.cs
@@ -0,0 +1,113 @@
+using System;
+using System.Diagnostics;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Database.Implementations;
+using Jellyfin.Extensions;
+using Jellyfin.Server.ServerSetupApp;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Entities;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.Server.Migrations.Routines;
+
+///
+/// Migration to recompute the SortName of all items that have a forced sort name.
+///
+[JellyfinMigration("2026-07-22T12:00:00", nameof(RefreshForcedSortNames))]
+[JellyfinMigrationBackup(JellyfinDb = true)]
+public class RefreshForcedSortNames : IAsyncMigrationRoutine
+{
+ private readonly IStartupLogger _logger;
+ private readonly IDbContextFactory _dbProvider;
+ private readonly IServerConfigurationManager _configurationManager;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The logger.
+ /// Instance of the interface.
+ /// The server configuration manager providing the sort rules.
+ public RefreshForcedSortNames(
+ IStartupLogger logger,
+ IDbContextFactory dbProvider,
+ IServerConfigurationManager configurationManager)
+ {
+ _logger = logger;
+ _dbProvider = dbProvider;
+ _configurationManager = configurationManager;
+ }
+
+ ///
+ public async Task PerformAsync(CancellationToken cancellationToken)
+ {
+ const int Limit = 10000;
+ int itemCount = 0;
+
+ var configuration = _configurationManager.Configuration;
+ // Only the Person type disables alphanumeric sorting; everything else uses the cleaning rules.
+ var personType = typeof(Person).ToString();
+
+ var sw = Stopwatch.StartNew();
+
+ using var context = _dbProvider.CreateDbContext();
+ var records = context.BaseItems.Count(b => !string.IsNullOrEmpty(b.ForcedSortName));
+ _logger.LogInformation("Refreshing SortName for {Count} library items with a forced sort name", records);
+
+ var processedInPartition = 0;
+
+ await foreach (var item in context.BaseItems
+ .Where(b => !string.IsNullOrEmpty(b.ForcedSortName))
+ .OrderBy(e => e.Id)
+ .WithPartitionProgress((partition) => _logger.LogInformation("Processed: {Offset}/{Total} - Updated: {UpdatedCount} - Time: {Elapsed}", partition * Limit, records, itemCount, sw.Elapsed))
+ .PartitionEagerAsync(Limit, cancellationToken)
+ .WithCancellation(cancellationToken)
+ .ConfigureAwait(false))
+ {
+ try
+ {
+ var enableAlphaNumericSorting = !string.Equals(item.Type, personType, StringComparison.Ordinal);
+ var newSortName = BaseItem.GetSortName(item.ForcedSortName!, enableAlphaNumericSorting, configuration);
+ if (!string.Equals(newSortName, item.SortName, StringComparison.Ordinal))
+ {
+ _logger.LogDebug(
+ "Updating SortName for item {Id}: '{OldValue}' -> '{NewValue}'",
+ item.Id,
+ item.SortName,
+ newSortName);
+ item.SortName = newSortName;
+ itemCount++;
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Failed to update SortName for item {Id} ({Name})", item.Id, item.Name);
+ }
+
+ processedInPartition++;
+
+ if (processedInPartition >= Limit)
+ {
+ await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
+ // Clear tracked entities to avoid memory growth across partitions
+ context.ChangeTracker.Clear();
+ processedInPartition = 0;
+ }
+ }
+
+ // Save any remaining changes after the loop
+ if (processedInPartition > 0)
+ {
+ await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
+ context.ChangeTracker.Clear();
+ }
+
+ _logger.LogInformation(
+ "Refreshed SortName for {UpdatedCount} out of {TotalCount} items in {Time}",
+ itemCount,
+ records,
+ sw.Elapsed);
+ }
+}
diff --git a/MediaBrowser.Controller/Entities/BaseItem.cs b/MediaBrowser.Controller/Entities/BaseItem.cs
index 49a4ed4bf6..21a726aaec 100644
--- a/MediaBrowser.Controller/Entities/BaseItem.cs
+++ b/MediaBrowser.Controller/Entities/BaseItem.cs
@@ -27,6 +27,7 @@ using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.MediaSegments;
using MediaBrowser.Controller.Persistence;
using MediaBrowser.Controller.Providers;
+using MediaBrowser.Model.Configuration;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Globalization;
@@ -540,8 +541,8 @@ namespace MediaBrowser.Controller.Entities
{
if (!string.IsNullOrEmpty(ForcedSortName))
{
- // Need the ToLower because that's what CreateSortName does
- _sortName = ModifySortChunks(ForcedSortName).ToLowerInvariant();
+ // Run the forced sort name through the same cleaning as auto-generated sort names.
+ _sortName = GetSortName(ForcedSortName, EnableAlphaNumericSorting, ConfigurationManager.Configuration);
}
else
{
@@ -926,19 +927,31 @@ namespace MediaBrowser.Controller.Entities
/// System.String.
protected virtual string CreateSortName()
{
- if (Name is null)
+ return GetSortName(Name, EnableAlphaNumericSorting, ConfigurationManager.Configuration);
+ }
+
+ ///
+ /// Cleans a raw name into its sortable form by applying the configured sort rules.
+ ///
+ /// The raw name to clean.
+ /// Whether alphanumeric sorting rules should be applied.
+ /// The server configuration providing the sort rules.
+ /// The cleaned, sortable name, or null if is null .
+ public static string GetSortName(string name, bool enableAlphaNumericSorting, ServerConfiguration configuration)
+ {
+ if (name is null)
{
return null; // some items may not have name filled in properly
}
- if (!EnableAlphaNumericSorting)
+ if (!enableAlphaNumericSorting)
{
- return Name.TrimStart();
+ return name.TrimStart();
}
- var sortable = Name.Trim().ToLowerInvariant();
+ var sortable = name.Trim().ToLowerInvariant();
- foreach (var search in ConfigurationManager.Configuration.SortRemoveWords)
+ foreach (var search in configuration.SortRemoveWords)
{
// Remove from beginning if a space follows
if (sortable.StartsWith(search + " ", StringComparison.Ordinal))
@@ -956,12 +969,12 @@ namespace MediaBrowser.Controller.Entities
}
}
- foreach (var removeChar in ConfigurationManager.Configuration.SortRemoveCharacters)
+ foreach (var removeChar in configuration.SortRemoveCharacters)
{
sortable = sortable.Replace(removeChar, string.Empty, StringComparison.Ordinal);
}
- foreach (var replaceChar in ConfigurationManager.Configuration.SortReplaceCharacters)
+ foreach (var replaceChar in configuration.SortReplaceCharacters)
{
sortable = sortable.Replace(replaceChar, " ", StringComparison.Ordinal);
}
diff --git a/tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs b/tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs
index c0a2b0ecca..de109c8d65 100644
--- a/tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs
+++ b/tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs
@@ -4,10 +4,12 @@ using System.Linq;
using System.Reflection;
using System.Threading;
using Jellyfin.Database.Implementations.Entities;
+using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.LiveTv;
using MediaBrowser.Controller.MediaSegments;
+using MediaBrowser.Model.Configuration;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.MediaInfo;
@@ -28,6 +30,35 @@ public class BaseItemTests
public void BaseItem_ModifySortChunks_Valid(string input, string expected)
=> Assert.Equal(expected, BaseItem.ModifySortChunks(input));
+ [Theory]
+ [InlineData("The Matrix", "matrix")]
+ [InlineData("Spider-Man", "spiderman")]
+ [InlineData("A Movie: Part 2", "movie: part 0000000002")]
+ public void GetSortName_AppliesConfiguredCleaning(string input, string expected)
+ => Assert.Equal(expected, BaseItem.GetSortName(input, true, new ServerConfiguration()));
+
+ [Fact]
+ public void GetSortName_WithoutAlphaNumericSorting_ReturnsTrimmedInput()
+ => Assert.Equal("The Matrix", BaseItem.GetSortName(" The Matrix", false, new ServerConfiguration()));
+
+ [Fact]
+ public void SortName_ForcedSortName_IsCleanedLikeAutoSortName()
+ {
+ var configManager = new Mock();
+ configManager.Setup(x => x.Configuration).Returns(new ServerConfiguration());
+ BaseItem.ConfigurationManager = configManager.Object;
+
+ const string Raw = "The Spider-Man: Homecoming";
+
+ var auto = new Video { Name = Raw };
+ var forced = new Video { Name = "zzz unrelated name", ForcedSortName = Raw };
+
+ // A forced sort name must be cleaned the same way as an auto-generated one so both sort together (#17388).
+ Assert.Equal(auto.SortName, forced.SortName);
+ // Sanity: cleaning actually ran (leading article and hyphen removed, colon kept, lowercased).
+ Assert.Equal("spiderman: homecoming", forced.SortName);
+ }
+
[Theory]
[InlineData("/Movies/Ted/Ted.mp4", "/Movies/Ted/Ted - Unrated Edition.mp4", "Ted", "Unrated Edition")]
[InlineData("/Movies/Deadpool 2 (2018)/Deadpool 2 (2018).mkv", "/Movies/Deadpool 2 (2018)/Deadpool 2 (2018) - Super Duper Cut.mkv", "Deadpool 2 (2018)", "Super Duper Cut")]
--
cgit v1.2.3