using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Jellyfin.Data;
using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Database.Implementations.Enums;
using MediaBrowser.Controller.Library;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.WatchedTogether.Services;
///
/// Grants a shared account only the libraries that every member can already reach.
///
///
/// Sharing an account must never be a way to gain access. If Alice is blocked from a library, a
/// group she belongs to cannot see it either, even when every other member can. The result is the
/// intersection of the members' access, so the shared account is always a subset of what each
/// member could reach alone.
///
public class LibraryAccessService : ILibraryAccessService
{
private readonly IUserManager _userManager;
private readonly ILibraryManager _libraryManager;
private readonly ILogger _logger;
///
/// Initializes a new instance of the class.
///
/// The user manager.
/// The library manager.
/// The logger.
public LibraryAccessService(
IUserManager userManager,
ILibraryManager libraryManager,
ILogger logger)
{
_userManager = userManager;
_libraryManager = libraryManager;
_logger = logger;
}
///
public IReadOnlyList ComputeIntersection(IReadOnlyList memberIds)
{
ArgumentNullException.ThrowIfNull(memberIds);
if (memberIds.Count == 0)
{
return [];
}
HashSet? intersection = null;
foreach (var memberId in memberIds)
{
var member = _userManager.GetUserById(memberId);
if (member is null)
{
// A member we cannot resolve contributes no access. Treating it as "everything"
// would silently widen the group.
_logger.LogWarning(
"Member {MemberId} could not be resolved; treating its library access as empty",
memberId);
return [];
}
var accessible = GetAccessibleLibraries(member);
if (intersection is null)
{
intersection = accessible;
}
else
{
intersection.IntersectWith(accessible);
}
// Nothing left in common; no later member can widen it again.
if (intersection.Count == 0)
{
break;
}
}
return intersection?.ToList() ?? (IReadOnlyList)[];
}
///
public async Task> ApplyIntersectionAsync(
Guid sharedUserId,
IReadOnlyList memberIds)
{
var granted = ComputeIntersection(memberIds);
var sharedUser = _userManager.GetUserById(sharedUserId);
if (sharedUser is null)
{
return granted;
}
// Always an explicit list, never EnableAllFolders: "all" would keep growing as libraries
// are added, silently granting the group access nobody agreed to.
var policy = _userManager.GetUserDto(sharedUser).Policy;
if (policy is null)
{
_logger.LogWarning(
"Could not read the policy for shared account {SharedUserId}; library access unchanged",
sharedUserId);
return granted;
}
policy.EnableAllFolders = false;
policy.EnabledFolders = granted.ToArray();
await _userManager.UpdatePolicyAsync(sharedUserId, policy).ConfigureAwait(false);
if (granted.Count == 0)
{
_logger.LogWarning(
"Shared account {SharedUserId} has no libraries: its members have none in common",
sharedUserId);
}
else
{
_logger.LogInformation(
"Shared account {SharedUserId} granted {Count} libraries common to all members",
sharedUserId,
granted.Count);
}
return granted;
}
///
/// Resolves the concrete set of libraries a single user can reach.
///
/// The user to inspect.
/// The library identifiers this user can see.
private HashSet GetAccessibleLibraries(User user)
{
// "Enable all folders" has to be expanded into concrete ids before it can be intersected
// with another member's explicit list.
var accessible = user.HasPermission(PermissionKind.EnableAllFolders)
? GetAllLibraryIds()
: user.GetPreferenceValues(PreferenceKind.EnabledFolders).ToHashSet();
// An explicitly blocked folder is removed even when the user otherwise has access to all.
accessible.ExceptWith(user.GetPreferenceValues(PreferenceKind.BlockedMediaFolders));
return accessible;
}
///
/// Gets the identifiers of every top-level library on the server.
///
/// All library identifiers.
private HashSet GetAllLibraryIds()
{
try
{
return _libraryManager.GetUserRootFolder()
.Children
.Select(c => c.Id)
.ToHashSet();
}
#pragma warning disable CA1031 // Failing closed is the safe direction for an access decision.
catch (Exception ex)
#pragma warning restore CA1031
{
_logger.LogError(ex, "Could not enumerate libraries; treating access as empty");
return [];
}
}
}