Jellyfin Plugin (C#)

Overview

The OpenWatchParty plugin integrates with Jellyfin’s plugin architecture to serve the client JavaScript and provide configuration management.

Project Structure

plugins/jellyfin/OpenWatchParty/
├── Plugin.cs                     # Plugin entry point
├── OpenWatchParty.csproj         # Project file
├── Controllers/
│   └── OpenWatchPartyController.cs  # REST API endpoints
├── Configuration/
│   └── PluginConfiguration.cs    # Configuration model
└── Web/
    ├── configPage.html           # Admin configuration page
    └── plugin.js                 # Client JavaScript bundle

Plugin.cs

Description

The plugin entry point. Implements BasePlugin<PluginConfiguration> and IHasWebPages.

Key Elements

public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
{
    // Singleton instance - standard Jellyfin plugin pattern
    public static Plugin? Instance { get; private set; }

    public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer, ILogger<Plugin> logger)
        : base(applicationPaths, xmlSerializer)
    {
        Instance = this;

        // Log JWT configuration status
        if (string.IsNullOrEmpty(Configuration.JwtSecret))
        {
            _logger.LogWarning("[OpenWatchParty] JwtSecret not configured. Authentication DISABLED.");
        }
    }

    public override string Name => "OpenWatchParty";
    public override Guid Id => new("0f2fd0fd-09ff-4f49-9f1c-4a8f421a4b7d");

    public IEnumerable<PluginPageInfo> GetPages()
    {
        return new[]
        {
            new PluginPageInfo
            {
                Name = "OpenWatchParty",
                EmbeddedResourcePath = GetType().Namespace + ".Web.configPage.html"
            }
        };
    }
}

Singleton Pattern

The Instance static property follows Jellyfin’s standard plugin pattern. It’s set once during plugin initialization and provides access to the plugin configuration from controllers.

OpenWatchPartyController.cs

Description

ASP.NET Core controller providing REST API endpoints.

Endpoints

GET /OpenWatchParty/ClientScript

Serves the client JavaScript bundle with caching support.

[HttpGet("ClientScript")]
[Produces("text/javascript")]
public async Task<ActionResult> GetClientScript()
{
    // ETag validation for cache
    var requestETag = Request.Headers["If-None-Match"].FirstOrDefault();
    if (!string.IsNullOrEmpty(requestETag) && requestETag == _cachedScriptETag)
    {
        return StatusCode(304); // Not Modified
    }

    // Load from embedded resource (cached after first load)
    if (_cachedScript == null)
    {
        var assembly = typeof(OpenWatchPartyController).Assembly;
        var resourceName = "OpenWatchParty.Plugin.Web.plugin.js";
        using var stream = assembly.GetManifestResourceStream(resourceName);
        if (stream == null) return NotFound();
        using var reader = new StreamReader(stream);
        _cachedScript = await reader.ReadToEndAsync();
        _cachedScriptETag = $"\"{ComputeETag(_cachedScript)}\"";
    }

    // Set cache headers
    Response.Headers["Cache-Control"] = "public, max-age=3600";
    Response.Headers["ETag"] = _cachedScriptETag;

    return Content(_cachedScript, "text/javascript");
}

Features:

  • Embedded resource loading
  • ETag-based cache validation
  • HTTP 304 Not Modified support
  • 1-hour cache lifetime

GET /OpenWatchParty/Token

Generates JWT tokens for authenticated users.

[HttpGet("Token")]
[Authorize]
[Produces("application/json")]
public ActionResult GetToken()
{
    // Get user from authenticated context
    var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
    var userName = User.FindFirst(ClaimTypes.Name)?.Value;

    // Validate claims
    if (string.IsNullOrEmpty(userId))
    {
        return Unauthorized(new { error = "User identity not found" });
    }

    // Rate limiting: 10 tokens per minute per user
    if (!CheckRateLimit(userId))
    {
        return StatusCode(429, new { error = "Rate limit exceeded" });
    }

    // Check if JWT is configured
    if (string.IsNullOrEmpty(config.JwtSecret))
    {
        return Ok(new {
            token = (string?)null,
            auth_enabled = false,
            user_id = userId,
            user_name = userName
        });
    }

    var token = GenerateJwtToken(userId, userName, config);

    return Ok(new {
        token,
        auth_enabled = true,
        expires_in = config.TokenTtlSeconds,
        user_id = userId,
        user_name = userName
    });
}

