aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Emby.Server.Implementations/Library/UserDataManager.cs36
-rw-r--r--Emby.Server.Implementations/Session/SessionManager.cs51
-rw-r--r--MediaBrowser.Controller/Entities/BaseItem.cs33
-rw-r--r--MediaBrowser.Controller/Entities/Video.cs145
-rw-r--r--MediaBrowser.Controller/Library/IUserDataManager.cs8
-rw-r--r--tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs204
6 files changed, 430 insertions, 47 deletions
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;
}
+
+ /// <inheritdoc />
+ 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
@@ -726,6 +726,31 @@ namespace Emby.Server.Implementations.Session
}
/// <summary>
+ /// 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 <c>ItemId</c> and the played version as <c>MediaSourceId</c>.
+ /// </summary>
+ /// <param name="libraryItem">The now playing (displayed) item.</param>
+ /// <param name="mediaSourceId">The reported media source id.</param>
+ /// <returns>The item to track progress against.</returns>
+ 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;
+ }
+
+ /// <summary>
/// Used to report that playback has started for an item.
/// </summary>
/// <param name="info">The info.</param>
@@ -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);
+ }
+
+ /// <summary>
+ /// Clears the played state on the supplied user data.
+ /// </summary>
+ /// <param name="data">The user data to reset.</param>
+ 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);
}
/// <summary>
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<string>();
- LocalAlternateVersions = Array.Empty<string>();
- SubtitleFiles = Array.Empty<string>();
- AudioFiles = Array.Empty<string>();
- LinkedAlternateVersions = Array.Empty<LinkedChild>();
+ AdditionalParts = [];
+ LocalAlternateVersions = [];
+ SubtitleFiles = [];
+ AudioFiles = [];
+ LinkedAlternateVersions = [];
}
[JsonIgnore]
@@ -335,6 +335,92 @@ namespace MediaBrowser.Controller.Entities
PresentationUniqueKey = CreatePresentationUniqueKey();
}
+ /// <summary>
+ /// Marks the played status of this video and propagates it to its alternate versions.
+ /// </summary>
+ /// <param name="user">The user.</param>
+ /// <param name="datePlayed">The date played.</param>
+ /// <param name="resetPosition">if set to <c>true</c> [reset position].</param>
+ public override void MarkPlayed(User user, DateTime? datePlayed, bool resetPosition)
+ {
+ base.MarkPlayed(user, datePlayed, resetPosition);
+ PropagatePlayedState(user, true, resetPosition);
+ }
+
+ /// <summary>
+ /// Marks this video unplayed and propagates the change to its alternate versions.
+ /// </summary>
+ /// <param name="user">The user.</param>
+ 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);
+ }
+
+ /// <summary>
+ /// Propagates the played status to every alternate version of this video.
+ /// </summary>
+ /// <param name="user">The user.</param>
+ /// <param name="played">The played status to apply to the alternate versions.</param>
+ /// <param name="resetPosition">When marking played, controls whether each version's resume point
+ /// is also reset (<c>true</c>) or left untouched (<c>false</c>). Ignored when marking unplayed,
+ /// which always fully resets every version.</param>
+ 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);
+ }
+ }
+ }
+
+ /// <summary>
+ /// Gets the alternate version of this video that matches the supplied item id.
+ /// </summary>
+ /// <param name="itemId">The version item id (the playback media source id).</param>
+ /// <returns>The matching version, or <c>null</c> when the id is not a version of this video.</returns>
+ public Video GetAlternateVersion(Guid itemId)
+ {
+ return GetAllItemsForMediaSources()
+ .Select(i => i.Item)
+ .OfType<Video>()
+ .FirstOrDefault(i => i.Id.Equals(itemId));
+ }
+
public override string CreatePresentationUniqueKey()
{
if (PrimaryVersionId.HasValue)
@@ -644,37 +730,32 @@ namespace MediaBrowser.Controller.Entities
protected override IEnumerable<(BaseItem Item, MediaSourceType MediaSourceType)> GetAllItemsForMediaSources()
{
- var list = new List<(BaseItem, MediaSourceType)>
- {
- (this, MediaSourceType.Default)
- };
-
- list.AddRange(
- LibraryManager.GetLinkedAlternateVersions(this)
- .Select(i => ((BaseItem)i, MediaSourceType.Grouping)));
-
- if (PrimaryVersionId.HasValue)
- {
- if (LibraryManager.GetItemById(PrimaryVersionId.Value) is Video primary)
- {
- var existingIds = list.Select(i => i.Item1.Id).ToList();
- list.Add((primary, MediaSourceType.Grouping));
- list.AddRange(LibraryManager.GetLinkedAlternateVersions(primary).Where(i => !existingIds.Contains(i.Id)).Select(i => ((BaseItem)i, MediaSourceType.Grouping)));
- }
- }
+ var primary = PrimaryVersionId.HasValue
+ ? LibraryManager.GetItemById(PrimaryVersionId.Value) as Video
+ : null;
+
+ // This video and its linked alternates, when this is itself an alternate, the primary and the primary's linked alternates.
+ var grouped = new[] { ((BaseItem)this, MediaSourceType.Default) }
+ .Concat(LibraryManager.GetLinkedAlternateVersions(this).Select(i => ((BaseItem)i, MediaSourceType.Grouping)))
+ .Concat(primary is null
+ ? []
+ : LibraryManager.GetLinkedAlternateVersions(primary).Prepend(primary).Select(i => ((BaseItem)i, MediaSourceType.Grouping)))
+ .ToList();
- var localAlternates = list
- .SelectMany(i =>
- {
- return i.Item1 is Video video ? LibraryManager.GetLocalAlternateVersionIds(video) : Enumerable.Empty<Guid>();
- })
+ // The local (file-based) alternate versions of every grouped item.
+ var localAlternates = grouped
+ .Select(i => i.Item1)
+ .OfType<Video>()
+ .SelectMany(LibraryManager.GetLocalAlternateVersionIds)
.Select(LibraryManager.GetItemById)
.Where(i => i is not null)
- .ToList();
+ .Select(i => (i, MediaSourceType.Default));
- list.AddRange(localAlternates.Select(i => (i, MediaSourceType.Default)));
-
- return list;
+ // Deduplicate
+ return grouped
+ .Concat(localAlternates)
+ .DistinctBy(i => i.Item1.Id)
+ .ToList();
}
}
}
diff --git a/MediaBrowser.Controller/Library/IUserDataManager.cs b/MediaBrowser.Controller/Library/IUserDataManager.cs
index 798812bf1f..0b4229c784 100644
--- a/MediaBrowser.Controller/Library/IUserDataManager.cs
+++ b/MediaBrowser.Controller/Library/IUserDataManager.cs
@@ -80,5 +80,13 @@ namespace MediaBrowser.Controller.Library
/// <param name="reportedPositionTicks">New playstate.</param>
/// <returns>True if playstate was updated.</returns>
bool UpdatePlayState(BaseItem item, UserItemData data, long? reportedPositionTicks);
+
+ /// <summary>
+ /// Clears any stored audio and subtitle stream selections for the given user/item pair.
+ /// Used when the user has opted out of remembering selections.
+ /// </summary>
+ /// <param name="user">The user.</param>
+ /// <param name="item">The item.</param>
+ void ResetPlaybackStreamSelections(User user, BaseItem item);
}
}
diff --git a/tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs b/tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs
index 8af176138c..2ee95f076b 100644
--- a/tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs
+++ b/tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs
@@ -1,6 +1,15 @@
using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Reflection;
+using System.Threading;
+using Jellyfin.Database.Implementations.Entities;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.LiveTv;
+using MediaBrowser.Controller.MediaSegments;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Entities;
using MediaBrowser.Model.MediaInfo;
using Moq;
using Xunit;
@@ -108,4 +117,199 @@ public class BaseItemTests
Assert.Equal(expectedPrimary, video.GetMediaSourceName(video, commonPrefix));
Assert.Equal(expectedAlt, videoAlt.GetMediaSourceName(videoAlt, commonPrefix));
}
+
+ [Fact]
+ public void GetAlternateVersion_ReturnsMatchingLocalVersion()
+ {
+ var (primary, alt1, alt2) = SetupVersionGroup();
+
+ Assert.Same(alt1, primary.GetAlternateVersion(alt1.Id));
+ Assert.Same(alt2, primary.GetAlternateVersion(alt2.Id));
+ Assert.Same(primary, primary.GetAlternateVersion(primary.Id));
+ Assert.Null(primary.GetAlternateVersion(Guid.NewGuid()));
+ }
+
+ [Fact]
+ public void PropagatePlayedState_MarksAlternateVersions_AndResetsPositionByDefault()
+ {
+ var (primary, alt1, alt2) = SetupVersionGroup();
+
+ var saved = CaptureSaves();
+
+ var user = new User("test", "default", "default");
+ primary.PropagatePlayedState(user, true);
+
+ // Both alternate versions are marked played, the primary (self) is not, and the position is
+ // reset so a watched version does not linger in "Continue Watching".
+ Assert.Equal(2, saved.Count);
+ Assert.DoesNotContain(saved, e => e.ItemId.Equals(primary.Id));
+ Assert.Contains(saved, e => e.ItemId.Equals(alt1.Id));
+ Assert.Contains(saved, e => e.ItemId.Equals(alt2.Id));
+ Assert.All(saved, e =>
+ {
+ Assert.True(e.Dto.Played.GetValueOrDefault());
+ Assert.Equal(0, e.Dto.PlaybackPositionTicks);
+ });
+ }
+
+ [Fact]
+ public void PropagatePlayedState_WithoutReset_LeavesPositionUntouched()
+ {
+ var (primary, _, _) = SetupVersionGroup();
+
+ var saved = CaptureSaves();
+
+ primary.PropagatePlayedState(new User("test", "default", "default"), true, resetPosition: false);
+
+ Assert.Equal(2, saved.Count);
+ Assert.All(saved, e =>
+ {
+ Assert.True(e.Dto.Played.GetValueOrDefault());
+ Assert.Null(e.Dto.PlaybackPositionTicks);
+ });
+ }
+
+ [Fact]
+ public void PropagatePlayedState_Unwatched_ClearsAllWatchedStateOnVersions()
+ {
+ var (primary, alt1, alt2) = SetupVersionGroup();
+
+ // Each alternate starts out watched, with a play count, resume point and last-played date.
+ var existing = new Dictionary<Guid, UserItemData>
+ {
+ [alt1.Id] = new UserItemData { Key = "alt1", Played = true, PlayCount = 3, PlaybackPositionTicks = 1000, LastPlayedDate = new DateTime(2020, 1, 1, 0, 0, 0, DateTimeKind.Utc) },
+ [alt2.Id] = new UserItemData { Key = "alt2", Played = true, PlayCount = 1, PlaybackPositionTicks = 500, LastPlayedDate = new DateTime(2021, 2, 2, 0, 0, 0, DateTimeKind.Utc) },
+ };
+
+ var saved = new List<UserItemData>();
+ var userDataManager = new Mock<IUserDataManager>();
+ userDataManager.Setup(x => x.GetUserData(It.IsAny<User>(), It.IsAny<BaseItem>()))
+ .Returns((User _, BaseItem item) => existing.GetValueOrDefault(item.Id));
+ userDataManager
+ .Setup(x => x.SaveUserData(It.IsAny<User>(), It.IsAny<BaseItem>(), It.IsAny<UserItemData>(), It.IsAny<UserDataSaveReason>(), It.IsAny<CancellationToken>()))
+ .Callback<User, BaseItem, UserItemData, UserDataSaveReason, CancellationToken>((_, _, data, _, _) => saved.Add(data));
+ BaseItem.UserDataManager = userDataManager.Object;
+
+ primary.PropagatePlayedState(new User("test", "default", "default"), false);
+
+ // Every alternate is fully reset to an unwatched state, mirroring MarkUnplayed: the played flag,
+ // play count, resume point and last-played date are all cleared so no watched state lingers.
+ Assert.Equal(2, saved.Count);
+ Assert.All(saved, d =>
+ {
+ Assert.False(d.Played);
+ Assert.Equal(0, d.PlayCount);
+ Assert.Equal(0, d.PlaybackPositionTicks);
+ Assert.Null(d.LastPlayedDate);
+ });
+ }
+
+ private static List<(Guid ItemId, UpdateUserItemDataDto Dto)> CaptureSaves()
+ {
+ var saved = new List<(Guid ItemId, UpdateUserItemDataDto Dto)>();
+ var userDataManager = new Mock<IUserDataManager>();
+ userDataManager
+ .Setup(x => x.SaveUserData(It.IsAny<User>(), It.IsAny<BaseItem>(), It.IsAny<UpdateUserItemDataDto>(), It.IsAny<UserDataSaveReason>()))
+ .Callback<User, BaseItem, UpdateUserItemDataDto, UserDataSaveReason>((_, item, dto, _) => saved.Add((item.Id, dto)));
+ BaseItem.UserDataManager = userDataManager.Object;
+ return saved;
+ }
+
+ [Fact]
+ public void PropagatePlayedState_SingleVersion_DoesNothing()
+ {
+ var solo = new Video { Id = Guid.NewGuid(), Path = "/Movies/Solo/Solo.mkv" };
+
+ var mediaSourceManager = new Mock<IMediaSourceManager>();
+ mediaSourceManager.Setup(x => x.GetPathProtocol(It.IsAny<string>())).Returns(MediaProtocol.File);
+ var libraryManager = new Mock<ILibraryManager>();
+ libraryManager.Setup(x => x.GetLocalAlternateVersionIds(It.IsAny<Video>())).Returns(Array.Empty<Guid>());
+ libraryManager.Setup(x => x.GetLinkedAlternateVersions(It.IsAny<Video>())).Returns(Array.Empty<Video>());
+ BaseItem.MediaSourceManager = mediaSourceManager.Object;
+ BaseItem.LibraryManager = libraryManager.Object;
+
+ var userDataManager = new Mock<IUserDataManager>();
+ BaseItem.UserDataManager = userDataManager.Object;
+
+ solo.PropagatePlayedState(new User("test", "default", "default"), true);
+
+ userDataManager.Verify(
+ x => x.SaveUserData(It.IsAny<User>(), It.IsAny<BaseItem>(), It.IsAny<UpdateUserItemDataDto>(), It.IsAny<UserDataSaveReason>()),
+ Times.Never);
+ }
+
+ private static (Video Primary, Video Alt1, Video Alt2) SetupVersionGroup()
+ {
+ var primary = new Video { Id = Guid.NewGuid(), Path = "/Movies/Movie/Movie.mkv" };
+ var alt1 = new Video { Id = Guid.NewGuid(), Path = "/Movies/Movie/Movie - 1080p.mkv", PrimaryVersionId = primary.Id };
+ var alt2 = new Video { Id = Guid.NewGuid(), Path = "/Movies/Movie/Movie - 4K.mkv", PrimaryVersionId = primary.Id };
+
+ // 2160p primary, 1080p alternates: width is only the ordering tiebreaker, set so it would place
+ // the primary first — letting the tests confirm the queried version's own source still wins.
+ var widths = new Dictionary<Guid, int> { [primary.Id] = 3840, [alt1.Id] = 1920, [alt2.Id] = 1920 };
+ var mediaSourceManager = new Mock<IMediaSourceManager>();
+ mediaSourceManager.Setup(x => x.GetPathProtocol(It.IsAny<string>())).Returns(MediaProtocol.File);
+ mediaSourceManager.Setup(x => x.GetMediaStreams(It.IsAny<Guid>()))
+ .Returns((Guid id) => new List<MediaStream> { new MediaStream { Type = MediaStreamType.Video, Width = widths.GetValueOrDefault(id) } });
+ mediaSourceManager.Setup(x => x.GetMediaAttachments(It.IsAny<Guid>())).Returns(new List<MediaAttachment>());
+
+ var segmentManager = new Mock<IMediaSegmentManager>();
+ segmentManager.Setup(x => x.IsTypeSupported(It.IsAny<BaseItem>())).Returns(false);
+ BaseItem.MediaSegmentManager = segmentManager.Object;
+
+ var libraryManager = new Mock<ILibraryManager>();
+ libraryManager.Setup(x => x.GetLinkedAlternateVersions(It.IsAny<Video>())).Returns(Array.Empty<Video>());
+ libraryManager.Setup(x => x.GetLocalAlternateVersionIds(primary)).Returns(new[] { alt1.Id, alt2.Id });
+ libraryManager.Setup(x => x.GetLocalAlternateVersionIds(alt1)).Returns(Array.Empty<Guid>());
+ libraryManager.Setup(x => x.GetLocalAlternateVersionIds(alt2)).Returns(Array.Empty<Guid>());
+ libraryManager.Setup(x => x.GetItemById(alt1.Id)).Returns(alt1);
+ libraryManager.Setup(x => x.GetItemById(alt2.Id)).Returns(alt2);
+ libraryManager.Setup(x => x.GetItemById(primary.Id)).Returns(primary);
+
+ var recordingsManager = new Mock<IRecordingsManager>();
+ recordingsManager.Setup(x => x.GetActiveRecordingInfo(It.IsAny<string>())).Returns((ActiveRecordingInfo?)null);
+ Video.RecordingsManager = recordingsManager.Object;
+
+ BaseItem.MediaSourceManager = mediaSourceManager.Object;
+ BaseItem.LibraryManager = libraryManager.Object;
+
+ return (primary, alt1, alt2);
+ }
+
+ [Fact]
+ public void GetMediaSources_DefaultsToTheQueriedVersionsOwnSource()
+ {
+ var (primary, alt1, _) = SetupVersionGroup();
+
+ // Resuming the 1080p alternate must default to the 1080p source, not the higher-resolution
+ // 2160p primary that the width ordering would otherwise place first.
+ Assert.Equal(alt1.Id.ToString("N"), alt1.GetMediaSources(false)[0].Id);
+
+ // Opening the primary still defaults to the primary's own (here highest-resolution) source.
+ Assert.Equal(primary.Id.ToString("N"), primary.GetMediaSources(false)[0].Id);
+ }
+
+ [Fact]
+ public void GetAllItemsForMediaSources_FromAnyVersion_HasNoDuplicates()
+ {
+ var (primary, alt1, alt2) = SetupVersionGroup();
+
+ var method = typeof(Video).GetMethod("GetAllItemsForMediaSources", BindingFlags.Instance | BindingFlags.NonPublic);
+ Assert.NotNull(method);
+
+ // Each version must surface exactly once, regardless of which member the list is built from.
+ // Building from an alternate previously re-added that alternate as a "local alternate" of the
+ // primary, producing a duplicate entry in the version dropdown.
+ foreach (var source in new[] { primary, alt1, alt2 })
+ {
+ var items = (IEnumerable<(BaseItem Item, MediaSourceType MediaSourceType)>)method!.Invoke(source, null)!;
+ var ids = items.Select(i => i.Item.Id).ToList();
+
+ Assert.Equal(3, ids.Count);
+ Assert.Equal(ids.Count, ids.Distinct().Count());
+ Assert.Contains(primary.Id, ids);
+ Assert.Contains(alt1.Id, ids);
+ Assert.Contains(alt2.Id, ids);
+ }
+ }
}