mirror of
https://github.com/jellyfin/jellyfin.git
synced 2026-10-26 16:45:09 +00:00
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:
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);
|
||||
@@ -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>
|
||||
|
||||
+55
-9
@@ -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")
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user