diff --git a/Emby.Server.Implementations/Library/MediaSourceManager.cs b/Emby.Server.Implementations/Library/MediaSourceManager.cs
index 9ccfefa86e..ee5d716cd0 100644
--- a/Emby.Server.Implementations/Library/MediaSourceManager.cs
+++ b/Emby.Server.Implementations/Library/MediaSourceManager.cs
@@ -229,7 +229,7 @@ namespace Emby.Server.Implementations.Library
list.Add(source);
}
- return SortMediaSources(list).ToArray();
+ return SortMediaSources(list, item.Id).ToArray();
}
/// >
@@ -540,24 +540,32 @@ namespace Emby.Server.Implementations.Library
}
}
- private static IEnumerable SortMediaSources(IEnumerable sources)
+ private static IEnumerable SortMediaSources(IEnumerable sources, Guid preferredItemId = default)
{
- return sources.OrderBy(i =>
- {
- if (i.VideoType.HasValue && i.VideoType.Value == VideoType.VideoFile)
+ // The source belonging to the queried item sorts first so it stays the default that gets played.
+ var preferredId = preferredItemId.IsEmpty()
+ ? null
+ : preferredItemId.ToString("N", CultureInfo.InvariantCulture);
+
+ return sources
+ .OrderByDescending(i => preferredId is not null && string.Equals(i.Id, preferredId, StringComparison.OrdinalIgnoreCase))
+ .ThenBy(i =>
{
- return 0;
- }
+ if (i.VideoType.HasValue && i.VideoType.Value == VideoType.VideoFile)
+ {
+ return 0;
+ }
- return 1;
- }).ThenBy(i => i.Video3DFormat.HasValue ? 1 : 0)
- .ThenByDescending(i =>
- {
- var stream = i.VideoStream;
+ return 1;
+ })
+ .ThenBy(i => i.Video3DFormat.HasValue ? 1 : 0)
+ .ThenByDescending(i =>
+ {
+ var stream = i.VideoStream;
- return stream?.Width ?? 0;
- })
- .Where(i => i.Type != MediaSourceType.Placeholder);
+ return stream?.Width ?? 0;
+ })
+ .Where(i => i.Type != MediaSourceType.Placeholder);
}
public async Task> OpenLiveStreamInternal(LiveStreamRequest request, CancellationToken cancellationToken)
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/Jellyfin.Api/Controllers/ItemsController.cs b/Jellyfin.Api/Controllers/ItemsController.cs
index 363af9e43b..2891869c94 100644
--- a/Jellyfin.Api/Controllers/ItemsController.cs
+++ b/Jellyfin.Api/Controllers/ItemsController.cs
@@ -919,6 +919,7 @@ public class ItemsController : BaseJellyfinApiController
MediaTypes = mediaTypes,
IsVirtualItem = false,
CollapseBoxSetItems = false,
+ IncludeOwnedItems = true,
EnableTotalRecordCount = enableTotalRecordCount,
AncestorIds = ancestorIds,
IncludeItemTypes = includeItemTypes,
diff --git a/MediaBrowser.Controller/Entities/BaseItem.cs b/MediaBrowser.Controller/Entities/BaseItem.cs
index bb4a36abd4..40c5d20162 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()
diff --git a/MediaBrowser.Controller/Entities/Video.cs b/MediaBrowser.Controller/Entities/Video.cs
index 44cae5197a..58643cf896 100644
--- a/MediaBrowser.Controller/Entities/Video.cs
+++ b/MediaBrowser.Controller/Entities/Video.cs
@@ -10,6 +10,7 @@ using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Data.Enums;
+using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Extensions;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.LiveTv;
@@ -33,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]
@@ -334,6 +335,78 @@ 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 true, the playback position of each version is also
+ /// reset, keeping the versions consistent with a deliberate played/unplayed toggle. When
+ /// false, only the played flag changes and each version keeps its own resume point.
+ 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;
+ }
+
+ var dto = new UpdateUserItemDataDto { Played = played };
+ 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);
+ }
+ }
+
+ ///
+ /// 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