JR-021, JR-022, JR-023: File Transformation is a hard dependency
🏗️ Build Plugin / build (push) Successful in 59s
Latest Release / latest-release (push) Successful in 30s
🧪 Test Plugin / test (push) Successful in 22s

Deletes the on-disk index.html injection rather than leaving it switched off.
Plugin.cs had already stopped calling it, but an unreachable write path with a
live signature is the one a later refactor re-enables by accident, and it was
still the behaviour the README and the release changelog advertised.

Patching index.html on disk is destructive in ways a plugin cannot clean up
after: the patch outlives an uninstall, a web-client upgrade discards it
silently, and it races any other plugin touching the same file. It is also a
second code path, and the one nobody runs is the one that rots.

WebClientPatchService is now removal-only. The strip is factored out as
RemoveInjection so it is testable without a filesystem. Removal is the one
write JR-021 permits -- an earlier JRay did patch the file, and those users
must not be left with a stale injection pointing at endpoints that have since
changed. It keys on JRay's own marker, so it touches nothing another plugin
added.

JR-021 is a requirement to *not do* something, which no unit test can
demonstrate, so scripts/checks/no-index-injection.sh verifies it by absence.
The check was confirmed to fail on a reintroduced Apply() and on reintroduced
ReplaceLast injection -- a check that has only ever passed is not evidence.

Jellyfin has no plugin dependency mechanism, so nothing installs File
Transformation for the user and a log warning alone is one nobody reads.
GET /Plugins/JRay/Status/Dependencies reports whether the dependency is
satisfied, and the configuration page renders it with the repository URL and
what to do with it. Absent the plugin only the overlay is disabled; every
other feature works.

JR-022 and JR-023 stay In Progress rather than Done: neither has a test that
executes, and this repo has no test project yet.

TRACES: JR-021, JR-022, JR-023 | PR-004

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-30 18:58:12 +02:00
co-authored by Claude Opus 5
parent 3b24fe1b3c
commit d9a38bb7fb
9 changed files with 425 additions and 36 deletions
@@ -0,0 +1,165 @@
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 mechanism JR-021 requires, but it does not by itself satisfy
/// JR-021 — that requirement is a prohibition, and it stays unmet while
/// <see cref="WebClientPatchService"/> can still write to disk.
/// </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.LogInformation("JRay: File Transformation plugin not found; falling back to patching index.html on disk.");
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: failed to register with the File Transformation plugin; falling back to patching index.html on disk.");
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.");
}
}