Files
WatchedTogether/Jellyfin.Plugin.WatchedTogether/Controllers/WatchedTogetherController.cs
T
dtourolle 7be07d16a2
🏗️ Build Plugin / build (push) Has been cancelled
🧪 Test Plugin / test (push) Has been cancelled
Implement Watched Together shared viewing accounts
Replaces the plugin template with a working plugin that lets several
users share one viewing account while keeping their individual watched
lists accurate.

Three pieces:

- Auto-creating groups. Logging in as "alice+bob" with any named
  member's own password provisions the shared account and signs you in.
  Verified against 10.11.5: AuthenticateUser offers unmatched usernames
  to every enabled provider and re-queries afterwards, which is the hook
  this relies on. Gated on a real member password so knowing two
  usernames is not enough to create an account.

- Multi-password authentication. IRequiresResolvedUser hands us the
  resolved shared account; each member's live stored hash is checked via
  ICryptoProvider.Verify. Deliberately avoids re-entering
  UserManager.AuthenticateUser, which would trip every member's
  failed-attempt counter whenever a different member's password matched.

- One-way played-state sync. Shared account to members only, filtered to
  PlaybackFinished/TogglePlayed/Import so playback progress ticks are
  ignored. No loop guard needed: member writes carry a non-shared id.

Membership is stored as user IDs rather than re-parsed from the username,
so shared accounts can be renamed freely. The +/name collision resolves
itself because Jellyfin only consults the plugin when no local user
matches the typed name.

Targets Jellyfin 10.11.x / net9.0. Adds Gitea CI (test, build, release),
a builder image, and 34 tests covering the auth and sync rules.
2026-07-29 00:00:13 +02:00

197 lines
6.9 KiB
C#

using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Mime;
using System.Threading.Tasks;
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;
/// <summary>
/// Administrative endpoints backing the configuration page.
/// </summary>
[ApiController]
[Authorize(Policy = Policies.RequiresElevation)]
[Route("Plugins/WatchedTogether")]
[Produces(MediaTypeNames.Application.Json)]
public class WatchedTogetherController : ControllerBase
{
private readonly IProvisioningService _provisioningService;
private readonly IUserManager _userManager;
private readonly ILogger<WatchedTogetherController> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="WatchedTogetherController"/> class.
/// </summary>
/// <param name="provisioningService">The provisioning service.</param>
/// <param name="userManager">The user manager.</param>
/// <param name="logger">The logger.</param>
public WatchedTogetherController(
IProvisioningService provisioningService,
IUserManager userManager,
ILogger<WatchedTogetherController> logger)
{
_provisioningService = provisioningService;
_userManager = userManager;
_logger = logger;
}
/// <summary>
/// Gets every configured group, resolved against current user records.
/// </summary>
/// <returns>The configured groups.</returns>
[HttpGet("Groups")]
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult<IEnumerable<GroupDto>> GetGroups()
{
var config = Plugin.Instance?.Configuration;
if (config is null)
{
return Ok(Array.Empty<GroupDto>());
}
var groups = config.Groups.Select(g => new GroupDto
{
SharedUserId = g.SharedUserId,
SharedUsername = _userManager.GetUserById(g.SharedUserId)?.Username ?? "(deleted)",
SyncUnwatched = g.SyncUnwatched,
SyncPlayCount = g.SyncPlayCount,
IsDisabled = g.IsDisabled,
Members = g.MemberUserIds.Select(id => new MemberDto
{
UserId = id,
Username = _userManager.GetUserById(id)?.Username ?? "(deleted)"
}).ToList()
}).ToList();
return Ok(groups);
}
/// <summary>
/// Gets the users that may be selected as members - everyone who is not already a shared account.
/// </summary>
/// <returns>The eligible users.</returns>
[HttpGet("EligibleUsers")]
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult<IEnumerable<MemberDto>> GetEligibleUsers()
{
var sharedIds = Plugin.Instance?.Configuration.Groups
.Select(g => g.SharedUserId)
.ToHashSet() ?? [];
var users = _userManager.Users
.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);
}
/// <summary>
/// Creates a shared account and its group.
/// </summary>
/// <param name="request">The group to create.</param>
/// <returns>The created group.</returns>
[HttpPost("Groups")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<GroupDto>> CreateGroup([FromBody] CreateGroupRequest request)
{
ArgumentNullException.ThrowIfNull(request);
try
{
var group = await _provisioningService.CreateGroupAsync(
request.MemberUserIds,
request.Name,
request.EnableAllFolders,
request.EnabledFolders).ConfigureAwait(false);
return Ok(new GroupDto
{
SharedUserId = group.SharedUserId,
SharedUsername = _userManager.GetUserById(group.SharedUserId)?.Username ?? string.Empty,
SyncUnwatched = group.SyncUnwatched,
SyncPlayCount = group.SyncPlayCount,
IsDisabled = group.IsDisabled,
Members = group.MemberUserIds.Select(id => new MemberDto
{
UserId = id,
Username = _userManager.GetUserById(id)?.Username ?? "(deleted)"
}).ToList()
});
}
catch (ArgumentException ex)
{
_logger.LogWarning(ex, "Rejected group creation request");
return BadRequest(ex.Message);
}
}
/// <summary>
/// Updates an existing group's membership and options.
/// </summary>
/// <param name="sharedUserId">The shared account identifying the group.</param>
/// <param name="request">The new membership and options.</param>
/// <returns>No content on success.</returns>
[HttpPost("Groups/{sharedUserId}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult> UpdateGroup(
[FromRoute] Guid sharedUserId,
[FromBody] UpdateGroupRequest request)
{
ArgumentNullException.ThrowIfNull(request);
try
{
await _provisioningService.UpdateGroupAsync(
sharedUserId,
request.MemberUserIds,
request.SyncUnwatched,
request.SyncPlayCount,
request.IsDisabled).ConfigureAwait(false);
return NoContent();
}
catch (ArgumentException ex)
{
_logger.LogWarning(ex, "Rejected group update request");
return BadRequest(ex.Message);
}
}
/// <summary>
/// Deletes a group, optionally deleting its shared account too.
/// </summary>
/// <param name="sharedUserId">The shared account identifying the group.</param>
/// <param name="deleteSharedUser">Whether to delete the shared account as well.</param>
/// <returns>No content on success.</returns>
[HttpDelete("Groups/{sharedUserId}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult> 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);
}
}
}