Jellyfin Plugin (C#)
Overview
The JellyWatchParty plugin integrates with Jellyfin’s plugin architecture to serve the client JavaScript and provide configuration management.
Project Structure
src/plugins/jellyfin/
├── JellyWatchParty/
│ ├── Plugin.cs # Plugin entry point
│ ├── JellyWatchPartyPlugin.csproj # Project file
│ ├── Controllers/
│ │ └── JellyWatchPartyController.cs # REST API endpoints
│ ├── Configuration/
│ │ └── PluginConfiguration.cs # Configuration model
│ ├── Services/ # Host Bridge (see host-bridge.md)
│ │ ├── HostBridgeManager.cs # Hosted service, owns active bridges
│ │ ├── SessionHostBridge.cs # One bridge: session → session-server WS
│ │ └── SessionServerAuth.cs # Shared JWT minting
│ └── Web/
│ ├── configPage.html # Admin configuration page
│ └── plugin.js # Client JS loader (fetches modules
│ # individually, not a pre-bundled script)
└── JellyWatchParty.Tests/ # xUnit/Moq test project
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("[JellyWatchParty] JwtSecret not configured. Authentication DISABLED.");
}
}
public override string Name => "JellyWatchParty";
public override Guid Id => new("0f2fd0fd-09ff-4f49-9f1c-4a8f421a4b7d");
public IEnumerable<PluginPageInfo> GetPages()
{
return new[]
{
new PluginPageInfo
{
Name = "JellyWatchParty",
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.
JellyWatchPartyController.cs
Description
ASP.NET Core controller providing REST API endpoints.
Endpoints
GET /JellyWatchParty/ClientScript
Serves the client JS loader (plugin.js) with caching support. The
loader then fetches each individual module via
GET /JellyWatchParty/Client/{*path} (GetClientModule, same embedded-
resource/ETag caching model) — the client is not shipped as one
pre-bundled file. See Client for the module list, and the
REST API Reference below for the full endpoint
list, including the Host Bridge endpoints this
controller also exposes.
[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(JellyWatchPartyController).Assembly;
var resourceName = "JellyWatchParty.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 /JellyWatchParty/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 "JellyWatchParty".
/// </summary>
public string JwtAudience { get; set; } = "JellyWatchParty";
/// <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;
/// <summary>
/// Checks a Session Server URL for common misconfigurations (wrong
/// scheme, malformed URL, bare internal hostname) and returns
/// human-readable warnings. Never rejects a value - empty result means
/// no issues found.
/// </summary>
public static IReadOnlyList<string> ValidateSessionServerUrl(string? value) { /* ... */ }
}
Validation:
- TTL values are clamped to valid range (1 minute to 24 hours)
- Null JWT secret is converted to empty string
SessionServerUrlis checked byValidateSessionServerUrland any warnings are logged (Pluginconstructor) — this is advisory only, the value itself is never modified or rejected
configPage.html
Description
Admin configuration page rendered in Jellyfin dashboard.
Features
- JWT Secret - Password input field, with a show/hide toggle
- JWT Audience - Configurable audience claim
- JWT Issuer - Configurable issuer claim
- Session Server URL - Live warning indicator (updates on input/blur) that flags suspicious values without blocking save
- Watch Party Panel Bridging (trusted servers only) - Master switch
EnablePanelBridgingplus the two per-role options, which are disabled while the switch is off - Save button - Persists configuration
Security Considerations
- The page loads the whole plugin configuration through Jellyfin’s plugin
configuration API, JWT secret included, so admins can see and edit it.
That API is admin-only; the secret is never sent to non-admin users or
to the injected web client (
/Tokenreturns a signed token, not the secret). - 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("JellyWatchParty.Plugin.Web.plugin.js");
Dependencies
The project targets net10.0 (Jellyfin 12.x only — Jellyfin 10.11.x support
has been dropped, see Directory.Build.props), so
Jellyfin.Controller/Jellyfin.Model are versioned via
$(JellyfinPackageVersion) in Directory.Build.props:
<ItemGroup>
<PackageReference Include="Jellyfin.Controller" Version="$(JellyfinPackageVersion)" ExcludeAssets="runtime" />
<PackageReference Include="Jellyfin.Model" Version="$(JellyfinPackageVersion)" ExcludeAssets="runtime" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" ExcludeAssets="runtime" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="6.35.0" />
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="6.35.0" />
</ItemGroup>
Building
Requires the .NET 10 SDK:
dotnet build
# Or use just (from project root)
just build plugin
The built DLL and dependencies are placed in bin/Debug/net10.0/.
REST API Reference
Base URL: http(s)://<jellyfin-host>:<port>/JellyWatchParty
| Method | Path | Auth | Description |
|---|---|---|---|
GET |
/JellyWatchParty/ClientScript |
None | Client JS loader (plugin.js), ETag-cached |
GET |
/JellyWatchParty/Client/{*path} |
None | Individual client module by path, e.g. Client/playback/sync.js |
GET |
/JellyWatchParty/Token |
Jellyfin auth | Issues a JWT (or no-auth response) for the current user |
GET |
/JellyWatchParty/Bridge/Sessions |
Jellyfin auth | The caller’s own sessions that can be bridged (admins: all); empty unless panel bridging is on — see Host Bridge |
GET |
/JellyWatchParty/Bridge/Status |
Jellyfin auth | The caller’s active bridges (admins: all) |
POST |
/JellyWatchParty/Bridge/{sessionId}/Start |
Jellyfin auth, session owner or admin | Start bridging a session in as host |
POST |
/JellyWatchParty/Bridge/{sessionId}/Follow?roomId=… |
Jellyfin auth, session owner or admin | Attach a session to a room as a receiver (follower) |
POST |
/JellyWatchParty/Bridge/{sessionId}/Stop |
Jellyfin auth, bridge owner or admin | Stop an active bridge |
Start and Follow answer 400 while EnablePanelBridging (or the role’s
own option) is off, and 403 for someone else’s session. Ownership
compares the session’s UserId with the caller’s Jellyfin-UserId
claim; callers with the Administrator role may bridge any session.
Stop works with panel bridging off, so leftover bridges can always be
stopped.
GET /JellyWatchParty/Token
curl -H "X-Emby-Token: YOUR_API_KEY" "http://localhost:8096/JellyWatchParty/Token"
{
"token": "eyJhbGciOiJIUzI1NiIs...",
"auth_enabled": true,
"expires_in": 3600,
"user_id": "abc123",
"user_name": "John"
}
When JWT isn’t configured, token is null and auth_enabled is false.
| Status | Meaning |
|---|---|
| 200 | Token generated successfully |
| 401 | Not authenticated or claims missing |
| 429 | Rate limit exceeded (10 tokens/min per user) |
| 500 | Plugin not configured |
All error responses share the shape {"error": "..."}.
Configuration API
Plugin configuration is managed through Jellyfin’s standard plugin configuration endpoints (admin privileges required):
# Read
curl -H "X-Emby-Token: $TOKEN" \
"http://localhost:8096/System/Configuration/Plugin/0f2fd0fd-09ff-4f49-9f1c-4a8f421a4b7d"
# Update
curl -X POST \
-H "X-Emby-Token: $TOKEN" -H "Content-Type: application/json" \
-d '{"JwtSecret": "...", "JwtAudience": "JellyWatchParty", "JwtIssuer": "Jellyfin", "TokenTtlSeconds": 3600, "InviteTtlSeconds": 3600, "SessionServerUrl": ""}' \
"http://localhost:8096/System/Configuration/Plugin/0f2fd0fd-09ff-4f49-9f1c-4a8f421a4b7d"
See Configuration for the field reference and examples.
WebSocket API
The session server itself uses WebSocket, not REST, for real-time
communication — endpoint ws(s)://<host>:3000/ws. See
Protocol for the complete message specification.