Add version-aware playback tracking

This commit is contained in:
Shadowghost
2026-06-07 22:37:34 +02:00
parent 507998a4e3
commit c242533f4e
6 changed files with 430 additions and 47 deletions

View File

@@ -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>

View File

@@ -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)
};
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();
}
}
}

View File

@@ -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);
}
}