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>
120 lines
5.0 KiB
C#
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><script></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;
|
|
}
|
|
}
|
|
}
|