Files
JellyfinSyncPlus/src/JellyfinSyncPlus/SyncPlay/SyncPlusIndexInjection.cs
T
seerandClaude Fable 5 268a6ed2d0 Add in-player SyncPlus overlay via served-index.html script injection
An IStartupFilter-registered middleware intercepts /web/index.html
responses and injects a script tag for /SyncPlus/Stats/Client.js --
response-level injection rather than the write-to-disk approach other
plugins use, since index.html is root-owned in the linuxserver image
while Jellyfin runs unprivileged. The overlay floats its own fixed
pill/panel (no dependency on jellyfin-web's minified OSD DOM), shows
per-member drift for the caller's own group via the new non-admin
/SyncPlus/Stats/Mine endpoint, and reuses the existing one-shot
/SyncPlus/Stats/Sync action.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 13:55:51 +02:00

120 lines
5.0 KiB
C#

using System;
using System.IO;
using System.Text;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.SyncPlus.SyncPlay;
/// <summary>
/// Registers <see cref="SyncPlusIndexInjectionMiddleware"/> at the front of the request
/// pipeline. An <see cref="IStartupFilter"/> registered in DI is the one seam a plugin
/// has for adding middleware -- plugins can't touch Jellyfin's own <c>Startup.Configure</c>,
/// but startup filters are resolved from the final service provider when the pipeline is
/// built, and plugin <c>RegisterServices</c> runs early enough to land there. Same
/// technique the "File Transformation" plugin ecosystem uses.
/// </summary>
public class SyncPlusIndexInjectionStartupFilter : IStartupFilter
{
/// <inheritdoc />
public Action<IApplicationBuilder> Configure(Action<IApplicationBuilder> next)
=> app =>
{
app.UseMiddleware<SyncPlusIndexInjectionMiddleware>();
next(app);
};
}
/// <summary>
/// Injects a <c>&lt;script&gt;</c> tag for the plugin's in-player overlay
/// (<see cref="SyncPlusStatsController.GetClientScript"/>) into the *served*
/// <c>index.html</c> response, on the fly. Deliberately does not write to the file on
/// disk: in the linuxserver image (dev and the real k8s deployment alike) index.html is
/// owned by root while Jellyfin runs as an unprivileged user, so the disk-write approach
/// other plugins default to (e.g. InPlayerEpisodePreview) fails with permission errors
/// there. Intercepting the response instead has no permission problem and leaves nothing
/// behind if the plugin is removed.
/// </summary>
public class SyncPlusIndexInjectionMiddleware
{
private const string ScriptTag = "<script src=\"/SyncPlus/Stats/Client.js\" defer></script>";
private readonly RequestDelegate _next;
private readonly ILogger<SyncPlusIndexInjectionMiddleware> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="SyncPlusIndexInjectionMiddleware"/> class.
/// </summary>
/// <param name="next">The next middleware in the pipeline.</param>
/// <param name="logger">Instance of the <see cref="ILogger{SyncPlusIndexInjectionMiddleware}"/> interface.</param>
public SyncPlusIndexInjectionMiddleware(RequestDelegate next, ILogger<SyncPlusIndexInjectionMiddleware> logger)
{
_next = next;
_logger = logger;
}
/// <summary>
/// Intercepts index.html responses and injects the overlay script tag.
/// </summary>
/// <param name="context">The HTTP context.</param>
/// <returns>A task.</returns>
public async Task InvokeAsync(HttpContext context)
{
var path = context.Request.Path.Value ?? string.Empty;
var isIndex = path.Equals("/web/", StringComparison.OrdinalIgnoreCase)
|| path.Equals("/web/index.html", StringComparison.OrdinalIgnoreCase);
if (!isIndex)
{
await _next(context).ConfigureAwait(false);
return;
}
// Downstream middleware would otherwise gzip/brotli the body before it gets back
// to us, making the </body> marker unfindable. Dropping Accept-Encoding for this
// one request keeps the body plain; index.html is ~5KB, the loss is irrelevant.
context.Request.Headers.Remove("Accept-Encoding");
var originalBody = context.Response.Body;
using var buffer = new MemoryStream();
context.Response.Body = buffer;
try
{
await _next(context).ConfigureAwait(false);
var isHtml = context.Response.ContentType?.Contains("text/html", StringComparison.OrdinalIgnoreCase) ?? false;
if (context.Response.StatusCode == StatusCodes.Status200OK && isHtml)
{
var html = Encoding.UTF8.GetString(buffer.ToArray());
var injected = html.Replace("</body>", ScriptTag + "</body>", StringComparison.OrdinalIgnoreCase);
var bytes = Encoding.UTF8.GetBytes(injected);
if (injected.Length == html.Length)
{
_logger.LogWarning("SyncPlus: served index.html contains no </body> tag, overlay script not injected");
}
// The stale validators describe the unmodified file; leaving them would let
// a browser cache-revalidate its way back to an uninjected copy.
context.Response.Headers.Remove("ETag");
context.Response.Headers.Remove("Last-Modified");
context.Response.ContentLength = bytes.Length;
await originalBody.WriteAsync(bytes).ConfigureAwait(false);
}
else
{
buffer.Position = 0;
await buffer.CopyToAsync(originalBody).ConfigureAwait(false);
}
}
finally
{
context.Response.Body = originalBody;
}
}
}