Backport pull request #18003 from jellyfin/release-12.z

Check Live TV access for a single user instead of enumerating all users

Original-merge: 2bcd3b1efb

Merged-by: crobibero <cody@robibe.ro>

Backported-by: Cody Robibero <cody@robibe.ro>
This commit is contained in:
Shadowghost authored and Cody Robibero committed 2026-09-15 11:16:54 -04:00
1 parent da3031628f
commit 7c7244d32f
10 files changed
+205 -20

No files matched your search

@@ -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));
}
@@ -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 />
@@ -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)
{
@@ -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
@@ -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
@@ -200,6 +200,13 @@ namespace MediaBrowser.Controller.LiveTv
/// <returns>IEnumerable{User}.</returns>
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>
@@ -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);
+8
View File
@@ -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>
@@ -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
{
@@ -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")
});
}