Features:

  • Jellyfin authentication required
  • Rate limiting (10 tokens/minute/user)
  • JWT token generation
  • Graceful handling when JWT not configured

JWT Token Generation

private static string GenerateJwtToken(string userId, string userName, PluginConfiguration config)
{
    var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(config.JwtSecret));
    var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256);

    var claims = new[]
    {
        new Claim(JwtRegisteredClaimNames.Sub, userId),
        new Claim(JwtRegisteredClaimNames.Name, userName),
        new Claim(JwtRegisteredClaimNames.Aud, config.JwtAudience),
        new Claim(JwtRegisteredClaimNames.Iss, config.JwtIssuer),
        new Claim(JwtRegisteredClaimNames.Iat, DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString()),
    };

    var token = new JwtSecurityToken(
        issuer: config.JwtIssuer,
        audience: config.JwtAudience,
        claims: claims,
        expires: DateTime.UtcNow.AddSeconds(config.TokenTtlSeconds),
        signingCredentials: credentials
    );

    return new JwtSecurityTokenHandler().WriteToken(token);
}

PluginConfiguration.cs

Description

Configuration model with validation.

public class PluginConfiguration : BasePluginConfiguration
{
    private string _jwtSecret = string.Empty;
    private int _tokenTtlSeconds = 3600;
    private int _inviteTtlSeconds = 3600;

    /// <summary>
    /// JWT secret. If empty, authentication is disabled.
    /// Set a value (min 32 chars) to enable authentication.
    /// </summary>
    public string JwtSecret
    {
        get => _jwtSecret;
        set => _jwtSecret = value ?? string.Empty;
    }

    /// <summary>
    /// JWT audience claim. Defaults to "OpenWatchParty".
    /// </summary>
    public string JwtAudience { get; set; } = "OpenWatchParty";

    /// <summary>
    /// JWT issuer claim. Defaults to "Jellyfin".
    /// </summary>
    public string JwtIssuer { get; set; } = "Jellyfin";

    /// <summary>
    /// Token TTL in seconds. Clamped between 60 and 86400.
    /// </summary>
    public int TokenTtlSeconds
    {
        get => _tokenTtlSeconds;
        set => _tokenTtlSeconds = Math.Clamp(value, 60, 86400);
    }

    /// <summary>
    /// Invite TTL in seconds. Clamped between 60 and 86400.
    /// </summary>
    public int InviteTtlSeconds
    {
        get => _inviteTtlSeconds;
        set => _inviteTtlSeconds = Math.Clamp(value, 60, 86400);
    }

    /// <summary>
    /// WebSocket server URL. If empty, uses default (same host, port 3000).
    /// </summary>
    public string SessionServerUrl { get; set; } = string.Empty;
}

Validation:

  • TTL values are clamped to valid range (1 minute to 24 hours)
  • Null JWT secret is converted to empty string

configPage.html

Description

Admin configuration page rendered in Jellyfin dashboard.

Features

  • JWT Secret - Password input field (never exposed in GET response)
  • JWT Audience - Configurable audience claim
  • JWT Issuer - Configurable issuer claim
  • Save button - Persists configuration

Security Considerations

  • JWT secret is never sent back to the client
  • Password field prevents shoulder surfing
  • Only admins can access the plugin configuration page

Embedded Resources

The project file configures embedded resources:

<ItemGroup>
  <EmbeddedResource Include="Web\configPage.html" />
  <EmbeddedResource Include="Web\plugin.js" />
</ItemGroup>

Resources are accessed via:

assembly.GetManifestResourceStream("OpenWatchParty.Plugin.Web.plugin.js");

Dependencies

<ItemGroup>
  <PackageReference Include="Jellyfin.Controller" Version="10.9.0" />
  <PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="7.0.0" />
</ItemGroup>

Building

# Build with dotnet
dotnet build

# Or use make (from project root)
make build-plugin

The built DLL and dependencies are placed in bin/Debug/net9.0/.


Back to top

OpenWatchParty - Synchronized watch parties for Jellyfin