mirror of
https://github.com/jellyfin/jellyfin.git
synced 2026-06-09 01:08:45 +01:00
Version-aware playback tracking
This commit is contained in:
@@ -229,7 +229,7 @@ namespace Emby.Server.Implementations.Library
|
||||
list.Add(source);
|
||||
}
|
||||
|
||||
return SortMediaSources(list).ToArray();
|
||||
return SortMediaSources(list, item.Id).ToArray();
|
||||
}
|
||||
|
||||
/// <inheritdoc />>
|
||||
@@ -540,24 +540,32 @@ namespace Emby.Server.Implementations.Library
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<MediaSourceInfo> SortMediaSources(IEnumerable<MediaSourceInfo> sources)
|
||||
private static IEnumerable<MediaSourceInfo> SortMediaSources(IEnumerable<MediaSourceInfo> 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<Tuple<LiveStreamResponse, IDirectStreamProvider>> OpenLiveStreamInternal(LiveStreamRequest request, CancellationToken cancellationToken)
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -725,6 +725,31 @@ namespace Emby.Server.Implementations.Session
|
||||
return item;
|
||||
}
|
||||
|
||||
/// <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>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -919,6 +919,7 @@ public class ItemsController : BaseJellyfinApiController
|
||||
MediaTypes = mediaTypes,
|
||||
IsVirtualItem = false,
|
||||
CollapseBoxSetItems = false,
|
||||
IncludeOwnedItems = true,
|
||||
EnableTotalRecordCount = enableTotalRecordCount,
|
||||
AncestorIds = ancestorIds,
|
||||
IncludeItemTypes = includeItemTypes,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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<string>();
|
||||
LocalAlternateVersions = Array.Empty<string>();
|
||||
SubtitleFiles = Array.Empty<string>();
|
||||
AudioFiles = Array.Empty<string>();
|
||||
LinkedAlternateVersions = Array.Empty<LinkedChild>();
|
||||
AdditionalParts = [];
|
||||
LocalAlternateVersions = [];
|
||||
SubtitleFiles = [];
|
||||
AudioFiles = [];
|
||||
LinkedAlternateVersions = [];
|
||||
}
|
||||
|
||||
[JsonIgnore]
|
||||
@@ -334,6 +335,78 @@ 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 <c>true</c>, the playback position of each version is also
|
||||
/// reset, keeping the versions consistent with a deliberate played/unplayed toggle. When
|
||||
/// <c>false</c>, only the played flag changes and each version keeps its own resume point.</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;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <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)
|
||||
@@ -643,37 +716,32 @@ namespace MediaBrowser.Controller.Entities
|
||||
|
||||
protected override IEnumerable<(BaseItem Item, MediaSourceType MediaSourceType)> GetAllItemsForMediaSources()
|
||||
{
|
||||
var list = new List<(BaseItem, MediaSourceType)>
|
||||
{
|
||||
(this, MediaSourceType.Default)
|
||||
};
|
||||
var primary = PrimaryVersionId.HasValue
|
||||
? LibraryManager.GetItemById(PrimaryVersionId.Value) as Video
|
||||
: null;
|
||||
|
||||
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 localAlternates = list
|
||||
.SelectMany(i =>
|
||||
{
|
||||
return i.Item1 is Video video ? LibraryManager.GetLocalAlternateVersionIds(video) : Enumerable.Empty<Guid>();
|
||||
})
|
||||
.Select(LibraryManager.GetItemById)
|
||||
.Where(i => i is not 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();
|
||||
|
||||
list.AddRange(localAlternates.Select(i => (i, MediaSourceType.Default)));
|
||||
// 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)
|
||||
.Select(i => (i, MediaSourceType.Default));
|
||||
|
||||
return list;
|
||||
// Deduplicate
|
||||
return grouped
|
||||
.Concat(localAlternates)
|
||||
.DistinctBy(i => i.Item1.Id)
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
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 +116,164 @@ 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);
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user