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 ++++++++++++++++++++++ 1 file changed, 36 insertions(+) (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(); + } } } -- cgit v1.2.3 From 0874a26131a5a1e9d62fb7231acbaf9eb921b5c8 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Tue, 9 Jun 2026 23:23:03 +0200 Subject: Coalesce alternate-version progress onto primary in resume filter --- Emby.Server.Implementations/Dto/DtoService.cs | 19 ++- .../Library/MediaSourceManager.cs | 72 +++++++++- .../Library/UserDataManager.cs | 70 ++++++++++ .../Item/BaseItemRepository.TranslateQuery.cs | 18 ++- .../Library/IUserDataManager.cs | 18 +++ .../Library/VersionResumeData.cs | 30 +++++ MediaBrowser.Model/Dto/MediaSourceInfo.cs | 5 + .../Library/VersionResumeDataTests.cs | 44 ++++++ .../Item/AlternateVersionQueryTranslationTests.cs | 147 +++++++++++++++++++++ .../Library/MediaSourceManagerTests.cs | 110 +++++++++++++++ 10 files changed, 525 insertions(+), 8 deletions(-) create mode 100644 MediaBrowser.Controller/Library/VersionResumeData.cs create mode 100644 tests/Jellyfin.Controller.Tests/Library/VersionResumeDataTests.cs create mode 100644 tests/Jellyfin.Server.Implementations.Tests/Item/AlternateVersionQueryTranslationTests.cs (limited to 'Emby.Server.Implementations/Library') diff --git a/Emby.Server.Implementations/Dto/DtoService.cs b/Emby.Server.Implementations/Dto/DtoService.cs index c9c31ebee4..2bf478953e 100644 --- a/Emby.Server.Implementations/Dto/DtoService.cs +++ b/Emby.Server.Implementations/Dto/DtoService.cs @@ -167,9 +167,13 @@ namespace Emby.Server.Implementations.Dto // Batch-fetch user data for all items Dictionary? userDataBatch = null; + IReadOnlyDictionary? resumeDataBatch = null; if (user is not null && options.EnableUserData) { userDataBatch = _userDataRepository.GetUserDataBatch(accessibleItems, user); + + // For items with alternate versions, the most recently played version drives resume. + resumeDataBatch = _userDataRepository.GetResumeUserDataBatch(accessibleItems, user); } // Pre-compute collection folders once to avoid N+1 queries in CanDelete @@ -248,7 +252,8 @@ namespace Emby.Server.Implementations.Dto allCollectionFolders, childCountBatch, playedCountBatch, - artistsBatch); + artistsBatch, + resumeDataBatch?.GetValueOrDefault(item.Id)); if (item is LiveTvChannel tvChannel) { @@ -309,7 +314,8 @@ namespace Emby.Server.Implementations.Dto List? allCollectionFolders = null, Dictionary? childCountBatch = null, Dictionary? playedCountBatch = null, - IReadOnlyDictionary? artistsBatch = null) + IReadOnlyDictionary? artistsBatch = null, + VersionResumeData? resumeData = null) { var dto = new BaseItemDto { @@ -353,7 +359,8 @@ namespace Emby.Server.Implementations.Dto options, userData, childCountBatch, - playedCountBatch); + playedCountBatch, + resumeData); } if (item is IHasMediaSources @@ -538,7 +545,8 @@ namespace Emby.Server.Implementations.Dto DtoOptions options, UserItemData? userData = null, Dictionary? childCountBatch = null, - Dictionary? playedCountBatch = null) + Dictionary? playedCountBatch = null, + VersionResumeData? resumeData = null) { if (item.IsFolder) { @@ -600,6 +608,9 @@ namespace Emby.Server.Implementations.Dto // Use pre-fetched user data dto.UserData = GetUserItemDataDto(userData, item.Id); item.FillUserDataDtoValues(dto.UserData, userData, dto, user, options); + + // For items with alternate versions, the most recently played version drives resume. + resumeData?.ApplyTo(dto.UserData); } else { diff --git a/Emby.Server.Implementations/Library/MediaSourceManager.cs b/Emby.Server.Implementations/Library/MediaSourceManager.cs index 9ccfefa86e..4a520cad0d 100644 --- a/Emby.Server.Implementations/Library/MediaSourceManager.cs +++ b/Emby.Server.Implementations/Library/MediaSourceManager.cs @@ -229,7 +229,11 @@ namespace Emby.Server.Implementations.Library list.Add(source); } - return SortMediaSources(list).ToArray(); + var preferredId = mediaSources.Count > 0 && Guid.TryParse(mediaSources[0].Id, out var topSourceId) + ? topSourceId + : item.Id; + + return SortMediaSources(list, preferredId).ToArray(); } /// > @@ -400,6 +404,72 @@ namespace Emby.Server.Implementations.Library source.SupportsDirectStream = user.HasPermission(PermissionKind.EnablePlaybackRemuxing); } } + + sources = SetAlternateVersionResumeStates(item, sources, user); + } + + return sources; + } + + /// + /// Populates each source's own playback position for the user and, when the queried item is a + /// primary, moves the most recently played version to the front so that resuming without an + /// explicit source selection plays the version that was last watched. A directly queried + /// alternate version keeps its own source first. + /// + /// The queried item. + /// The item's media sources. + /// The user. + /// The media sources, reordered when a version drives resume. + private IReadOnlyList SetAlternateVersionResumeStates(BaseItem item, IReadOnlyList sources, User user) + { + // For a video, multiple sources means alternate versions. + if (item is not Video video || sources.Count < 2) + { + return sources; + } + + var versions = video.GetAllVersions(); + if (versions.Count < 2) + { + return sources; + } + + var userDataByVersion = _userDataManager.GetUserDataBatch(versions, user); + var dataBySourceId = new Dictionary(versions.Count, StringComparer.OrdinalIgnoreCase); + foreach (var version in versions) + { + if (userDataByVersion.TryGetValue(version.Id, out var data)) + { + dataBySourceId[version.Id.ToString("N", CultureInfo.InvariantCulture)] = data; + } + } + + MediaSourceInfo resumeSource = null; + UserItemData resumeData = null; + foreach (var source in sources) + { + if (source.Id is null + || !dataBySourceId.TryGetValue(source.Id, out var data) + || data.PlaybackPositionTicks <= 0) + { + continue; + } + + source.PlaybackPositionTicks = data.PlaybackPositionTicks; + + if (resumeData is null || (data.LastPlayedDate ?? DateTime.MinValue) > (resumeData.LastPlayedDate ?? DateTime.MinValue)) + { + resumeSource = source; + resumeData = data; + } + } + + if (resumeSource is not null && !video.PrimaryVersionId.HasValue && !ReferenceEquals(sources[0], resumeSource)) + { + var reordered = new List(sources.Count) { resumeSource }; + reordered.AddRange(sources.Where(s => !ReferenceEquals(s, resumeSource))); + return reordered; } return sources; diff --git a/Emby.Server.Implementations/Library/UserDataManager.cs b/Emby.Server.Implementations/Library/UserDataManager.cs index 962dd6fda8..4fda853bb1 100644 --- a/Emby.Server.Implementations/Library/UserDataManager.cs +++ b/Emby.Server.Implementations/Library/UserDataManager.cs @@ -247,6 +247,72 @@ namespace Emby.Server.Implementations.Library return result; } + /// + public VersionResumeData? GetResumeUserData(User user, BaseItem item) + { + return GetResumeUserDataBatch([item], user).GetValueOrDefault(item.Id); + } + + /// + public IReadOnlyDictionary GetResumeUserDataBatch(IReadOnlyList items, User user) + { + ArgumentNullException.ThrowIfNull(user); + + var result = new Dictionary(); + List<(Guid PrimaryId, IReadOnlyList