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; /// /// Registers JRay's overlay script with the /// File Transformation /// plugin, which rewrites index.html 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. /// /// /// 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 /// AssemblyLoadContext 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 /// can still write to disk. /// // TRACES: JR-020, JR-023 | PR-004 public static class FileTransformationRegistration { /// /// The marker comment written alongside the injected script tag, used to /// keep the transformation idempotent. /// internal const string Marker = ""; /// /// 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. /// 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 = ""; private const string BodyClose = ""; /// /// Stable id for JRay's index.html transformation. File Transformation /// keys registrations on this, so re-registering replaces rather than /// duplicates. /// private static readonly Guid TransformationId = Guid.Parse("2c9b5a41-6ad0-4c1e-9f7d-1d1e6b0d5a90"); /// /// Attempts to register JRay's index.html transformation with the File /// Transformation plugin. /// /// The logger. /// if the transformation was registered; /// if the File Transformation plugin is not /// installed or its interface could not be invoked. 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; } } /// /// 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 ) must /// not change. /// /// The current state of the file being served. /// The transformed file contents. 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); } /// /// Builds the registration payload. File Transformation expects a /// Newtonsoft JObject, which JRay does not reference, so the /// payload is serialized to JSON and parsed back through the type the /// target method actually declares. /// 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."); } }