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
---
Emby.Server.Implementations/Library/LibraryManager.cs | 12 ++++++++++++
1 file changed, 12 insertions(+)
(limited to 'Emby.Server.Implementations/Library')
diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs
index cc85f09d23..f075d2f649 100644
--- a/Emby.Server.Implementations/Library/LibraryManager.cs
+++ b/Emby.Server.Implementations/Library/LibraryManager.cs
@@ -3862,5 +3862,17 @@ namespace Emby.Server.Implementations.Library
{
return _mediaStreamRepository.GetMediaStreamLanguages(mediaStreamType);
}
+
+ ///
+ public IReadOnlyList GetMediaStreamLanguages(MediaStreamType mediaStreamType, InternalItemsQuery query)
+ {
+ if (query.User is not null)
+ {
+ AddUserToQuery(query, query.User);
+ }
+
+ SetTopParentOrAncestorIds(query);
+ return _itemRepository.GetMediaStreamLanguages(query, mediaStreamType);
+ }
}
}
--
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 'Emby.Server.Implementations/Library')
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
/// The name of the series.
public string? Name { get; set; }
+
+ ///
+ /// Gets or sets the year of the series.
+ ///
+ /// The year of the series.
+ public int? Year { get; set; }
}
}
diff --git a/Emby.Naming/TV/SeriesResolver.cs b/Emby.Naming/TV/SeriesResolver.cs
index 0b7309bae0..7fe0ed9485 100644
--- a/Emby.Naming/TV/SeriesResolver.cs
+++ b/Emby.Naming/TV/SeriesResolver.cs
@@ -21,7 +21,7 @@ namespace Emby.Naming.TV
/// Regex that matches titles with year in parentheses. Captures the title (which may be
/// numeric) before the year, i.e. turns "1923 (2022)" into "1923".
///
- [GeneratedRegex(@"(?.+?)\s*\(\d{4}\)")]
+ [GeneratedRegex(@"(?.+?)\s*\((?\d{4})\)")]
private static partial Regex TitleWithYearRegex();
///
@@ -43,7 +43,8 @@ namespace Emby.Naming.TV
seriesName = titleWithYearMatch.Groups["title"].Value.Trim();
return new SeriesInfo(path)
{
- Name = seriesName
+ Name = seriesName,
+ Year = int.TryParse(titleWithYearMatch.Groups["year"].Value, out var year) ? year : null
};
}
}
diff --git a/Emby.Server.Implementations/Library/Resolvers/TV/SeriesResolver.cs b/Emby.Server.Implementations/Library/Resolvers/TV/SeriesResolver.cs
index 769d721665..f7eb3570a7 100644
--- a/Emby.Server.Implementations/Library/Resolvers/TV/SeriesResolver.cs
+++ b/Emby.Server.Implementations/Library/Resolvers/TV/SeriesResolver.cs
@@ -69,7 +69,8 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV
return new Series
{
Path = args.Path,
- Name = seriesInfo.Name
+ Name = seriesInfo.Name,
+ ProductionYear = seriesInfo.Year
};
}
}
--
cgit v1.2.3
From b9271eb19995056ab9e1c56ba810e963702c9254 Mon Sep 17 00:00:00 2001
From: theguymadmax
Date: Mon, 15 Jun 2026 19:07:34 -0400
Subject: Skip parsing root-level folders in SeriesResolver
---
Emby.Server.Implementations/Library/Resolvers/TV/SeriesResolver.cs | 5 +++++
1 file changed, 5 insertions(+)
(limited to 'Emby.Server.Implementations/Library')
diff --git a/Emby.Server.Implementations/Library/Resolvers/TV/SeriesResolver.cs b/Emby.Server.Implementations/Library/Resolvers/TV/SeriesResolver.cs
index 769d721665..94e48c59ec 100644
--- a/Emby.Server.Implementations/Library/Resolvers/TV/SeriesResolver.cs
+++ b/Emby.Server.Implementations/Library/Resolvers/TV/SeriesResolver.cs
@@ -57,6 +57,11 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV
return null;
}
+ if (args.Parent is not null && args.Parent.IsRoot)
+ {
+ return null;
+ }
+
var seriesInfo = Naming.TV.SeriesResolver.Resolve(_namingOptions, args.Path);
var collectionType = args.GetCollectionType();
--
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 'Emby.Server.Implementations/Library')
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 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 'Emby.Server.Implementations/Library')
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 'Emby.Server.Implementations/Library')
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 1294990f4fa93b30ec9699d18af4de1cd1d562b8 Mon Sep 17 00:00:00 2001
From: Jakub Schmidtke
Date: Tue, 7 Jul 2026 04:18:02 +0200
Subject: Added more aliases for attributes
Adds tvdb alias for tvdbid and imdb alias for imdbid.
It also fixes an issue where tmdb alias was being ignored
if it was followed by something like "tmdbidfoo".
The same issue prevented imdb pattern matching from
working, if it was followed by something like "imdbidfoo".
It also allows for detecting the first matching occurence,
whether it was an alias or not.
Finally, it ignores attributes with values consisting of only whitespaces.
---
.../Library/PathExtensions.cs | 81 +++++++++++++++-------
.../Library/PathExtensionsTests.cs | 63 ++++++++++++++++-
2 files changed, 118 insertions(+), 26 deletions(-)
(limited to 'Emby.Server.Implementations/Library')
diff --git a/Emby.Server.Implementations/Library/PathExtensions.cs b/Emby.Server.Implementations/Library/PathExtensions.cs
index 7591359ea4..8815e2785a 100644
--- a/Emby.Server.Implementations/Library/PathExtensions.cs
+++ b/Emby.Server.Implementations/Library/PathExtensions.cs
@@ -29,17 +29,41 @@ namespace Emby.Server.Implementations.Library
throw new ArgumentException("String can't be empty.", nameof(attribute));
}
- var attributeIndex = str.IndexOf(attribute, StringComparison.OrdinalIgnoreCase);
+ // Allow tmdb as an alias for tmdbid, tvdb for tvdbid, etc.
+ // The code below only supports aliases for attributes in the form of "id".
+ ReadOnlySpan shortAttr = attribute switch
+ {
+ _ when attribute.Equals("tmdbid", StringComparison.OrdinalIgnoreCase) => "tmdb",
+ _ when attribute.Equals("tvdbid", StringComparison.OrdinalIgnoreCase) => "tvdb",
+ _ when attribute.Equals("imdbid", StringComparison.OrdinalIgnoreCase) => "imdb",
+ _ => string.Empty
+ };
- // Must be at least 3 characters after the attribute =, ], any character,
- // then we offset it by 1, because we want the index and not length.
- var maxIndex = str.Length - attribute.Length - 2;
- while (attributeIndex > -1 && attributeIndex < maxIndex)
+ for (int strIndex = 0, attributeIndex = 0; attributeIndex > -1;)
{
- var attributeEnd = attributeIndex + attribute.Length;
+ // We may want to use imdbid pattern matching later, so we don't want to modify the original 'str'.
+ var subStr = str[strIndex..];
+ int attributeEnd = 0;
+
+ if (shortAttr.Length > 0)
+ {
+ // If we are using an alias it should be shorter (and a prefix), so let's search for that.
+ attributeIndex = subStr.IndexOf(shortAttr, StringComparison.OrdinalIgnoreCase);
+ attributeEnd = attributeIndex + shortAttr.Length;
+ }
+ else
+ {
+ attributeIndex = subStr.IndexOf(attribute, StringComparison.OrdinalIgnoreCase);
+ attributeEnd = attributeIndex + attribute.Length;
+ }
+
+ // The next iteration should start at the end of the attribute we just found.
+ // If attributeIndex < 0, the loop will end and strIndex won't be used again.
+ strIndex += attributeEnd;
+
if (attributeIndex > 0)
{
- var attributeOpener = str[attributeIndex - 1];
+ var attributeOpener = subStr[attributeIndex - 1];
var attributeCloser = attributeOpener switch
{
'[' => ']',
@@ -47,20 +71,37 @@ namespace Emby.Server.Implementations.Library
'{' => '}',
_ => '\0'
};
- if (attributeCloser != '\0' && (str[attributeEnd] == '=' || str[attributeEnd] == '-'))
+
+ if (attributeCloser != '\0')
{
- var closingIndex = str[attributeEnd..].IndexOf(attributeCloser);
+ if (shortAttr.Length > 0
+ && attributeEnd + 1 < subStr.Length
+ && (subStr[attributeEnd] is 'i' or 'I')
+ && (subStr[attributeEnd + 1] is 'd' or 'D'))
+ {
+ // We were searching for a shortened attribute, but it's followed by "id" - let's skip it.
+ attributeEnd += 2;
+ }
- // Must be at least 1 character before the closing bracket.
- if (closingIndex > 1)
+ // attributeEnd points at '='.
+ // We need at least 1 more character and the closing bracket after that.
+ if (attributeEnd + 2 < subStr.Length && (subStr[attributeEnd] is '=' or '-'))
{
- return str[(attributeEnd + 1)..(attributeEnd + closingIndex)].Trim().ToString();
+ var closingIndex = subStr[attributeEnd..].IndexOf(attributeCloser);
+
+ // Must be at least 1 character before the closing bracket.
+ if (closingIndex > 1)
+ {
+ var trimmed = subStr[(attributeEnd + 1)..(attributeEnd + closingIndex)].Trim();
+
+ if (trimmed.Length > 0)
+ {
+ return trimmed.ToString();
+ }
+ }
}
}
}
-
- str = str[attributeEnd..];
- attributeIndex = str.IndexOf(attribute, StringComparison.OrdinalIgnoreCase);
}
// for imdbid we also accept pattern matching
@@ -70,16 +111,6 @@ namespace Emby.Server.Implementations.Library
return match ? imdbId.ToString() : null;
}
- // Allow tmdb as an alias for tmdbid
- if (attribute.Equals("tmdbid", StringComparison.OrdinalIgnoreCase))
- {
- var tmdbValue = str.GetAttributeValue("tmdb");
- if (tmdbValue is not null)
- {
- return tmdbValue;
- }
- }
-
return null;
}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/PathExtensionsTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/PathExtensionsTests.cs
index 650d67b195..e65bc1d31f 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Library/PathExtensionsTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/Library/PathExtensionsTests.cs
@@ -9,44 +9,105 @@ namespace Jellyfin.Server.Implementations.Tests.Library
{
[Theory]
[InlineData("Superman: Red Son [imdbid=tt10985510]", "imdbid", "tt10985510")]
+ [InlineData("Superman: Red Son [imdb=tt10985510]", "imdbid", "tt10985510")]
[InlineData("Superman: Red Son [imdbid-tt10985510]", "imdbid", "tt10985510")]
+ [InlineData("Superman: Red Son [imdb-tt10985510]", "imdbid", "tt10985510")]
[InlineData("Superman: Red Son - tt10985510", "imdbid", "tt10985510")]
[InlineData("Superman: Red Son {imdbid=tt10985510}", "imdbid", "tt10985510")]
+ [InlineData("Superman: Red Son {imdb=tt10985510}", "imdbid", "tt10985510")]
+ [InlineData("Superman: Red Son {imdbid-tt10985510}", "imdbid", "tt10985510")]
+ [InlineData("Superman: Red Son {imdb-tt10985510}", "imdbid", "tt10985510")]
+ [InlineData("Superman: Red Son (imdbid=tt10985510)", "imdbid", "tt10985510")]
+ [InlineData("Superman: Red Son (imdb=tt10985510)", "imdbid", "tt10985510")]
[InlineData("Superman: Red Son (imdbid-tt10985510)", "imdbid", "tt10985510")]
+ [InlineData("Superman: Red Son (imdb-tt10985510)", "imdbid", "tt10985510")]
[InlineData("Superman: Red Son", "imdbid", null)]
[InlineData("Superman: Red Son [imdbid1=tt11111111][imdbid=tt10985510]", "imdbid", "tt10985510")]
+ [InlineData("Superman: Red Son [imdbid1=tt11111111][imdb=tt10985510]", "imdbid", "tt10985510")]
[InlineData("Superman: Red Son {imdbid1=tt11111111}(imdbid=tt10985510)", "imdbid", "tt10985510")]
+ [InlineData("Superman: Red Son {imdbid1=tt11111111}(imdb=tt10985510)", "imdbid", "tt10985510")]
[InlineData("Superman: Red Son (imdbid1-tt11111111)[imdbid=tt10985510]", "imdbid", "tt10985510")]
+ [InlineData("Superman: Red Son (imdbid1-tt11111111)[imdb=tt10985510]", "imdbid", "tt10985510")]
[InlineData("Superman: Red Son [tmdbid=618355][imdbid=tt10985510]", "imdbid", "tt10985510")]
+ [InlineData("Superman: Red Son [tmdbid=618355][imdb=tt10985510]", "imdbid", "tt10985510")]
[InlineData("Superman: Red Son [tmdbid-618355]{imdbid-tt10985510}", "imdbid", "tt10985510")]
+ [InlineData("Superman: Red Son [tmdbid-618355]{imdb-tt10985510}", "imdbid", "tt10985510")]
[InlineData("Superman: Red Son (tmdbid-618355)[imdbid-tt10985510]", "tmdbid", "618355")]
+ [InlineData("Superman: Red Son (tmdbid-618355)[imdb-tt10985510]", "tmdbid", "618355")]
[InlineData("Superman: Red Son [providera-id=1]", "providera-id", "1")]
[InlineData("Superman: Red Son [providerb-id=2]", "providerb-id", "2")]
[InlineData("Superman: Red Son [providera id=4]", "providera id", "4")]
[InlineData("Superman: Red Son [providerb id=5]", "providerb id", "5")]
+ [InlineData("Superman: Red Son [provider=99][providerid=5]", "providerid", "5")]
[InlineData("Superman: Red Son [tmdbid=3]", "tmdbid", "3")]
- [InlineData("Superman: Red Son [tvdbid-6]", "tvdbid", "6")]
+ [InlineData("Superman: Red Son [tmdb=3]", "tmdbid", "3")]
+ [InlineData("Superman: Red Son [tmdbid-3]", "tmdbid", "3")]
+ [InlineData("Superman: Red Son [tmdb-3]", "tmdbid", "3")]
[InlineData("Superman: Red Son {tmdbid=3}", "tmdbid", "3")]
+ [InlineData("Superman: Red Son {tmdb=3}", "tmdbid", "3")]
+ [InlineData("Superman: Red Son {tmdbid-3}", "tmdbid", "3")]
+ [InlineData("Superman: Red Son {tmdb-3}", "tmdbid", "3")]
+ [InlineData("Superman: Red Son (tmdbid=6)", "tmdbid", "6")]
+ [InlineData("Superman: Red Son (tmdb=6)", "tmdbid", "6")]
+ [InlineData("Superman: Red Son (tmdbid-6)", "tmdbid", "6")]
+ [InlineData("Superman: Red Son (tmdb-6)", "tmdbid", "6")]
+ [InlineData("Superman: Red Son [tvdbid=6]", "tvdbid", "6")]
+ [InlineData("Superman: Red Son [tvdb=6]", "tvdbid", "6")]
+ [InlineData("Superman: Red Son [tvdbid-6]", "tvdbid", "6")]
+ [InlineData("Superman: Red Son [tvdb-6]", "tvdbid", "6")]
+ [InlineData("Superman: Red Son {tvdbid=3}", "tvdbid", "3")]
+ [InlineData("Superman: Red Son {tvdb=3}", "tvdbid", "3")]
+ [InlineData("Superman: Red Son {tvdbid-3}", "tvdbid", "3")]
+ [InlineData("Superman: Red Son {tvdb-3}", "tvdbid", "3")]
+ [InlineData("Superman: Red Son (tvdbid=6)", "tvdbid", "6")]
+ [InlineData("Superman: Red Son (tvdb=6)", "tvdbid", "6")]
[InlineData("Superman: Red Son (tvdbid-6)", "tvdbid", "6")]
+ [InlineData("Superman: Red Son (tvdb-6)", "tvdbid", "6")]
[InlineData("[tmdbid=618355]", "tmdbid", "618355")]
+ [InlineData("[tmdb=618355]", "tmdbid", "618355")]
[InlineData("{tmdbid=618355}", "tmdbid", "618355")]
+ [InlineData("{tmdb=618355}", "tmdbid", "618355")]
[InlineData("(tmdbid=618355)", "tmdbid", "618355")]
+ [InlineData("(tmdb=618355)", "tmdbid", "618355")]
[InlineData("[tmdbid-618355]", "tmdbid", "618355")]
+ [InlineData("[tmdb-618355]", "tmdbid", "618355")]
[InlineData("{tmdbid-618355)", "tmdbid", null)]
+ [InlineData("{tmdb-618355)", "tmdbid", null)]
[InlineData("[tmdbid-618355}", "tmdbid", null)]
+ [InlineData("[tmdb-618355}", "tmdbid", null)]
[InlineData("tmdbid=111111][tmdbid=618355]", "tmdbid", "618355")]
+ [InlineData("tmdbid=111111][tmdb=618355]", "tmdbid", "618355")]
[InlineData("[tmdbid=618355]tmdbid=111111]", "tmdbid", "618355")]
+ [InlineData("[tmdb=618355]tmdbid=111111]", "tmdbid", "618355")]
[InlineData("tmdbid=618355]", "tmdbid", null)]
+ [InlineData("tmdb=618355]", "tmdbid", null)]
[InlineData("[tmdbid=618355", "tmdbid", null)]
+ [InlineData("[tmdb=618355", "tmdbid", null)]
[InlineData("tmdbid=618355", "tmdbid", null)]
+ [InlineData("tmdb=618355", "tmdbid", null)]
[InlineData("tmdbid=", "tmdbid", null)]
+ [InlineData("tmdb=", "tmdbid", null)]
[InlineData("tmdbid", "tmdbid", null)]
+ [InlineData("tmdb", "tmdbid", null)]
+ [InlineData("[tmdbid= ][tmdbid=223344]", "tmdbid", "223344")]
+ [InlineData("[tmdb= ][tmdb=223344]", "tmdbid", "223344")]
+ [InlineData("[tmdbid= ][tmdb=223344]", "tmdbid", "223344")]
+ [InlineData("[tmdb= ][tmdbid=223344]", "tmdbid", "223344")]
[InlineData("[tmdbid=][imdbid=tt10985510]", "tmdbid", null)]
+ [InlineData("[tmdb=][imdbid=tt10985510]", "tmdbid", null)]
[InlineData("[tmdbid-][imdbid-tt10985510]", "tmdbid", null)]
+ [InlineData("[tmdb-][imdbid-tt10985510]", "tmdbid", null)]
[InlineData("Superman: Red Son [tmdbid-618355][tmdbid=1234567]", "tmdbid", "618355")]
+ [InlineData("Superman: Red Son [tmdb-618355][tmdbid=1234567]", "tmdbid", "618355")]
[InlineData("{tmdbid=}{imdbid=tt10985510}", "tmdbid", null)]
+ [InlineData("{tmdb=}{imdbid=tt10985510}", "tmdbid", null)]
[InlineData("(tmdbid-)(imdbid-tt10985510)", "tmdbid", null)]
+ [InlineData("(tmdb-)(imdbid-tt10985510)", "tmdbid", null)]
[InlineData("Superman: Red Son {tmdbid-618355}{tmdbid=1234567}", "tmdbid", "618355")]
+ [InlineData("Superman: Red Son {tmdb-618355}{tmdbid=1234567}", "tmdbid", "618355")]
+ [InlineData("Superman: Red Son - tt10985510 [imdbid1=tt11]", "imdbid", "tt10985510")]
+ [InlineData("Superman: Red Son [tmdb=618355][tmdbid1=1]", "tmdbid", "618355")]
+ [InlineData("Superman: Red Son [tmdb=618355][tmdbid=12345]", "tmdbid", "618355")]
public void GetAttributeValue_ValidArgs_Correct(string input, string attribute, string? expectedResult)
{
Assert.Equal(expectedResult, PathExtensions.GetAttributeValue(input, attribute));
--
cgit v1.2.3
From 08f6627a24083288a1450c7af62128371b634454 Mon Sep 17 00:00:00 2001
From: Jakub Schmidtke
Date: Wed, 8 Jul 2026 01:40:26 +0200
Subject: Replaced string.Empty with ReadOnlySpan.Empty
---
Emby.Server.Implementations/Library/PathExtensions.cs | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
(limited to 'Emby.Server.Implementations/Library')
diff --git a/Emby.Server.Implementations/Library/PathExtensions.cs b/Emby.Server.Implementations/Library/PathExtensions.cs
index 8815e2785a..7d0f3900c5 100644
--- a/Emby.Server.Implementations/Library/PathExtensions.cs
+++ b/Emby.Server.Implementations/Library/PathExtensions.cs
@@ -36,7 +36,7 @@ namespace Emby.Server.Implementations.Library
_ when attribute.Equals("tmdbid", StringComparison.OrdinalIgnoreCase) => "tmdb",
_ when attribute.Equals("tvdbid", StringComparison.OrdinalIgnoreCase) => "tvdb",
_ when attribute.Equals("imdbid", StringComparison.OrdinalIgnoreCase) => "imdb",
- _ => string.Empty
+ _ => ReadOnlySpan.Empty
};
for (int strIndex = 0, attributeIndex = 0; attributeIndex > -1;)
--
cgit v1.2.3
From fcce10894816768f275173eb6905c5aea402e515 Mon Sep 17 00:00:00 2001
From: Jordan Rushing
Date: Mon, 13 Jul 2026 15:40:40 -0500
Subject: Fix: Fetch the correct row matching the most up to date file
---
.../Library/UserDataManager.cs | 33 ++++-
.../Library/UserDataManagerTests.cs | 149 +++++++++++++++++++++
2 files changed, 180 insertions(+), 2 deletions(-)
create mode 100644 tests/Jellyfin.Server.Implementations.Tests/Library/UserDataManagerTests.cs
(limited to 'Emby.Server.Implementations/Library')
diff --git a/Emby.Server.Implementations/Library/UserDataManager.cs b/Emby.Server.Implementations/Library/UserDataManager.cs
index 40cd2bb69c..58126b97b2 100644
--- a/Emby.Server.Implementations/Library/UserDataManager.cs
+++ b/Emby.Server.Implementations/Library/UserDataManager.cs
@@ -192,7 +192,8 @@ namespace Emby.Server.Implementations.Library
}
else
{
- var userData = item.UserData?.Where(e => e.UserId.Equals(user.Id)).Select(Map).FirstOrDefault();
+ var userDataRow = ResolveUserDataRow(item, item.UserData?.Where(e => e.UserId.Equals(user.Id)));
+ var userData = userDataRow is not null ? Map(userDataRow) : null;
if (userData is not null)
{
result[item.Id] = userData;
@@ -356,12 +357,40 @@ namespace Emby.Server.Implementations.Library
///
public UserItemData? GetUserData(User user, BaseItem item)
{
- return item.UserData?.Where(e => e.UserId.Equals(user.Id)).Select(Map).FirstOrDefault() ?? new UserItemData()
+ var row = ResolveUserDataRow(item, item.UserData?.Where(e => e.UserId.Equals(user.Id)));
+ return row is not null ? Map(row) : new UserItemData()
{
Key = item.GetUserDataKeys()[0],
};
}
+ ///
+ /// Picks the row matching the item's current user data keys, in key order, so rows left behind
+ /// under keys from older metadata don't take priority over the rows the write path updates.
+ ///
+ /// The item whose keys to match.
+ /// The candidate user data rows for a single user.
+ /// The best matching row, or null when there are none.
+ private static UserData? ResolveUserDataRow(BaseItem item, IEnumerable? rows)
+ {
+ var candidates = rows?.ToList();
+ if (candidates is null || candidates.Count == 0)
+ {
+ return null;
+ }
+
+ foreach (var key in item.GetUserDataKeys())
+ {
+ var match = candidates.Find(e => string.Equals(e.CustomDataKey, key, StringComparison.Ordinal));
+ if (match is not null)
+ {
+ return match;
+ }
+ }
+
+ return candidates[0];
+ }
+
///
public UserItemDataDto? GetUserDataDto(BaseItem item, User user)
=> GetUserDataDto(item, null, user, new DtoOptions());
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/UserDataManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/UserDataManagerTests.cs
new file mode 100644
index 0000000000..092e87b9ff
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Library/UserDataManagerTests.cs
@@ -0,0 +1,149 @@
+using System;
+using System.Collections.Generic;
+using Emby.Server.Implementations.Library;
+using Jellyfin.Database.Implementations;
+using Jellyfin.Database.Implementations.Entities;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Model.Configuration;
+using Microsoft.EntityFrameworkCore;
+using Moq;
+using Xunit;
+using AudioBook = MediaBrowser.Controller.Entities.AudioBook;
+
+namespace Jellyfin.Server.Implementations.Tests.Library;
+
+public class UserDataManagerTests
+{
+ private readonly UserDataManager _userDataManager;
+ private readonly User _user;
+
+ public UserDataManagerTests()
+ {
+ var config = new Mock();
+ config.SetupGet(c => c.Configuration).Returns(new ServerConfiguration());
+
+ var repository = Mock.Of>();
+
+ _userDataManager = new UserDataManager(config.Object, repository);
+ _user = new User("user", "auth-provider", "reset-provider")
+ {
+ Id = Guid.NewGuid()
+ };
+ }
+
+ private AudioBook CreateAudioBook()
+ {
+ // GetUserDataKeys(): ["Author-Series-0001Book Title", "- "]
+ return new AudioBook
+ {
+ Id = Guid.NewGuid(),
+ Name = "Book Title",
+ Album = "Series",
+ AlbumArtists = new[] { "Author" },
+ IndexNumber = 1
+ };
+ }
+
+ private UserData CreateUserDataRow(AudioBook item, string key, long positionTicks)
+ {
+ return new UserData
+ {
+ ItemId = item.Id,
+ Item = null,
+ UserId = _user.Id,
+ User = null,
+ CustomDataKey = key,
+ PlaybackPositionTicks = positionTicks
+ };
+ }
+
+ [Fact]
+ public void GetUserData_RowsUnderCurrentAndRetiredKeys_PrefersCurrentKeyRow()
+ {
+ var item = CreateAudioBook();
+ var currentKey = item.GetUserDataKeys()[0];
+
+ // the retired-key row comes first to ensure selection is by key, not row order
+ item.UserData = new List
+ {
+ CreateUserDataRow(item, "Author-Old Album-0001Old File Name", 111),
+ CreateUserDataRow(item, currentKey, 222)
+ };
+
+ var userData = _userDataManager.GetUserData(_user, item);
+
+ Assert.NotNull(userData);
+ Assert.Equal(currentKey, userData.Key);
+ Assert.Equal(222, userData.PlaybackPositionTicks);
+ }
+
+ [Fact]
+ public void GetUserData_NoPrimaryKeyRow_UsesNextCurrentKeyRow()
+ {
+ var item = CreateAudioBook();
+ var idKey = item.GetUserDataKeys()[1];
+
+ item.UserData = new List
+ {
+ CreateUserDataRow(item, "Author-Old Album-0001Old File Name", 111),
+ CreateUserDataRow(item, idKey, 333)
+ };
+
+ var userData = _userDataManager.GetUserData(_user, item);
+
+ Assert.NotNull(userData);
+ Assert.Equal(idKey, userData.Key);
+ Assert.Equal(333, userData.PlaybackPositionTicks);
+ }
+
+ [Fact]
+ public void GetUserData_OnlyRetiredKeyRows_ReturnsRetiredKeyRow()
+ {
+ var item = CreateAudioBook();
+
+ item.UserData = new List
+ {
+ CreateUserDataRow(item, "Author-Old Album-0001Old File Name", 111)
+ };
+
+ var userData = _userDataManager.GetUserData(_user, item);
+
+ Assert.NotNull(userData);
+ Assert.Equal(111, userData.PlaybackPositionTicks);
+ }
+
+ [Fact]
+ public void GetUserData_NoRows_ReturnsDefaultWithPrimaryKey()
+ {
+ var item = CreateAudioBook();
+ item.UserData = new List();
+
+ var userData = _userDataManager.GetUserData(_user, item);
+
+ Assert.NotNull(userData);
+ Assert.Equal(item.GetUserDataKeys()[0], userData.Key);
+ Assert.Equal(0, userData.PlaybackPositionTicks);
+ }
+
+ [Fact]
+ public void GetUserData_RowsForOtherUsers_AreIgnored()
+ {
+ var item = CreateAudioBook();
+ var currentKey = item.GetUserDataKeys()[0];
+
+ var otherUserRow = CreateUserDataRow(item, currentKey, 999);
+ otherUserRow.UserId = Guid.NewGuid();
+
+ item.UserData = new List
+ {
+ otherUserRow,
+ CreateUserDataRow(item, currentKey, 222)
+ };
+
+ var userData = _userDataManager.GetUserData(_user, item);
+
+ Assert.NotNull(userData);
+ Assert.Equal(222, userData.PlaybackPositionTicks);
+ }
+}
--
cgit v1.2.3
From b8bac71270f7b2fccbbdda1f28e1f50a554c7383 Mon Sep 17 00:00:00 2001
From: Shadowghost
Date: Tue, 14 Jul 2026 10:05:32 +0200
Subject: remove PlaybackPositionTicks from MediaSourceInfo
---
.../Library/MediaSourceManager.cs | 18 ++++--------------
MediaBrowser.Model/Dto/MediaSourceInfo.cs | 5 -----
.../Library/MediaSourceManagerTests.cs | 12 +++---------
3 files changed, 7 insertions(+), 28 deletions(-)
(limited to 'Emby.Server.Implementations/Library')
diff --git a/Emby.Server.Implementations/Library/MediaSourceManager.cs b/Emby.Server.Implementations/Library/MediaSourceManager.cs
index c64833ddaa..97e00177b6 100644
--- a/Emby.Server.Implementations/Library/MediaSourceManager.cs
+++ b/Emby.Server.Implementations/Library/MediaSourceManager.cs
@@ -418,10 +418,10 @@ namespace Emby.Server.Implementations.Library
}
///
- /// 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.
+ /// 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. Per-user playback position
+ /// is not surfaced on the source itself; it is carried by each version's own UserData.
///
/// The queried item.
/// The item's media sources.
@@ -451,16 +451,6 @@ namespace Emby.Server.Implementations.Library
}
}
- foreach (var source in sources)
- {
- if (source.Id is not null
- && dataBySourceId.TryGetValue(source.Id, out var data)
- && data.PlaybackPositionTicks > 0)
- {
- 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(
diff --git a/MediaBrowser.Model/Dto/MediaSourceInfo.cs b/MediaBrowser.Model/Dto/MediaSourceInfo.cs
index 017e26ef59..75ccdcf276 100644
--- a/MediaBrowser.Model/Dto/MediaSourceInfo.cs
+++ b/MediaBrowser.Model/Dto/MediaSourceInfo.cs
@@ -55,11 +55,6 @@ 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.Server.Implementations.Tests/Library/MediaSourceManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/MediaSourceManagerTests.cs
index b788fb304e..c80f899498 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Library/MediaSourceManagerTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/Library/MediaSourceManagerTests.cs
@@ -150,7 +150,7 @@ namespace Jellyfin.Server.Implementations.Tests.Library
}
[Fact]
- public void GetStaticMediaSources_PrimaryQueried_PopulatesPerVersionPositionsAndDefaultsToMostRecent()
+ public void GetStaticMediaSources_PrimaryQueried_DefaultsToMostRecentlyPlayedVersion()
{
var (primary, alt1, alt2) = SetupVersionGroup();
SetupUserDataBatch(new Dictionary
@@ -161,12 +161,8 @@ namespace Jellyfin.Server.Implementations.Tests.Library
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.
+ // Per-user positions live in each version's UserData, not on the source.
Assert.Equal(alt2.Id.ToString("N"), sources[0].Id);
}
@@ -182,9 +178,8 @@ namespace Jellyfin.Server.Implementations.Tests.Library
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.
+ // played more recently.
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);
}
@@ -197,7 +192,6 @@ namespace Jellyfin.Server.Implementations.Tests.Library
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]
--
cgit v1.2.3
From c6545a8b688380bc6ae6f27a449d5a7f2dd1253a Mon Sep 17 00:00:00 2001
From: Shadowghost
Date: Wed, 15 Jul 2026 11:47:59 +0200
Subject: Limit similar items to user accessible libraries
---
.../Library/SimilarItems/MovieSimilarItemsProvider.cs | 11 ++++++++++-
1 file changed, 10 insertions(+), 1 deletion(-)
(limited to 'Emby.Server.Implementations/Library')
diff --git a/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs b/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs
index b4ed12a20c..57d1f7c770 100644
--- a/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs
+++ b/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs
@@ -53,6 +53,7 @@ public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider _dbProvider;
private readonly IItemQueryHelpers _queryHelpers;
private readonly IServerConfigurationManager _serverConfigurationManager;
+ private readonly ILibraryManager _libraryManager;
///
/// Initializes a new instance of the class.
@@ -60,14 +61,17 @@ public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProviderThe database context factory.
/// The shared query helpers.
/// The server configuration manager.
+ /// The library manager.
public MovieSimilarItemsProvider(
IDbContextFactory dbProvider,
IItemQueryHelpers queryHelpers,
- IServerConfigurationManager serverConfigurationManager)
+ IServerConfigurationManager serverConfigurationManager,
+ ILibraryManager libraryManager)
{
_dbProvider = dbProvider;
_queryHelpers = queryHelpers;
_serverConfigurationManager = serverConfigurationManager;
+ _libraryManager = libraryManager;
}
///
@@ -156,6 +160,11 @@ public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider
Date: Wed, 15 Jul 2026 16:21:42 -0500
Subject: Move GetUserDataBatch to use ResolveUserDataRow when item.UserData
isn't preloaded
---
.../Library/UserDataManager.cs | 51 ++++++++--------
.../Library/UserDataManagerTests.cs | 68 ++++++++++++++++++++--
2 files changed, 87 insertions(+), 32 deletions(-)
(limited to 'Emby.Server.Implementations/Library')
diff --git a/Emby.Server.Implementations/Library/UserDataManager.cs b/Emby.Server.Implementations/Library/UserDataManager.cs
index 58126b97b2..00f155e132 100644
--- a/Emby.Server.Implementations/Library/UserDataManager.cs
+++ b/Emby.Server.Implementations/Library/UserDataManager.cs
@@ -212,37 +212,32 @@ namespace Emby.Server.Implementations.Library
return result;
}
- // Build a single query for all missing items
+ // Build a single query for all missing items. Fetch rows by item alone so rows kept
+ // under keys from older metadata resolve the same way as the in-memory path.
var allItemIds = itemsNeedingQuery.Select(x => x.Item.Id).ToList();
- var allKeys = itemsNeedingQuery.SelectMany(x => x.Keys).Distinct().ToList();
- if (allKeys.Count > 0)
- {
- using var context = _repository.CreateDbContext();
- var userDataArray = context.UserData
- .AsNoTracking()
- .Where(e => e.UserId.Equals(user.Id))
- .WhereOneOrMany(allItemIds, e => e.ItemId)
- .WhereOneOrMany(allKeys, e => e.CustomDataKey)
- .ToArray();
-
- var userDataByItem = userDataArray.GroupBy(e => e.ItemId).ToDictionary(g => g.Key, g => g.ToArray());
- foreach (var (item, keys) in itemsNeedingQuery)
+ using var context = _repository.CreateDbContext();
+ var userDataArray = context.UserData
+ .AsNoTracking()
+ .Where(e => e.UserId.Equals(user.Id))
+ .WhereOneOrMany(allItemIds, e => e.ItemId)
+ .ToArray();
+
+ var userDataByItem = userDataArray.GroupBy(e => e.ItemId).ToDictionary(g => g.Key, g => g.ToArray());
+ foreach (var (item, keys) in itemsNeedingQuery)
+ {
+ UserItemData userData;
+ if (userDataByItem.TryGetValue(item.Id, out var itemUserData) && itemUserData.Length > 0)
{
- UserItemData userData;
- if (userDataByItem.TryGetValue(item.Id, out var itemUserData) && itemUserData.Length > 0)
- {
- var directDataReference = itemUserData.FirstOrDefault(e => e.CustomDataKey == item.Id.ToString("N"));
- userData = directDataReference is not null ? Map(directDataReference) : Map(itemUserData.First());
- }
- else
- {
- userData = new UserItemData { Key = keys.Count > 0 ? keys[0] : string.Empty };
- }
-
- result[item.Id] = userData;
- var cacheKey = GetCacheKey(user.InternalId, item.Id);
- _cache.AddOrUpdate(cacheKey, userData);
+ userData = Map(ResolveUserDataRow(item, itemUserData)!);
}
+ else
+ {
+ userData = new UserItemData { Key = keys.Count > 0 ? keys[0] : string.Empty };
+ }
+
+ result[item.Id] = userData;
+ var cacheKey = GetCacheKey(user.InternalId, item.Id);
+ _cache.AddOrUpdate(cacheKey, userData);
}
return result;
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/UserDataManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/UserDataManagerTests.cs
index 092e87b9ff..bd14ca008d 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Library/UserDataManagerTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/Library/UserDataManagerTests.cs
@@ -3,35 +3,68 @@ using System.Collections.Generic;
using Emby.Server.Implementations.Library;
using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Database.Implementations.Locking;
+using Jellyfin.Database.Providers.Sqlite;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Model.Configuration;
+using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
using AudioBook = MediaBrowser.Controller.Entities.AudioBook;
namespace Jellyfin.Server.Implementations.Tests.Library;
-public class UserDataManagerTests
+public sealed class UserDataManagerTests : IDisposable
{
+ private readonly SqliteConnection _connection;
+ private readonly DbContextOptions _dbOptions;
private readonly UserDataManager _userDataManager;
private readonly User _user;
public UserDataManagerTests()
{
+ _connection = new SqliteConnection("Data Source=:memory:");
+ _connection.Open();
+
+ _dbOptions = new DbContextOptionsBuilder()
+ .UseSqlite(_connection)
+ .Options;
+
+ using (var ctx = CreateDbContext())
+ {
+ ctx.Database.EnsureCreated();
+ }
+
+ var factory = new Mock>();
+ factory.Setup(f => f.CreateDbContext()).Returns(CreateDbContext);
+
var config = new Mock();
config.SetupGet(c => c.Configuration).Returns(new ServerConfiguration());
- var repository = Mock.Of>();
-
- _userDataManager = new UserDataManager(config.Object, repository);
+ _userDataManager = new UserDataManager(config.Object, factory.Object);
_user = new User("user", "auth-provider", "reset-provider")
{
Id = Guid.NewGuid()
};
}
+ public void Dispose()
+ {
+ _connection.Dispose();
+ }
+
+ private JellyfinDbContext CreateDbContext()
+ {
+ return new JellyfinDbContext(
+ _dbOptions,
+ NullLogger.Instance,
+ new SqliteDatabaseProvider(null!, NullLogger.Instance),
+ new NoLockBehavior(NullLogger.Instance));
+ }
+
private AudioBook CreateAudioBook()
{
// GetUserDataKeys(): ["Author-Series-0001Book Title", "
- "]
@@ -146,4 +179,31 @@ public class UserDataManagerTests
Assert.NotNull(userData);
Assert.Equal(222, userData.PlaybackPositionTicks);
}
+
+ [Fact]
+ public void GetUserDataBatch_DatabaseFallback_ResolvesRowsByKeyOrder()
+ {
+ // no preloaded navigation data, so the batch takes the database fallback
+ var fossilItem = CreateAudioBook();
+ var retiredItem = CreateAudioBook();
+
+ using (var ctx = CreateDbContext())
+ {
+ ctx.Users.Add(_user);
+ ctx.BaseItems.Add(new BaseItemEntity { Id = fossilItem.Id, Type = typeof(AudioBook).FullName! });
+ ctx.BaseItems.Add(new BaseItemEntity { Id = retiredItem.Id, Type = typeof(AudioBook).FullName! });
+
+ // the stale id-key row is inserted first so selection by row order would return it
+ ctx.UserData.AddRange(
+ CreateUserDataRow(fossilItem, fossilItem.GetUserDataKeys()[1], 111),
+ CreateUserDataRow(fossilItem, fossilItem.GetUserDataKeys()[0], 222),
+ CreateUserDataRow(retiredItem, "Author-Old Album-0001Old File Name", 333));
+ ctx.SaveChanges();
+ }
+
+ var result = _userDataManager.GetUserDataBatch([fossilItem, retiredItem], _user);
+
+ Assert.Equal(222, result[fossilItem.Id].PlaybackPositionTicks);
+ Assert.Equal(333, result[retiredItem.Id].PlaybackPositionTicks);
+ }
}
--
cgit v1.2.3
From cdf7ce0fc4504eda29a2b4060fbe1fe628e2ee0a Mon Sep 17 00:00:00 2001
From: Shadowghost
Date: Thu, 16 Jul 2026 14:21:03 +0200
Subject: Fix user data batch query perf
---
Emby.Server.Implementations/Library/UserDataManager.cs | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
(limited to 'Emby.Server.Implementations/Library')
diff --git a/Emby.Server.Implementations/Library/UserDataManager.cs b/Emby.Server.Implementations/Library/UserDataManager.cs
index 40cd2bb69c..19b42a3a11 100644
--- a/Emby.Server.Implementations/Library/UserDataManager.cs
+++ b/Emby.Server.Implementations/Library/UserDataManager.cs
@@ -291,8 +291,8 @@ namespace Emby.Server.Implementations.Library
{
using var dbContext = _repository.CreateDbContext();
withLocalAlternates = dbContext.LinkedChildren
- .Where(lc => lc.ChildType == Jellyfin.Database.Implementations.Entities.LinkedChildType.LocalAlternateVersion
- && localProbeIds.Contains(lc.ParentId))
+ .Where(lc => lc.ChildType == Jellyfin.Database.Implementations.Entities.LinkedChildType.LocalAlternateVersion)
+ .WhereOneOrMany(localProbeIds, lc => lc.ParentId)
.Select(lc => lc.ParentId)
.Distinct()
.ToHashSet();
--
cgit v1.2.3
From 1a45fc82b5c0b7c7623be4117d15140cda020387 Mon Sep 17 00:00:00 2001
From: Shadowghost
Date: Fri, 17 Jul 2026 16:46:13 +0200
Subject: Sanitize media attachment and lyric paths against traversal
---
Emby.Server.Implementations/Library/PathManager.cs | 15 +++++-
.../Attachments/AttachmentExtractor.cs | 7 ++-
MediaBrowser.Providers/Lyric/LyricManager.cs | 4 +-
tests/Jellyfin.Extensions.Tests/PathHelperTests.cs | 60 ++++++++++++++++++++++
4 files changed, 81 insertions(+), 5 deletions(-)
create mode 100644 tests/Jellyfin.Extensions.Tests/PathHelperTests.cs
(limited to 'Emby.Server.Implementations/Library')
diff --git a/Emby.Server.Implementations/Library/PathManager.cs b/Emby.Server.Implementations/Library/PathManager.cs
index fad948ad97..2a50fcc7fe 100644
--- a/Emby.Server.Implementations/Library/PathManager.cs
+++ b/Emby.Server.Implementations/Library/PathManager.cs
@@ -2,6 +2,7 @@ using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
+using Jellyfin.Extensions;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities;
@@ -43,7 +44,19 @@ public class PathManager : IPathManager
public string? GetAttachmentPath(string mediaSourceId, string fileName)
{
var folder = GetAttachmentFolderPath(mediaSourceId);
- return folder is null ? null : Path.Combine(folder, fileName);
+ if (folder is null)
+ {
+ return null;
+ }
+
+ var safeName = PathHelper.GetSafeLeafFileName(fileName);
+ if (safeName is null)
+ {
+ _logger.LogWarning("Rejecting attachment filename '{FileName}' for MediaSource {MediaSourceId}: not a valid leaf name.", fileName, mediaSourceId);
+ return null;
+ }
+
+ return Path.Combine(folder, safeName);
}
///
diff --git a/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs b/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs
index 9dd3dcecba..aff4af9581 100644
--- a/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs
+++ b/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs
@@ -8,6 +8,7 @@ using System.Text;
using System.Threading;
using System.Threading.Tasks;
using AsyncKeyedLock;
+using Jellyfin.Extensions;
using MediaBrowser.Common.Extensions;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.IO;
@@ -101,7 +102,7 @@ namespace MediaBrowser.MediaEncoding.Attachments
CancellationToken cancellationToken)
{
var shouldExtractOneByOne = mediaSource.MediaAttachments.Any(a => !string.IsNullOrEmpty(a.FileName)
- && (a.FileName.Contains('/', StringComparison.OrdinalIgnoreCase) || a.FileName.Contains('\\', StringComparison.OrdinalIgnoreCase)));
+ && PathHelper.GetSafeLeafFileName(a.FileName) != a.FileName);
if (shouldExtractOneByOne && !inputFile.EndsWith(".mks", StringComparison.OrdinalIgnoreCase))
{
await ExtractAllAttachmentsIndividuallyInternal(
@@ -387,7 +388,9 @@ namespace MediaBrowser.MediaEncoding.Attachments
using (await _semaphoreLocks.LockAsync(attachmentFolderPath, cancellationToken).ConfigureAwait(false))
{
- var attachmentPath = _pathManager.GetAttachmentPath(mediaSource.Id, mediaAttachment.FileName ?? mediaAttachment.Index.ToString(CultureInfo.InvariantCulture))!;
+ var indexName = mediaAttachment.Index.ToString(CultureInfo.InvariantCulture);
+ var attachmentPath = _pathManager.GetAttachmentPath(mediaSource.Id, mediaAttachment.FileName ?? indexName)
+ ?? _pathManager.GetAttachmentPath(mediaSource.Id, indexName)!;
if (!File.Exists(attachmentPath))
{
await ExtractAttachmentInternal(
diff --git a/MediaBrowser.Providers/Lyric/LyricManager.cs b/MediaBrowser.Providers/Lyric/LyricManager.cs
index 913a104a0d..af31e373ef 100644
--- a/MediaBrowser.Providers/Lyric/LyricManager.cs
+++ b/MediaBrowser.Providers/Lyric/LyricManager.cs
@@ -398,7 +398,7 @@ public class LyricManager : ILyricManager
{
var mediaFolderPath = Path.GetFullPath(Path.Combine(audio.ContainingFolderPath, saveFileName));
// TODO: Add some error handling to the API user: return BadRequest("Could not save lyric, bad path.");
- if (mediaFolderPath.StartsWith(audio.ContainingFolderPath, StringComparison.Ordinal))
+ if (PathHelper.IsContainedIn(audio.ContainingFolderPath, mediaFolderPath))
{
savePaths.Add(mediaFolderPath);
}
@@ -407,7 +407,7 @@ public class LyricManager : ILyricManager
var internalPath = Path.GetFullPath(Path.Combine(audio.GetInternalMetadataPath(), saveFileName));
// TODO: Add some error to the user: return BadRequest("Could not save lyric, bad path.");
- if (internalPath.StartsWith(audio.GetInternalMetadataPath(), StringComparison.Ordinal))
+ if (PathHelper.IsContainedIn(audio.GetInternalMetadataPath(), internalPath))
{
savePaths.Add(internalPath);
}
diff --git a/tests/Jellyfin.Extensions.Tests/PathHelperTests.cs b/tests/Jellyfin.Extensions.Tests/PathHelperTests.cs
new file mode 100644
index 0000000000..71fd853ba2
--- /dev/null
+++ b/tests/Jellyfin.Extensions.Tests/PathHelperTests.cs
@@ -0,0 +1,60 @@
+using System.IO;
+using Jellyfin.Extensions;
+using Xunit;
+
+namespace Jellyfin.Extensions.Tests
+{
+ public static class PathHelperTests
+ {
+ [Theory]
+ [InlineData("file.txt", "file.txt")]
+ [InlineData("sub/file.txt", "file.txt")]
+ [InlineData("../../etc/passwd", "passwd")]
+ public static void GetSafeLeafFileName_ReducesToLeaf(string input, string expected)
+ {
+ Assert.Equal(expected, PathHelper.GetSafeLeafFileName(input));
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData(".")]
+ [InlineData("..")]
+ public static void GetSafeLeafFileName_RejectsUnusableLeaf(string? input)
+ {
+ Assert.Null(PathHelper.GetSafeLeafFileName(input));
+ }
+
+ [Fact]
+ public static void IsContainedIn_ChildPath_ReturnsTrue()
+ {
+ var root = Path.Combine(Path.GetTempPath(), "root");
+ var child = Path.Combine(root, "sub", "file.txt");
+ Assert.True(PathHelper.IsContainedIn(root, child));
+ }
+
+ [Fact]
+ public static void IsContainedIn_RootItself_ReturnsTrue()
+ {
+ var root = Path.Combine(Path.GetTempPath(), "root");
+ Assert.True(PathHelper.IsContainedIn(root, root));
+ }
+
+ [Fact]
+ public static void IsContainedIn_TraversalEscape_ReturnsFalse()
+ {
+ var root = Path.Combine(Path.GetTempPath(), "root");
+ var escape = Path.Combine(root, "..", "..", "etc", "passwd");
+ Assert.False(PathHelper.IsContainedIn(root, escape));
+ }
+
+ [Fact]
+ public static void IsContainedIn_SiblingPrefixCollision_ReturnsFalse()
+ {
+ // "/var/data" must not be accepted as a parent of "/var/dataset".
+ var root = Path.Combine(Path.GetTempPath(), "data");
+ var sibling = Path.Combine(Path.GetTempPath(), "dataset", "file.txt");
+ Assert.False(PathHelper.IsContainedIn(root, sibling));
+ }
+ }
+}
--
cgit v1.2.3
From ffe075650c11141667ccef04575d17b2c2266792 Mon Sep 17 00:00:00 2001
From: theguymadmax
Date: Mon, 20 Jul 2026 06:23:20 -0400
Subject: Add TVDB provider ID support for movies (#17255)
Add TVDB provider ID support for movies
---
.../Library/Resolvers/Movies/MovieResolver.cs | 21 +++++++++++++++------
1 file changed, 15 insertions(+), 6 deletions(-)
(limited to 'Emby.Server.Implementations/Library')
diff --git a/Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs b/Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs
index 68b66ab7f5..80375ae12d 100644
--- a/Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs
+++ b/Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs
@@ -376,15 +376,24 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies
// We need to only look at the name of this actual item (not parents)
var justName = item.IsInMixedFolder ? Path.GetFileName(item.Path.AsSpan()) : Path.GetFileName(item.ContainingFolderPath.AsSpan());
- var tmdbid = justName.GetAttributeValue("tmdbid");
+ // The fallback filename is only used when the item isn't in a mixed folder
+ var fileName = item.IsInMixedFolder ? ReadOnlySpan.Empty : Path.GetFileName(item.Path.AsSpan());
- // If not in a mixed folder and ID not found in folder path, check filename
- if (string.IsNullOrEmpty(tmdbid) && !item.IsInMixedFolder)
+ item.TrySetProviderId(MetadataProvider.Tmdb, GetIdFromNameOrPath(justName, fileName, "tmdbid"));
+ item.TrySetProviderId(MetadataProvider.Tvdb, GetIdFromNameOrPath(justName, fileName, "tvdbid"));
+
+ string GetIdFromNameOrPath(ReadOnlySpan name, ReadOnlySpan fallbackName, string attribute)
{
- tmdbid = Path.GetFileName(item.Path.AsSpan()).GetAttributeValue("tmdbid");
- }
+ var id = name.GetAttributeValue(attribute);
+
+ // If not in a mixed folder and ID not found in folder path, check filename
+ if (string.IsNullOrEmpty(id) && !item.IsInMixedFolder)
+ {
+ id = fallbackName.GetAttributeValue(attribute);
+ }
- item.TrySetProviderId(MetadataProvider.Tmdb, tmdbid);
+ return id;
+ }
if (!string.IsNullOrEmpty(item.Path))
{
--
cgit v1.2.3
From 5d580abb08d9d23f54e74050fdaa8fcdbc21571f Mon Sep 17 00:00:00 2001
From: Paolo Antinori
Date: Tue, 21 Jul 2026 08:20:40 +0200
Subject: fix: avoid NRE when sorting by user-dependent keys without a user
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A query sorted by a user-dependent key (PlayCount, IsFavoriteOrLiked,
DatePlayed, IsPlayed, IsUnplayed) but carrying no User caused a
NullReferenceException inside UserDataManager.GetUserData, surfacing as
"Failed to compare two elements in the array" (InvalidOperationException
wrapping the NRE from the LINQ sort) and 500-ing the /Items request.
Root cause: LibraryManager.GetComparer assigned comparer.User = user
without a null guard, so PlayCountComparer.GetValue called
UserDataManager.GetUserData(null, item), dereferencing user.Id.
Two-part fix:
- LibraryManager.GetComparer: when user is null and the sort key requires a
user (IUserBaseItemComparer), substitute the SortName comparer so the
result stays deterministic instead of 500-ing. SortName is the project's
canonical tiebreaker (ItemsController injects it for album-by-artist).
- UserDataManager.GetUserData: ArgumentNullException.ThrowIfNull(user) as
defense in depth (matches the existing guards on the SaveUserData
overloads in the same file). On master this overload was rewritten to use
ResolveUserDataRow, so the NRE dereferences user.Id rather than
user.InternalId as on the release branch — same bug, different line.
Also fixes DateLastMediaAddedComparer being statically mis-tagged as
IUserBaseItemComparer: its GetDate is static and never reads User, so it
does not need one. Without this, the SortName fallback above would wrongly
engage for DateLastContentAdded on anonymous queries (returning SortName
order instead of date order). Re-tagged to IBaseItemComparer and dropped the
unused User/UserManager/UserDataManager properties.
Tests:
- UserDataManagerTests.GetUserData_NullUser_ThrowsArgumentNullException:
reproduces the crash (NRE -> now ArgumentNullException). Added to master's
existing UserDataManagerTests.
- LibraryManagerSortTests.Sort_UserDependentKey_NullUser_FallsBackToSortNameWithoutThrowing:
Sort with a user-dependent key + null user no longer throws and returns
items ordered by the SortName fallback (direction preserved).
- LibraryManagerSortTests.Sort_DateLastContentAdded_NullUser_OrdersByDateNotSortName:
guards that DateLastContentAdded still sorts by date with no user (fixture
chosen so date-desc and SortName-desc disagree, so a revert is caught).
Full Jellyfin.Server.Implementations.Tests suite: 642 passed, 0 failed.
Fixes #17393
---
.../Library/LibraryManager.cs | 9 +-
.../Library/UserDataManager.cs | 1 +
.../Sorting/DateLastMediaAddedComparer.cs | 22 +----
.../Library/LibraryManagerSortTests.cs | 101 +++++++++++++++++++++
.../Library/UserDataManagerTests.cs | 7 ++
5 files changed, 118 insertions(+), 22 deletions(-)
create mode 100644 tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManagerSortTests.cs
(limited to 'Emby.Server.Implementations/Library')
diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs
index 3691f4e19d..8d67f0c6c7 100644
--- a/Emby.Server.Implementations/Library/LibraryManager.cs
+++ b/Emby.Server.Implementations/Library/LibraryManager.cs
@@ -2315,9 +2315,16 @@ namespace Emby.Server.Implementations.Library
{
var comparer = Comparers.FirstOrDefault(c => name == c.Type);
- // If it requires a user, create a new one, and assign the user
+ // User-dependent comparers (IUserBaseItemComparer) need a User. With no user
+ // (anonymous/API-key /Items requests), a user-dependent sort key is a caller contract
+ // violation — throw rather than silently falling back to a different key.
if (comparer is IUserBaseItemComparer)
{
+ if (user is null)
+ {
+ throw new ArgumentException($"Sort key '{name}' requires a user, but none was provided.");
+ }
+
var userComparer = (IUserBaseItemComparer)Activator.CreateInstance(comparer.GetType())!; // only null for Nullable instances
userComparer.User = user;
diff --git a/Emby.Server.Implementations/Library/UserDataManager.cs b/Emby.Server.Implementations/Library/UserDataManager.cs
index f5c41e5670..0680046c11 100644
--- a/Emby.Server.Implementations/Library/UserDataManager.cs
+++ b/Emby.Server.Implementations/Library/UserDataManager.cs
@@ -352,6 +352,7 @@ namespace Emby.Server.Implementations.Library
///
public UserItemData? GetUserData(User user, BaseItem item)
{
+ ArgumentNullException.ThrowIfNull(user);
var row = ResolveUserDataRow(item, item.UserData?.Where(e => e.UserId.Equals(user.Id)));
return row is not null ? Map(row) : new UserItemData()
{
diff --git a/Emby.Server.Implementations/Sorting/DateLastMediaAddedComparer.cs b/Emby.Server.Implementations/Sorting/DateLastMediaAddedComparer.cs
index f10e7fcbb7..4159f8cf7d 100644
--- a/Emby.Server.Implementations/Sorting/DateLastMediaAddedComparer.cs
+++ b/Emby.Server.Implementations/Sorting/DateLastMediaAddedComparer.cs
@@ -3,34 +3,14 @@
using System;
using Jellyfin.Data.Enums;
-using Jellyfin.Database.Implementations.Entities;
using MediaBrowser.Controller.Entities;
-using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Sorting;
using MediaBrowser.Model.Querying;
namespace Emby.Server.Implementations.Sorting
{
- public class DateLastMediaAddedComparer : IUserBaseItemComparer
+ public class DateLastMediaAddedComparer : IBaseItemComparer
{
- ///
- /// Gets or sets the user.
- ///
- /// The user.
- public User User { get; set; }
-
- ///
- /// Gets or sets the user manager.
- ///
- /// The user manager.
- public IUserManager UserManager { get; set; }
-
- ///
- /// Gets or sets the user data manager.
- ///
- /// The user data manager.
- public IUserDataManager UserDataManager { get; set; }
-
///
/// Gets the name.
///
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManagerSortTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManagerSortTests.cs
new file mode 100644
index 0000000000..9cec9d6736
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManagerSortTests.cs
@@ -0,0 +1,101 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using AutoFixture;
+using AutoFixture.AutoMoq;
+using Emby.Naming.Common;
+using Emby.Server.Implementations.Library;
+using Emby.Server.Implementations.Sorting;
+using Jellyfin.Data.Enums;
+using Jellyfin.Database.Implementations.Enums;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.Audio;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Persistence;
+using MediaBrowser.Controller.Resolvers;
+using MediaBrowser.Controller.Sorting;
+using MediaBrowser.Model.IO;
+using Moq;
+using Xunit;
+using BaseItem = MediaBrowser.Controller.Entities.BaseItem;
+
+namespace Jellyfin.Server.Implementations.Tests.Library;
+
+public class LibraryManagerSortTests
+{
+ [Fact]
+ public void Sort_UserDependentKey_NullUser_ThrowsArgumentException()
+ {
+ var libraryManager = CreateLibraryManager(
+ new IBaseItemComparer[] { new PlayCountComparer(), new SortNameComparer() });
+
+ BaseItem[] items =
+ {
+ new Audio { Name = "Zulu", SortName = "Zulu", Id = Guid.NewGuid() },
+ new Audio { Name = "Alpha", SortName = "Alpha", Id = Guid.NewGuid() },
+ };
+
+ // A user-dependent sort key with no user is a caller contract violation — throw,
+ // don't silently fall back to a different key (review feedback on #17395).
+ Assert.Throws(() => libraryManager.Sort(
+ items,
+ user: null,
+ new[] { (ItemSortBy.PlayCount, SortOrder.Descending) }).ToArray());
+ }
+
+ [Fact]
+ public void Sort_DateLastContentAdded_NullUser_OrdersByDateNotSortName()
+ {
+ // DateLastMediaAddedComparer does not use User (its GetDate is static), so it must NOT be
+ // treated as a user-dependent comparer: with no user it should still sort by date, not fall
+ // back to SortName.
+ var libraryManager = CreateLibraryManager(
+ new IBaseItemComparer[] { new DateLastMediaAddedComparer(), new SortNameComparer() });
+
+ // Names are chosen so date-descending and SortName-descending DISAGREE: Alpha is newest
+ // (date-desc rank 1), but Zulu sorts last alphabetically (SortName-desc rank 1). If the
+ // comparer were still tagged IUserBaseItemComparer, the null-user SortName fallback would
+ // return [Zulu, Mike, Alpha] and this assertion would fail.
+ BaseItem[] items =
+ {
+ MakeFolder("Alpha", new DateTime(2026, 1, 1)),
+ MakeFolder("Mike", new DateTime(2025, 1, 1)),
+ MakeFolder("Zulu", new DateTime(2024, 1, 1))
+ };
+
+ var sorted = libraryManager.Sort(
+ items,
+ user: null,
+ new[] { (ItemSortBy.DateLastContentAdded, SortOrder.Descending) }).ToArray();
+
+ // Descending by date => newest first: Alpha, Mike, Zulu. (SortName-desc would be Zulu, Mike, Alpha.)
+ Assert.Equal(new[] { "Alpha", "Mike", "Zulu" }, sorted.Select(i => i.Name));
+ }
+
+ private static Folder MakeFolder(string name, DateTime dateLastMediaAdded)
+ => new() { Name = name, Id = Guid.NewGuid(), DateLastMediaAdded = dateLastMediaAdded };
+
+ private static Emby.Server.Implementations.Library.LibraryManager CreateLibraryManager(IReadOnlyCollection comparers)
+ {
+ var fixture = new Fixture().Customize(new AutoMoqCustomization());
+ fixture.Register(() => new NamingOptions());
+ var configMock = fixture.Freeze>();
+ configMock.Setup(c => c.ApplicationPaths.ProgramDataPath).Returns("/data");
+ // BaseItem.SortName/CreateSortName dereference this static; set it so SortName-fallback
+ // paths don't NRE in-process (mirrors AudioResolverTests in the sibling test project).
+ BaseItem.ConfigurationManager ??= configMock.Object;
+ var itemRepository = fixture.Freeze>();
+ itemRepository.Setup(i => i.RetrieveItem(It.IsAny())).Returns(null);
+ var fileSystemMock = fixture.Freeze>();
+ fileSystemMock.Setup(f => f.GetFileInfo(It.IsAny())).Returns(path => new FileSystemMetadata { FullName = path });
+
+ return fixture.Build().Do(s => s.AddParts(
+ fixture.Create>(),
+ fixture.Create>(),
+ fixture.Create>(),
+ comparers,
+ fixture.Create>()))
+ .Create();
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/UserDataManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/UserDataManagerTests.cs
index bd14ca008d..ba3127bc08 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Library/UserDataManagerTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/Library/UserDataManagerTests.cs
@@ -206,4 +206,11 @@ public sealed class UserDataManagerTests : IDisposable
Assert.Equal(222, result[fossilItem.Id].PlaybackPositionTicks);
Assert.Equal(333, result[retiredItem.Id].PlaybackPositionTicks);
}
+
+ [Fact]
+ public void GetUserData_NullUser_ThrowsArgumentNullException()
+ {
+ var item = CreateAudioBook();
+ Assert.Throws(() => _userDataManager.GetUserData(null!, item));
+ }
}
--
cgit v1.2.3
From 8b70582561754d22fababede84316beead46d3a9 Mon Sep 17 00:00:00 2001
From: Paolo Antinori
Date: Sat, 25 Jul 2026 12:50:32 +0200
Subject: Remove added comments (#17395 review)
---
Emby.Server.Implementations/Library/LibraryManager.cs | 3 ---
.../Library/LibraryManagerSortTests.cs | 12 ------------
2 files changed, 15 deletions(-)
(limited to 'Emby.Server.Implementations/Library')
diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs
index 8d67f0c6c7..1c2d314341 100644
--- a/Emby.Server.Implementations/Library/LibraryManager.cs
+++ b/Emby.Server.Implementations/Library/LibraryManager.cs
@@ -2315,9 +2315,6 @@ namespace Emby.Server.Implementations.Library
{
var comparer = Comparers.FirstOrDefault(c => name == c.Type);
- // User-dependent comparers (IUserBaseItemComparer) need a User. With no user
- // (anonymous/API-key /Items requests), a user-dependent sort key is a caller contract
- // violation — throw rather than silently falling back to a different key.
if (comparer is IUserBaseItemComparer)
{
if (user is null)
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManagerSortTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManagerSortTests.cs
index 9cec9d6736..65ec41291d 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManagerSortTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManagerSortTests.cs
@@ -36,8 +36,6 @@ public class LibraryManagerSortTests
new Audio { Name = "Alpha", SortName = "Alpha", Id = Guid.NewGuid() },
};
- // A user-dependent sort key with no user is a caller contract violation — throw,
- // don't silently fall back to a different key (review feedback on #17395).
Assert.Throws(() => libraryManager.Sort(
items,
user: null,
@@ -47,16 +45,9 @@ public class LibraryManagerSortTests
[Fact]
public void Sort_DateLastContentAdded_NullUser_OrdersByDateNotSortName()
{
- // DateLastMediaAddedComparer does not use User (its GetDate is static), so it must NOT be
- // treated as a user-dependent comparer: with no user it should still sort by date, not fall
- // back to SortName.
var libraryManager = CreateLibraryManager(
new IBaseItemComparer[] { new DateLastMediaAddedComparer(), new SortNameComparer() });
- // Names are chosen so date-descending and SortName-descending DISAGREE: Alpha is newest
- // (date-desc rank 1), but Zulu sorts last alphabetically (SortName-desc rank 1). If the
- // comparer were still tagged IUserBaseItemComparer, the null-user SortName fallback would
- // return [Zulu, Mike, Alpha] and this assertion would fail.
BaseItem[] items =
{
MakeFolder("Alpha", new DateTime(2026, 1, 1)),
@@ -69,7 +60,6 @@ public class LibraryManagerSortTests
user: null,
new[] { (ItemSortBy.DateLastContentAdded, SortOrder.Descending) }).ToArray();
- // Descending by date => newest first: Alpha, Mike, Zulu. (SortName-desc would be Zulu, Mike, Alpha.)
Assert.Equal(new[] { "Alpha", "Mike", "Zulu" }, sorted.Select(i => i.Name));
}
@@ -82,8 +72,6 @@ public class LibraryManagerSortTests
fixture.Register(() => new NamingOptions());
var configMock = fixture.Freeze>();
configMock.Setup(c => c.ApplicationPaths.ProgramDataPath).Returns("/data");
- // BaseItem.SortName/CreateSortName dereference this static; set it so SortName-fallback
- // paths don't NRE in-process (mirrors AudioResolverTests in the sibling test project).
BaseItem.ConfigurationManager ??= configMock.Object;
var itemRepository = fixture.Freeze>();
itemRepository.Setup(i => i.RetrieveItem(It.IsAny())).Returns(null);
--
cgit v1.2.3