Both failure paths still told admins the overlay was "falling back to patching index.html on disk". JR-021 deleted that fallback, so the message was false -- and it was false in the worst place, since an admin reads it precisely when debugging a missing overlay and would go hunting for a patch that no longer exists. They now name the install URL and say the overlay is disabled while every other feature is unaffected. The not-found case is a Warning, not Information: a headline feature being off should not sit among startup chatter. UT-012..015 cover the branch. The File Transformation assembly is genuinely absent from the test host, so TryRegister exercises its real not-found path rather than a seam invented for the test. UT-015 pins that a null payload returns empty rather than throwing -- this callback runs inside another plugin's request path on every page served, so throwing would break the web client itself, not just JRay's overlay. Making those runnable needed the test project to reference Jellyfin.Controller and Jellyfin.Model without the plugin's ExcludeAssets=runtime. My earlier claim that DisableTransitiveFrameworkReferences alone sufficed was too narrow: it drops the demand for the web *framework*, but ILogger and MediaBrowser.Common live in the excluded assets, so anything past dependency-free logic failed at run time with FileNotFoundException. Both settings are needed, and the register now says so. Second mutation check: downgrading the warning to Information fails UT-013 and nothing else. Source restored and re-verified. JR-023 reaches Done for the detection half. The config-page banner stays T4 -- verifiable only against a live server. TRACES: UT-012, UT-013, UT-014, UT-015 | JR-023 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
176 lines
7.4 KiB
C#
176 lines
7.4 KiB
C#
using System;
|
|
using System.Globalization;
|
|
using System.Linq;
|
|
using System.Reflection;
|
|
using System.Runtime.Loader;
|
|
using System.Text.Json;
|
|
using Jellyfin.Plugin.JRay.Models;
|
|
using Microsoft.Extensions.Logging;
|
|
|
|
namespace Jellyfin.Plugin.JRay.Services;
|
|
|
|
/// <summary>
|
|
/// Registers JRay's overlay script with the
|
|
/// <see href="https://github.com/IAmParadox27/jellyfin-plugin-file-transformation">File Transformation</see>
|
|
/// plugin, which rewrites <c>index.html</c> as it is served instead of
|
|
/// modifying the file on disk. This is non-destructive and composes with
|
|
/// other plugins that patch the same file.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// File Transformation is referenced by reflection (rather than a NuGet
|
|
/// package reference) so JRay still loads when it isn't installed. JRay must
|
|
/// never bundle the assembly: a bundled copy would sit in a different
|
|
/// <c>AssemblyLoadContext</c> from the real one, which is precisely the failure
|
|
/// the reflection integration exists to avoid.
|
|
///
|
|
/// This is the only route by which JRay reaches the web client. There is no
|
|
/// on-disk fallback (JR-021), so when registration fails the overlay is simply
|
|
/// disabled — which is why both failure paths log a warning naming the missing
|
|
/// plugin rather than quietly degrading.
|
|
/// </remarks>
|
|
// TRACES: JR-020, JR-023 | PR-004
|
|
public static class FileTransformationRegistration
|
|
{
|
|
/// <summary>
|
|
/// The marker comment written alongside the injected script tag, used to
|
|
/// keep the transformation idempotent.
|
|
/// </summary>
|
|
internal const string Marker = "<!-- jray-overlay -->";
|
|
|
|
/// <summary>
|
|
/// Repository manifest an admin adds to install the dependency. Surfaced on
|
|
/// the configuration page rather than only in the log, since that is where
|
|
/// it can be acted on.
|
|
/// </summary>
|
|
public const string ManifestUrl = "https://www.iamparadox.dev/jellyfin/plugins/manifest.json";
|
|
|
|
private const string PluginInterfaceTypeName = "Jellyfin.Plugin.FileTransformation.PluginInterface";
|
|
private const string RegisterMethodName = "RegisterTransformation";
|
|
private const string ScriptTag = "<script defer src=\"/Plugins/JRay/ClientScript\"></script>";
|
|
private const string BodyClose = "</body>";
|
|
|
|
/// <summary>
|
|
/// Stable id for JRay's index.html transformation. File Transformation
|
|
/// keys registrations on this, so re-registering replaces rather than
|
|
/// duplicates.
|
|
/// </summary>
|
|
private static readonly Guid TransformationId = Guid.Parse("2c9b5a41-6ad0-4c1e-9f7d-1d1e6b0d5a90");
|
|
|
|
/// <summary>
|
|
/// Attempts to register JRay's index.html transformation with the File
|
|
/// Transformation plugin.
|
|
/// </summary>
|
|
/// <param name="logger">The logger.</param>
|
|
/// <returns><see langword="true"/> if the transformation was registered;
|
|
/// <see langword="false"/> if the File Transformation plugin is not
|
|
/// installed or its interface could not be invoked.</returns>
|
|
public static bool TryRegister(ILogger logger)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(logger);
|
|
|
|
try
|
|
{
|
|
var registerMethod = ResolveRegisterMethod();
|
|
if (registerMethod is null)
|
|
{
|
|
logger.LogWarning(
|
|
"JRay: the File Transformation plugin was not found, so the pause overlay is "
|
|
+ "disabled. JRay does not modify index.html on disk and has no fallback. "
|
|
+ "Install it from {ManifestUrl} to enable the overlay; every other JRay "
|
|
+ "feature is unaffected.",
|
|
ManifestUrl);
|
|
return false;
|
|
}
|
|
|
|
var payload = BuildPayload(registerMethod);
|
|
registerMethod.Invoke(null, [payload]);
|
|
|
|
logger.LogInformation("JRay: registered index.html transformation with the File Transformation plugin.");
|
|
return true;
|
|
}
|
|
catch (Exception ex) when (ex is TargetInvocationException or InvalidOperationException or JsonException or MissingMethodException)
|
|
{
|
|
logger.LogWarning(
|
|
ex,
|
|
"JRay: the File Transformation plugin is present but registration failed, so the "
|
|
+ "pause overlay is disabled. JRay does not modify index.html on disk and has no "
|
|
+ "fallback. Every other JRay feature is unaffected.");
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// The transformation callback invoked by the File Transformation plugin.
|
|
/// It is resolved by name via reflection, so the signature (public,
|
|
/// static, single payload parameter, returns <see cref="string"/>) must
|
|
/// not change.
|
|
/// </summary>
|
|
/// <param name="payload">The current state of the file being served.</param>
|
|
/// <returns>The transformed file contents.</returns>
|
|
public static string TransformIndexHtml(TransformationPayload payload)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(payload);
|
|
|
|
var contents = payload.Contents ?? string.Empty;
|
|
if (!Plugin.OverlayEnabled || contents.Contains(Marker, StringComparison.Ordinal))
|
|
{
|
|
return contents;
|
|
}
|
|
|
|
var index = contents.LastIndexOf(BodyClose, StringComparison.OrdinalIgnoreCase);
|
|
if (index < 0)
|
|
{
|
|
return contents;
|
|
}
|
|
|
|
return contents[..index]
|
|
+ ScriptTag
|
|
+ Marker
|
|
+ "\n"
|
|
+ contents[index..];
|
|
}
|
|
|
|
private static MethodInfo? ResolveRegisterMethod()
|
|
{
|
|
var assembly = AssemblyLoadContext.All
|
|
.SelectMany(context => context.Assemblies)
|
|
.FirstOrDefault(candidate => candidate.FullName?.Contains(".FileTransformation", StringComparison.Ordinal) ?? false);
|
|
|
|
return assembly?
|
|
.GetType(PluginInterfaceTypeName)?
|
|
.GetMethod(RegisterMethodName, BindingFlags.Public | BindingFlags.Static);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Builds the registration payload. File Transformation expects a
|
|
/// Newtonsoft <c>JObject</c>, which JRay does not reference, so the
|
|
/// payload is serialized to JSON and parsed back through the type the
|
|
/// target method actually declares.
|
|
/// </summary>
|
|
private static object BuildPayload(MethodInfo registerMethod)
|
|
{
|
|
// File Transformation matches this against Assembly.FullName exactly,
|
|
// so it must be the full display name, not the short name.
|
|
var assemblyName = typeof(FileTransformationRegistration).Assembly.FullName
|
|
?? throw new InvalidOperationException("JRay assembly has no name.");
|
|
|
|
var json = JsonSerializer.Serialize(new
|
|
{
|
|
id = TransformationId.ToString("D", CultureInfo.InvariantCulture),
|
|
fileNamePattern = "index.html",
|
|
callbackAssembly = assemblyName,
|
|
callbackClass = typeof(FileTransformationRegistration).FullName,
|
|
callbackMethod = nameof(TransformIndexHtml)
|
|
});
|
|
|
|
var payloadType = registerMethod.GetParameters().FirstOrDefault()?.ParameterType
|
|
?? throw new InvalidOperationException("RegisterTransformation has no parameters.");
|
|
|
|
var parseMethod = payloadType.GetMethod("Parse", BindingFlags.Public | BindingFlags.Static, [typeof(string)])
|
|
?? throw new InvalidOperationException($"Cannot construct File Transformation payload of type '{payloadType.FullName}'.");
|
|
|
|
return parseMethod.Invoke(null, [json])
|
|
?? throw new InvalidOperationException("File Transformation payload parsed to null.");
|
|
}
|
|
}
|