aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorShadowghost <Shadowghost@users.noreply.github.com>2026-09-15 11:16:54 -0400
committerCody Robibero <cody@robibe.ro>2026-09-15 11:16:54 -0400
commit7c7244d32fd9290989c523e3f344657d87cfbb7c (patch)
treeba829b95e59bf3e516cb047b18b20e387cb152dc
parentda3031628fda013be20ae4f89906882cc5d72e14 (diff)
Backport pull request #18003 from jellyfin/release-12.z
Check Live TV access for a single user instead of enumerating all users Original-merge: 2bcd3b1efb121ad661495570b43aace247ee0a27 Merged-by: crobibero <cody@robibe.ro> Backported-by: Cody Robibero <cody@robibe.ro>
-rw-r--r--Emby.Server.Implementations/Library/UserViewManager.cs2
-rw-r--r--Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs4
-rw-r--r--Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs4
-rw-r--r--Jellyfin.Server.Implementations/Item/ItemCountService.cs6
-rw-r--r--Jellyfin.Server.Implementations/Item/NextUpService.cs6
-rw-r--r--MediaBrowser.Controller/LiveTv/ILiveTvManager.cs7
-rw-r--r--src/Jellyfin.Database/Jellyfin.Database.Implementations/LeafPlayedState.cs10
-rw-r--r--src/Jellyfin.LiveTv/LiveTvManager.cs8
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryPlayedVersionTests.cs64
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Item/NextUpServiceTests.cs114
10 files changed, 205 insertions, 20 deletions
diff --git a/Emby.Server.Implementations/Library/UserViewManager.cs b/Emby.Server.Implementations/Library/UserViewManager.cs
index 231dbe6429..db911cd24a 100644
--- a/Emby.Server.Implementations/Library/UserViewManager.cs
+++ b/Emby.Server.Implementations/Library/UserViewManager.cs
@@ -136,7 +136,7 @@ namespace Emby.Server.Implementations.Library
list.AddRange(channels);
- if (_liveTvManager.GetEnabledUsers().Select(i => i.Id).Contains(user.Id))
+ if (_liveTvManager.IsEnabledForUser(user))
{
list.Add(_liveTvManager.GetInternalLiveTvFolder(CancellationToken.None));
}
diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs
index 1ed10cce2b..8d573569e9 100644
--- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs
+++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs
@@ -593,10 +593,10 @@ public sealed partial class BaseItemRepository
return dbContext.BaseItems
.Where(e => descendantIds.Contains(e.Id) && !e.IsFolder && !e.IsVirtualItem)
- .All(f => f.UserData!.Any(e => e.UserId == user.Id && e.Played));
+ .All(BuildLeafIsPlayedFilter(dbContext, user.Id));
}
- return dbContext.BaseItems.Where(e => e.ParentId == id).All(f => f.UserData!.Any(e => e.UserId == user.Id && e.Played));
+ return dbContext.BaseItems.Where(e => e.ParentId == id).All(BuildLeafIsPlayedFilter(dbContext, user.Id));
}
/// <inheritdoc />
diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs
index 486b3b5de6..d726f0f143 100644
--- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs
+++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs
@@ -581,8 +581,8 @@ public sealed partial class BaseItemRepository
.ToArray();
var folderIsResumableFilter = IsFolderFilter.And(e => resumableFolderTypes.Contains(e.Type))
.And(BuildHasDescendantFilter(context, inProgressLeafItems)
- .Or(BuildHasDescendantFilter(context, leafItems.Where(e => e.UserData!.Any(ud => ud.UserId == userId && ud.Played)))
- .And(BuildHasDescendantFilter(context, leafItems.Where(e => !e.UserData!.Any(ud => ud.UserId == userId && ud.Played))))));
+ .Or(BuildHasDescendantFilter(context, leafItems.Where(BuildLeafIsPlayedFilter(context, userId)))
+ .And(BuildHasDescendantFilter(context, leafItems.Where(BuildLeafIsPlayedFilter(context, userId).Not())))));
if (isResumable)
{
diff --git a/Jellyfin.Server.Implementations/Item/ItemCountService.cs b/Jellyfin.Server.Implementations/Item/ItemCountService.cs
index f8903127b9..942161a176 100644
--- a/Jellyfin.Server.Implementations/Item/ItemCountService.cs
+++ b/Jellyfin.Server.Implementations/Item/ItemCountService.cs
@@ -414,7 +414,7 @@ public class ItemCountService : IItemCountService
using var dbContext = _dbProvider.CreateDbContext();
var baseQuery = BuildGroupedDescendantsQuery(dbContext, filter, ancestorId);
- return baseQuery.Count(b => b.UserData!.Any(u => u.UserId == filter.User.Id && u.Played));
+ return baseQuery.Count(DescendantQueryHelper.IsPlayedBy(filter.User.Id));
}
/// <inheritdoc/>
@@ -617,7 +617,7 @@ public class ItemCountService : IItemCountService
leafItems = _queryHelpers.ApplyAccessFiltering(dbContext, leafItems, filter);
var playedLeafItems = leafItems
- .Select(b => new { b.Id, Played = b.UserData!.Any(ud => ud.UserId == userId && ud.Played) });
+ .Select(DescendantQueryHelper.PlayedStateBy(userId));
var ancestorLeaves = dbContext.AncestorIds
.WhereOneOrMany(folderIdsArray, a => a.ParentItemId)
@@ -735,7 +735,7 @@ public class ItemCountService : IItemCountService
private static (int Played, int Total) GetPlayedAndTotalCountFromQuery(IQueryable<BaseItemEntity> query, Guid userId)
{
var result = query
- .Select(b => b.UserData!.Any(u => u.UserId == userId && u.Played))
+ .Select(DescendantQueryHelper.IsPlayedBy(userId))
.GroupBy(_ => 1)
.OrderBy(g => g.Key)
.Select(g => new
diff --git a/Jellyfin.Server.Implementations/Item/NextUpService.cs b/Jellyfin.Server.Implementations/Item/NextUpService.cs
index d3c7ecb0ad..897fb98cbb 100644
--- a/Jellyfin.Server.Implementations/Item/NextUpService.cs
+++ b/Jellyfin.Server.Implementations/Item/NextUpService.cs
@@ -95,7 +95,7 @@ public class NextUpService : INextUpService
.Where(e => e.Type == episodeTypeName)
.Where(e => e.SeriesPresentationUniqueKey != null && seriesKeys.Contains(e.SeriesPresentationUniqueKey))
.Where(e => e.ParentIndexNumber != 0)
- .Where(e => e.UserData!.Any(ud => ud.UserId == userId && ud.Played));
+ .Where(DescendantQueryHelper.IsPlayedBy(userId));
lastWatchedBase = _queryHelpers.ApplyAccessFiltering(context, lastWatchedBase, filter);
// Use lightweight projection + client-side dedup to avoid the correlated scalar subquery
@@ -207,7 +207,7 @@ public class NextUpService : INextUpService
.Where(e => e.SeriesPresentationUniqueKey != null && seriesKeys.Contains(e.SeriesPresentationUniqueKey))
.Where(e => e.ParentIndexNumber != 0)
.Where(e => !e.IsVirtualItem)
- .Where(e => !e.UserData!.Any(ud => ud.UserId == userId && ud.Played));
+ .Where(DescendantQueryHelper.IsUnplayedBy(userId));
allUnplayedBase = _queryHelpers.ApplyAccessFiltering(context, allUnplayedBase, filter);
var allUnplayedCandidates = allUnplayedBase
.Select(e => new
@@ -255,7 +255,7 @@ public class NextUpService : INextUpService
.Where(e => e.SeriesPresentationUniqueKey != null && seriesKeys.Contains(e.SeriesPresentationUniqueKey))
.Where(e => e.ParentIndexNumber != 0)
.Where(e => !e.IsVirtualItem)
- .Where(e => e.UserData!.Any(ud => ud.UserId == userId && ud.Played));
+ .Where(DescendantQueryHelper.IsPlayedBy(userId));
allPlayedBase = _queryHelpers.ApplyAccessFiltering(context, allPlayedBase, filter);
var allPlayedCandidates = allPlayedBase
.Select(e => new
diff --git a/MediaBrowser.Controller/LiveTv/ILiveTvManager.cs b/MediaBrowser.Controller/LiveTv/ILiveTvManager.cs
index 8d59eef9f1..77e0087048 100644
--- a/MediaBrowser.Controller/LiveTv/ILiveTvManager.cs
+++ b/MediaBrowser.Controller/LiveTv/ILiveTvManager.cs
@@ -201,6 +201,13 @@ namespace MediaBrowser.Controller.LiveTv
IEnumerable<User> GetEnabledUsers();
/// <summary>
+ /// Gets whether Live TV is enabled for a single user.
+ /// </summary>
+ /// <param name="user">The user.</param>
+ /// <returns>Whether Live TV is enabled for the user.</returns>
+ bool IsEnabledForUser(User user);
+
+ /// <summary>
/// Gets the internal channels.
/// </summary>
/// <param name="query">The query.</param>
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/LeafPlayedState.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/LeafPlayedState.cs
new file mode 100644
index 0000000000..0846013c45
--- /dev/null
+++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/LeafPlayedState.cs
@@ -0,0 +1,10 @@
+using System;
+
+namespace Jellyfin.Database.Implementations;
+
+/// <summary>
+/// An item's id paired with whether a given user has played it.
+/// </summary>
+/// <param name="Id">The id of the item.</param>
+/// <param name="Played">Whether the user has played the item.</param>
+public readonly record struct LeafPlayedState(Guid Id, bool Played);
diff --git a/src/Jellyfin.LiveTv/LiveTvManager.cs b/src/Jellyfin.LiveTv/LiveTvManager.cs
index 2edf7681db..93f9083178 100644
--- a/src/Jellyfin.LiveTv/LiveTvManager.cs
+++ b/src/Jellyfin.LiveTv/LiveTvManager.cs
@@ -1229,6 +1229,14 @@ namespace Jellyfin.LiveTv
.Where(IsLiveTvEnabled);
}
+ /// <inheritdoc />
+ public bool IsEnabledForUser(User user)
+ {
+ ArgumentNullException.ThrowIfNull(user);
+
+ return IsLiveTvEnabled(user);
+ }
+
/// <summary>
/// Resets the tuner.
/// </summary>
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryPlayedVersionTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryPlayedVersionTests.cs
index 0958512b1a..a9548a6d13 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryPlayedVersionTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryPlayedVersionTests.cs
@@ -32,6 +32,8 @@ public sealed class BaseItemRepositoryPlayedVersionTests : SqliteDbTestFixture
private readonly Guid _seriesPlayedViaAlternate = Guid.NewGuid();
private readonly Guid _unplayedSeries = Guid.NewGuid();
+ private readonly Guid _seriesPlayedAcrossVersions = Guid.NewGuid();
+ private readonly Guid _partiallyPlayedSeries = Guid.NewGuid();
public BaseItemRepositoryPlayedVersionTests()
{
@@ -68,8 +70,29 @@ public sealed class BaseItemRepositoryPlayedVersionTests : SqliteDbTestFixture
[Fact]
public void IsPlayed_CountsASeriesWatchedThroughAnEpisodeAlternateVersion()
{
- Assert.Equal(new HashSet<Guid> { _seriesPlayedViaAlternate }, Ids(BaseItemKind.Series, isPlayed: true));
- Assert.Equal(new HashSet<Guid> { _unplayedSeries }, Ids(BaseItemKind.Series, isPlayed: false));
+ Assert.Equal(
+ new HashSet<Guid> { _seriesPlayedViaAlternate, _seriesPlayedAcrossVersions },
+ Ids(BaseItemKind.Series, isPlayed: true));
+ Assert.Equal(
+ new HashSet<Guid> { _unplayedSeries, _partiallyPlayedSeries },
+ Ids(BaseItemKind.Series, isPlayed: false));
+ }
+
+ [Fact]
+ public void GetIsPlayed_CountsASeriesWatchedThroughAnEpisodeAlternateVersion()
+ {
+ Assert.True(_repository.GetIsPlayed(_user, _seriesPlayedViaAlternate, true));
+ Assert.False(_repository.GetIsPlayed(_user, _unplayedSeries, true));
+ }
+
+ [Fact]
+ public void IsResumable_DropsASeriesWhoseLastEpisodeWasPlayedThroughAnAlternateVersion()
+ {
+ var resumable = _repository.GetItemIdsList(new InternalItemsQuery(_user) { IsResumable = true });
+
+ // Nothing is left to watch, so the series is not half finished.
+ Assert.DoesNotContain(_seriesPlayedAcrossVersions, resumable);
+ Assert.Contains(_partiallyPlayedSeries, resumable);
}
private HashSet<Guid> Ids(BaseItemKind kind, bool isPlayed)
@@ -95,6 +118,9 @@ public sealed class BaseItemRepositoryPlayedVersionTests : SqliteDbTestFixture
AddSeriesWithAlternateEpisode(context, _seriesPlayedViaAlternate, "E", playedAlternate: true);
AddSeriesWithAlternateEpisode(context, _unplayedSeries, "F", playedAlternate: false);
+ AddSeriesWithTwoEpisodes(context, _seriesPlayedAcrossVersions, "G", secondPlayedViaAlternate: true);
+ AddSeriesWithTwoEpisodes(context, _partiallyPlayedSeries, "H", secondPlayedViaAlternate: false);
+
context.SaveChanges();
}
@@ -113,7 +139,33 @@ public sealed class BaseItemRepositoryPlayedVersionTests : SqliteDbTestFixture
{
var episodeId = Guid.NewGuid();
- context.BaseItems.Add(new BaseItemEntity
+ AddSeriesFolder(context, seriesId, name);
+
+ AddItem(context, episodeId, EpisodeType, $"{name} 1");
+ context.AncestorIds.Add(new AncestorId { ItemId = episodeId, ParentItemId = seriesId, Item = null!, ParentItem = null! });
+
+ AddAlternateVersion(context, episodeId, EpisodeType, $"{name} 1 4K", playedAlternate);
+ }
+
+ // A watched first episode plus a second one that is either watched as its alternate version or not
+ // watched at all, which is what separates a finished series from a half watched one.
+ private void AddSeriesWithTwoEpisodes(JellyfinDbContext context, Guid seriesId, string name, bool secondPlayedViaAlternate)
+ {
+ AddSeriesFolder(context, seriesId, name);
+
+ var firstId = Guid.NewGuid();
+ AddItem(context, firstId, EpisodeType, $"{name} 1");
+ context.AncestorIds.Add(new AncestorId { ItemId = firstId, ParentItemId = seriesId, Item = null!, ParentItem = null! });
+ AddPlayedUserData(context, firstId);
+
+ var secondId = Guid.NewGuid();
+ AddItem(context, secondId, EpisodeType, $"{name} 2");
+ context.AncestorIds.Add(new AncestorId { ItemId = secondId, ParentItemId = seriesId, Item = null!, ParentItem = null! });
+ AddAlternateVersion(context, secondId, EpisodeType, $"{name} 2 4K", secondPlayedViaAlternate);
+ }
+
+ private void AddSeriesFolder(JellyfinDbContext context, Guid seriesId, string name)
+ => context.BaseItems.Add(new BaseItemEntity
{
Id = seriesId,
Type = SeriesType,
@@ -123,12 +175,6 @@ public sealed class BaseItemRepositoryPlayedVersionTests : SqliteDbTestFixture
IsFolder = true
});
- AddItem(context, episodeId, EpisodeType, $"{name} 1");
- context.AncestorIds.Add(new AncestorId { ItemId = episodeId, ParentItemId = seriesId, Item = null!, ParentItem = null! });
-
- AddAlternateVersion(context, episodeId, EpisodeType, $"{name} 1 4K", playedAlternate);
- }
-
private void AddItem(JellyfinDbContext context, Guid id, string type, string name)
=> context.BaseItems.Add(new BaseItemEntity
{
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/NextUpServiceTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/NextUpServiceTests.cs
new file mode 100644
index 0000000000..8ed3c61a59
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Item/NextUpServiceTests.cs
@@ -0,0 +1,114 @@
+using System;
+using System.Linq;
+using Emby.Server.Implementations.Data;
+using Jellyfin.Database.Implementations;
+using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Server.Implementations.Item;
+using MediaBrowser.Controller.Entities;
+using Xunit;
+using LinkedChildType = Jellyfin.Database.Implementations.Entities.LinkedChildType;
+
+namespace Jellyfin.Server.Implementations.Tests.Item;
+
+/// <summary>
+/// Covers Next Up over episodes with alternate versions: the episode that was watched is the one
+/// whose alternate carries the played row, so an episode already seen must not be offered again.
+/// </summary>
+public sealed class NextUpServiceTests : SqliteDbTestFixture
+{
+ private const string SeriesKey = "next-up-series";
+ private const string EpisodeType = "MediaBrowser.Controller.Entities.TV.Episode";
+
+ private readonly NextUpService _service;
+ private readonly User _user = new("test", "auth-provider", "reset-provider");
+
+ private readonly Guid _playedViaAlternate = Guid.NewGuid();
+ private readonly Guid _unplayed = Guid.NewGuid();
+
+ public NextUpServiceTests()
+ {
+ var itemTypeLookup = new ItemTypeLookup();
+
+ using (var context = CreateDbContext())
+ {
+ Seed(context);
+ }
+
+ _service = new NextUpService(
+ CreateDbContextFactory(),
+ itemTypeLookup,
+ CreateBaseItemRepository(itemTypeLookup));
+ }
+
+ [Fact]
+ public void GetNextUpEpisodesBatch_EpisodePlayedThroughItsAlternateVersion_OffersTheOneAfterIt()
+ {
+ var batch = _service.GetNextUpEpisodesBatch(
+ new InternalItemsQuery(_user),
+ [SeriesKey],
+ includeSpecials: false,
+ includeWatchedForRewatching: false)[SeriesKey];
+
+ Assert.Equal(_playedViaAlternate, batch.LastWatched?.Id);
+ Assert.Equal(_unplayed, batch.NextUp?.Id);
+ }
+
+ private void Seed(JellyfinDbContext context)
+ {
+ context.Users.Add(_user);
+
+ AddEpisode(context, _playedViaAlternate, 1);
+ AddEpisode(context, _unplayed, 2);
+
+ // The second file of the first episode, and the only row the playback was recorded against.
+ // It presents under its primary's key, which is what keeps it out of the candidate list.
+ var alternateId = Guid.NewGuid();
+ context.BaseItems.Add(new BaseItemEntity
+ {
+ Id = alternateId,
+ Type = EpisodeType,
+ Name = "Episode 1 4K",
+ SeriesPresentationUniqueKey = SeriesKey,
+ ParentIndexNumber = 1,
+ IndexNumber = 1,
+ PresentationUniqueKey = _playedViaAlternate.ToString("N"),
+ PrimaryVersionId = _playedViaAlternate
+ });
+
+ context.SaveChanges();
+
+ // The link the scanner writes alongside PrimaryVersionId, and the hop the played state
+ // reaches the alternate through.
+ context.LinkedChildren.Add(new LinkedChildEntity
+ {
+ ParentId = _playedViaAlternate,
+ ChildId = alternateId,
+ ChildType = LinkedChildType.LocalAlternateVersion,
+ SortOrder = 0
+ });
+
+ context.UserData.Add(new UserData
+ {
+ ItemId = alternateId,
+ UserId = _user.Id,
+ CustomDataKey = alternateId.ToString("N"),
+ Played = true,
+ Item = null!,
+ User = null!
+ });
+
+ context.SaveChanges();
+ }
+
+ private void AddEpisode(JellyfinDbContext context, Guid id, int indexNumber)
+ => context.BaseItems.Add(new BaseItemEntity
+ {
+ Id = id,
+ Type = EpisodeType,
+ Name = $"Episode {indexNumber}",
+ SeriesPresentationUniqueKey = SeriesKey,
+ ParentIndexNumber = 1,
+ IndexNumber = indexNumber,
+ PresentationUniqueKey = id.ToString("N")
+ });
+}