jellyLMS/Jellyfin.Plugin.JellyLMS/Api/JellyLmsController.cs
Duncan Tourolle 4d2f7df217
Some checks failed
Build Plugin / build (push) Successful in 49s
Release Plugin / build-and-release (push) Failing after 39s
new build system
inject controls in homepage
2026-06-13 23:35:46 +02:00

448 lines
15 KiB
C#

using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Net.Mime;
using System.Reflection;
using System.Threading.Tasks;
using Jellyfin.Data.Enums;
using Jellyfin.Database.Implementations.Enums;
using Jellyfin.Plugin.JellyLMS.Models;
using Jellyfin.Plugin.JellyLMS.Services;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Audio;
using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Entities;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
namespace Jellyfin.Plugin.JellyLMS.Api;
/// <summary>
/// REST API controller for JellyLMS operations.
/// </summary>
[ApiController]
[Route("JellyLms")]
[Authorize]
[Produces(MediaTypeNames.Application.Json)]
public class JellyLmsController : ControllerBase
{
private readonly ILmsApiClient _lmsClient;
private readonly LmsPlayerManager _playerManager;
private readonly ILibraryManager _libraryManager;
private readonly IUserManager _userManager;
/// <summary>
/// Initializes a new instance of the <see cref="JellyLmsController"/> class.
/// </summary>
/// <param name="lmsClient">The LMS API client.</param>
/// <param name="playerManager">The player manager.</param>
/// <param name="libraryManager">The library manager.</param>
/// <param name="userManager">The user manager.</param>
public JellyLmsController(
ILmsApiClient lmsClient,
LmsPlayerManager playerManager,
ILibraryManager libraryManager,
IUserManager userManager)
{
_lmsClient = lmsClient;
_playerManager = playerManager;
_libraryManager = libraryManager;
_userManager = userManager;
}
/// <summary>
/// Determines whether the current user is allowed to use the multi-room remote
/// control features (administrators, or users granted the
/// "Allow remote control of other users" permission).
/// </summary>
/// <returns><c>true</c> if the user may use remote control endpoints.</returns>
private bool HasRemoteControlAccess()
{
var username = User.Identity?.Name;
if (string.IsNullOrEmpty(username))
{
return false;
}
var user = _userManager.GetUserByName(username);
if (user is null)
{
return false;
}
return HasPermission(user, PermissionKind.IsAdministrator)
|| HasPermission(user, PermissionKind.EnableRemoteControlOfOtherUsers);
}
private static bool HasPermission(Jellyfin.Database.Implementations.Entities.User user, PermissionKind kind)
{
foreach (var permission in user.Permissions)
{
if (permission.Kind == kind)
{
return permission.Value;
}
}
return false;
}
/// <summary>
/// Tests the connection to the LMS server.
/// </summary>
/// <returns>The connection status.</returns>
[HttpPost("TestConnection")]
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task<ActionResult<LmsServerStatus>> TestConnection()
{
var status = await _lmsClient.TestConnectionAsync().ConfigureAwait(false);
return Ok(status);
}
/// <summary>
/// Gets all LMS players.
/// </summary>
/// <param name="refresh">Force refresh from LMS.</param>
/// <returns>List of players.</returns>
[HttpGet("Players")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult<List<LmsPlayer>>> GetPlayers([FromQuery] bool refresh = false)
{
if (!HasRemoteControlAccess())
{
return Forbid();
}
var players = await _playerManager.GetPlayersAsync(refresh).ConfigureAwait(false);
return Ok(players);
}
/// <summary>
/// Gets a specific player by MAC address.
/// </summary>
/// <param name="mac">The player's MAC address.</param>
/// <returns>The player details.</returns>
[HttpGet("Players/{mac}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<LmsPlayer>> GetPlayer(string mac)
{
if (!HasRemoteControlAccess())
{
return Forbid();
}
var player = await _playerManager.GetPlayerAsync(mac).ConfigureAwait(false);
if (player == null)
{
return NotFound();
}
return Ok(player);
}
/// <summary>
/// Powers on a player.
/// </summary>
/// <param name="mac">The player's MAC address.</param>
/// <returns>Success status.</returns>
[HttpPost("Players/{mac}/PowerOn")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult> PowerOn(string mac)
{
if (!HasRemoteControlAccess())
{
return Forbid();
}
var success = await _lmsClient.PowerOnAsync(mac).ConfigureAwait(false);
return success ? Ok() : BadRequest("Failed to power on player");
}
/// <summary>
/// Powers off a player.
/// </summary>
/// <param name="mac">The player's MAC address.</param>
/// <returns>Success status.</returns>
[HttpPost("Players/{mac}/PowerOff")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult> PowerOff(string mac)
{
if (!HasRemoteControlAccess())
{
return Forbid();
}
var success = await _lmsClient.PowerOffAsync(mac).ConfigureAwait(false);
return success ? Ok() : BadRequest("Failed to power off player");
}
/// <summary>
/// Sets the volume on a player.
/// </summary>
/// <param name="mac">The player's MAC address.</param>
/// <param name="request">The volume request.</param>
/// <returns>Success status.</returns>
[HttpPost("Players/{mac}/Volume")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult> SetVolume(string mac, [FromBody] VolumeRequest request)
{
if (!HasRemoteControlAccess())
{
return Forbid();
}
var success = await _lmsClient.SetVolumeAsync(mac, request.Volume).ConfigureAwait(false);
return success ? Ok() : BadRequest("Failed to set volume");
}
/// <summary>
/// Gets all sync groups.
/// </summary>
/// <returns>List of sync groups.</returns>
[HttpGet("SyncGroups")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult<List<SyncGroup>>> GetSyncGroups()
{
if (!HasRemoteControlAccess())
{
return Forbid();
}
var groups = await _playerManager.GetSyncGroupsAsync().ConfigureAwait(false);
return Ok(groups);
}
/// <summary>
/// Creates a sync group.
/// </summary>
/// <param name="request">The sync request.</param>
/// <returns>Success status.</returns>
[HttpPost("SyncGroups")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult> CreateSyncGroup([FromBody] CreateSyncGroupRequest request)
{
if (!HasRemoteControlAccess())
{
return Forbid();
}
var success = await _playerManager.CreateSyncGroupAsync(request.MasterMac, request.SlaveMacs)
.ConfigureAwait(false);
return success ? Ok() : BadRequest("Failed to create sync group");
}
/// <summary>
/// Removes a player from its sync group.
/// </summary>
/// <param name="mac">The player's MAC address.</param>
/// <returns>Success status.</returns>
[HttpDelete("SyncGroups/Players/{mac}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult> UnsyncPlayer(string mac)
{
if (!HasRemoteControlAccess())
{
return Forbid();
}
var success = await _playerManager.UnsyncPlayerAsync(mac).ConfigureAwait(false);
return success ? Ok() : BadRequest("Failed to unsync player");
}
/// <summary>
/// Dissolves an entire sync group.
/// </summary>
/// <param name="masterMac">The master player's MAC address.</param>
/// <returns>Success status.</returns>
[HttpDelete("SyncGroups/{masterMac}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult> DissolveSyncGroup(string masterMac)
{
if (!HasRemoteControlAccess())
{
return Forbid();
}
var success = await _playerManager.DissolveSyncGroupAsync(masterMac).ConfigureAwait(false);
return success ? Ok() : BadRequest("Failed to dissolve sync group");
}
/// <summary>
/// Checks whether the current user is allowed to use the multi-room remote control.
/// Used by the remote control page and the injected web client button to decide
/// whether to show themselves.
/// </summary>
/// <returns>200 OK if allowed, otherwise 403 Forbidden.</returns>
[HttpGet("RemoteControl/Access")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public ActionResult CheckRemoteControlAccess()
{
return HasRemoteControlAccess() ? Ok() : Forbid();
}
/// <summary>
/// Serves the standalone multi-room remote control page. The page itself contains
/// no sensitive data; it authenticates API calls using the Jellyfin access token
/// stored by the web client, so it is reachable without a prior Jellyfin auth header.
/// </summary>
/// <returns>The remote control HTML page.</returns>
[HttpGet("RemoteControl")]
[AllowAnonymous]
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult GetRemoteControlPage()
{
return ServeEmbeddedResource("Jellyfin.Plugin.JellyLMS.Web.RemoteControl.html", "text/html");
}
/// <summary>
/// Serves the client script that is injected into the Jellyfin web client to add a
/// floating button linking to the remote control page.
/// </summary>
/// <returns>The client script.</returns>
[HttpGet("RemoteControl/ClientScript")]
[AllowAnonymous]
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult GetRemoteControlClientScript()
{
return ServeEmbeddedResource("Jellyfin.Plugin.JellyLMS.Web.remote-button.js", "application/javascript");
}
private FileStreamResult ServeEmbeddedResource(string resourceName, string contentType)
{
var stream = Assembly.GetExecutingAssembly().GetManifestResourceStream(resourceName)
?? throw new InvalidOperationException($"Embedded resource '{resourceName}' not found.");
return File(stream, contentType);
}
/// <summary>
/// Discovers file paths used by Jellyfin's music libraries.
/// Helps users configure path mappings for direct file access.
/// </summary>
/// <returns>Sample file paths from each music library.</returns>
[HttpGet("DiscoverPaths")]
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult<DiscoveredPathsResponse> DiscoverPaths()
{
var response = new DiscoveredPathsResponse();
// Get sample audio files from the library
var query = new InternalItemsQuery
{
IncludeItemTypes = [BaseItemKind.Audio],
Limit = 50,
Recursive = true
};
var items = _libraryManager.GetItemsResult(query).Items;
// Extract unique path prefixes
var pathPrefixes = new HashSet<string>();
foreach (var item in items)
{
if (string.IsNullOrEmpty(item.Path))
{
continue;
}
// Add sample paths
if (response.SamplePaths.Count < 5)
{
response.SamplePaths.Add(item.Path);
}
// Try to find common path prefixes
var path = item.Path.Replace('\\', '/');
var parts = path.Split('/');
// Build prefix from first few directory levels
if (parts.Length > 2)
{
// Try different prefix lengths to find common ones
for (var i = 2; i <= Math.Min(4, parts.Length - 1); i++)
{
var prefix = string.Join('/', parts.Take(i));
if (!string.IsNullOrEmpty(prefix) && !prefix.Contains('.', StringComparison.Ordinal))
{
pathPrefixes.Add(prefix);
}
}
}
}
// Sort prefixes by length (shorter = more general)
response.DetectedPrefixes = pathPrefixes
.OrderBy(p => p.Length)
.Take(10)
.ToList();
return Ok(response);
}
}
/// <summary>
/// Response containing discovered file paths from Jellyfin libraries.
/// </summary>
public class DiscoveredPathsResponse
{
/// <summary>
/// Gets or sets sample file paths from the music library.
/// </summary>
public List<string> SamplePaths { get; set; } = [];
/// <summary>
/// Gets or sets detected common path prefixes.
/// </summary>
public List<string> DetectedPrefixes { get; set; } = [];
}
/// <summary>
/// Request to set volume.
/// </summary>
public class VolumeRequest
{
/// <summary>
/// Gets or sets the volume level (0-100).
/// </summary>
[Range(0, 100)]
public int Volume { get; set; }
}
/// <summary>
/// Request to create a sync group.
/// </summary>
public class CreateSyncGroupRequest
{
/// <summary>
/// Gets or sets the master player MAC address.
/// </summary>
[Required]
public string MasterMac { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the slave player MAC addresses.
/// </summary>
[Required]
public List<string> SlaveMacs { get; set; } = [];
}