using System; using System.Collections.Generic; using System.Linq; using System.Net.Mime; using System.Threading.Tasks; using Jellyfin.Plugin.WatchedTogether.Compat; using Jellyfin.Plugin.WatchedTogether.Configuration; using Jellyfin.Plugin.WatchedTogether.Models; using Jellyfin.Plugin.WatchedTogether.Services; using MediaBrowser.Common.Api; using MediaBrowser.Controller.Library; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; namespace Jellyfin.Plugin.WatchedTogether.Controllers; /// /// Administrative endpoints backing the configuration page. /// [ApiController] [Authorize(Policy = Policies.RequiresElevation)] [Route("Plugins/WatchedTogether")] [Produces(MediaTypeNames.Application.Json)] public class WatchedTogetherController : ControllerBase { private readonly IProvisioningService _provisioningService; private readonly IRestrictionService _restrictionService; private readonly IUserManager _userManager; private readonly ILogger _logger; /// /// Initializes a new instance of the class. /// /// The provisioning service. /// The content restriction service. /// The user manager. /// The logger. public WatchedTogetherController( IProvisioningService provisioningService, IRestrictionService restrictionService, IUserManager userManager, ILogger logger) { _provisioningService = provisioningService; _restrictionService = restrictionService; _userManager = userManager; _logger = logger; } /// /// Gets every configured group, resolved against current user records. /// /// The configured groups. [HttpGet("Groups")] [ProducesResponseType(StatusCodes.Status200OK)] public ActionResult> GetGroups() { var config = Plugin.Instance?.Configuration; if (config is null) { return Ok(Array.Empty()); } return Ok(config.Groups.Select(ToDto).ToList()); } /// /// Gets the users that may be selected as members - everyone who is not already a shared account. /// /// The eligible users. [HttpGet("EligibleUsers")] [ProducesResponseType(StatusCodes.Status200OK)] public ActionResult> GetEligibleUsers() { var sharedIds = Plugin.Instance?.Configuration.Groups .Select(g => g.SharedUserId) .ToHashSet() ?? []; var users = _userManager.GetAllUsers() .Where(u => !sharedIds.Contains(u.Id)) .Select(u => new MemberDto { UserId = u.Id, Username = u.Username }) .OrderBy(u => u.Username, StringComparer.OrdinalIgnoreCase) .ToList(); return Ok(users); } /// /// Creates a shared account and its group. /// /// The group to create. /// The created group. [HttpPost("Groups")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] public async Task> CreateGroup([FromBody] CreateGroupRequest request) { ArgumentNullException.ThrowIfNull(request); try { var group = await _provisioningService.CreateGroupAsync( request.MemberUserIds, request.Name).ConfigureAwait(false); return Ok(ToDto(group)); } catch (ArgumentException ex) { _logger.LogWarning(ex, "Rejected group creation request"); return BadRequest(ex.Message); } } /// /// Updates an existing group's membership and options. /// /// The shared account identifying the group. /// The new membership and options. /// No content on success. [HttpPost("Groups/{sharedUserId}")] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status400BadRequest)] public async Task UpdateGroup( [FromRoute] Guid sharedUserId, [FromBody] UpdateGroupRequest request) { ArgumentNullException.ThrowIfNull(request); try { await _provisioningService.UpdateGroupAsync( sharedUserId, request.MemberUserIds, request.SyncUnwatched, request.SyncPlayCount, request.IsDisabled, request.InheritParentalRating, request.ParentalRatingCap).ConfigureAwait(false); return NoContent(); } catch (ArgumentException ex) { _logger.LogWarning(ex, "Rejected group update request"); return BadRequest(ex.Message); } } /// /// Deletes a group, optionally deleting its shared account too. /// /// The shared account identifying the group. /// Whether to delete the shared account as well. /// No content on success. [HttpDelete("Groups/{sharedUserId}")] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status400BadRequest)] public async Task DeleteGroup( [FromRoute] Guid sharedUserId, [FromQuery] bool deleteSharedUser = false) { try { await _provisioningService.DeleteGroupAsync(sharedUserId, deleteSharedUser).ConfigureAwait(false); return NoContent(); } catch (ArgumentException ex) { _logger.LogWarning(ex, "Rejected group deletion request"); return BadRequest(ex.Message); } } /// /// Resolves a stored group against current user records, including the restrictions its /// shared account actually carries right now. /// /// The stored group. /// The group as the dashboard shows it. private GroupDto ToDto(SharedGroup group) { var sharedUser = _userManager.GetUserById(group.SharedUserId); var range = _restrictionService.GetRatingRange(group.MemberUserIds); return new GroupDto { SharedUserId = group.SharedUserId, SharedUsername = sharedUser?.Username ?? "(deleted)", SyncUnwatched = group.SyncUnwatched, SyncPlayCount = group.SyncPlayCount, IsDisabled = group.IsDisabled, InheritParentalRating = group.InheritParentalRating, ParentalRatingCap = group.ParentalRatingCap, StrictestMemberRating = range.Strictest, LoosestMemberRating = range.Loosest, Restrictions = sharedUser is null ? null : ToDto(ContentRestrictions.FromUser(sharedUser)), Members = group.MemberUserIds.Select(id => { var member = _userManager.GetUserById(id); return new MemberDto { UserId = id, Username = member?.Username ?? "(deleted)", MaxParentalRating = member is null ? 0 : member.MaxParentalRatingScore }; }).ToList() }; } private static RestrictionsDto ToDto(ContentRestrictions restrictions) => new() { MaxParentalRating = restrictions.MaxParentalRatingScore, MaxParentalSubRating = restrictions.MaxParentalRatingSubScore, BlockUnratedItems = restrictions.BlockUnratedItems.Select(u => u.ToString()).OrderBy(u => u, StringComparer.Ordinal).ToList(), BlockedTags = restrictions.BlockedTags.OrderBy(t => t, StringComparer.OrdinalIgnoreCase).ToList(), AllowedTags = (restrictions.AllowedTags ?? Enumerable.Empty()).OrderBy(t => t, StringComparer.OrdinalIgnoreCase).ToList(), AllowsNothing = restrictions.HasAllowedTags && restrictions.AllowedTags!.Count == 0 }; }