first commit
🏗️ Build Plugin / build (push) Successful in 1m13s
🧪 Test Plugin / test (push) Successful in 20s
Latest Release / latest-release (push) Successful in 25s

This commit is contained in:
2026-06-12 18:16:47 +02:00
parent 7a9dbdafcc
commit a38122e993
39 changed files with 1522 additions and 295 deletions
@@ -0,0 +1,40 @@
using MediaBrowser.Model.Plugins;
namespace Jellyfin.Plugin.JRay.Configuration;
/// <summary>
/// Plugin configuration.
/// </summary>
public class PluginConfiguration : BasePluginConfiguration
{
/// <summary>
/// Initializes a new instance of the <see cref="PluginConfiguration"/> class.
/// </summary>
public PluginConfiguration()
{
TruthFileSuffix = ".jray.json";
CacheDurationMinutes = 60;
EnableOverlay = true;
}
/// <summary>
/// Gets or sets the filename suffix used to find a scene-actor-extraction
/// "truth" file for a media item. The plugin looks for a file named
/// "&lt;media file basename&gt;&lt;TruthFileSuffix&gt;" next to the media file,
/// e.g. "Movie.mkv" -&gt; "Movie.jray.json".
/// </summary>
public string TruthFileSuffix { get; set; }
/// <summary>
/// Gets or sets how long (in minutes) a loaded truth file is cached in
/// memory before being re-read from disk.
/// </summary>
public int CacheDurationMinutes { get; set; }
/// <summary>
/// Gets or sets a value indicating whether JRay should inject its
/// pause-overlay script into the web client's index.html. When disabled,
/// any previously injected script is removed.
/// </summary>
public bool EnableOverlay { get; set; }
}
@@ -0,0 +1,78 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>JRay</title>
</head>
<body>
<div id="JRayConfigPage" data-role="page" class="page type-interior pluginConfigurationPage" data-require="emby-input,emby-button,emby-select,emby-checkbox">
<div data-role="content">
<div class="content-primary">
<form id="JRayConfigForm">
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="TruthFileSuffix">Truth file suffix</label>
<input id="TruthFileSuffix" name="TruthFileSuffix" type="text" is="emby-input" />
<div class="fieldDescription">
Filename suffix used to find the scene-actor-extraction output for a media
file, e.g. "Movie.mkv" with suffix ".jray.json" looks for "Movie.jray.json"
in the same folder.
</div>
</div>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="CacheDurationMinutes">Cache duration (minutes)</label>
<input id="CacheDurationMinutes" name="CacheDurationMinutes" type="number" is="emby-input" min="0" />
<div class="fieldDescription">How long a loaded truth file is cached before being re-read from disk.</div>
</div>
<div class="checkboxContainer checkboxContainer-withDescription">
<label>
<input id="EnableOverlay" name="EnableOverlay" type="checkbox" is="emby-checkbox" />
<span>Enable pause overlay</span>
</label>
<div class="fieldDescription">
Injects a small script into the web client that shows on-screen actors
when playback is paused. Disabling this removes the injected script.
</div>
</div>
<div>
<button is="emby-button" type="submit" class="raised button-submit block emby-button">
<span>Save</span>
</button>
</div>
</form>
</div>
</div>
<script type="text/javascript">
var JRayConfig = {
pluginUniqueId: '96a22d9d-23fd-49bb-8970-5e153817d223'
};
document.querySelector('#JRayConfigPage')
.addEventListener('pageshow', function() {
Dashboard.showLoadingMsg();
ApiClient.getPluginConfiguration(JRayConfig.pluginUniqueId).then(function (config) {
document.querySelector('#TruthFileSuffix').value = config.TruthFileSuffix;
document.querySelector('#CacheDurationMinutes').value = config.CacheDurationMinutes;
document.querySelector('#EnableOverlay').checked = config.EnableOverlay;
Dashboard.hideLoadingMsg();
});
});
document.querySelector('#JRayConfigForm')
.addEventListener('submit', function(e) {
Dashboard.showLoadingMsg();
ApiClient.getPluginConfiguration(JRayConfig.pluginUniqueId).then(function (config) {
config.TruthFileSuffix = document.querySelector('#TruthFileSuffix').value;
config.CacheDurationMinutes = parseInt(document.querySelector('#CacheDurationMinutes').value, 10);
config.EnableOverlay = document.querySelector('#EnableOverlay').checked;
ApiClient.updatePluginConfiguration(JRayConfig.pluginUniqueId, config).then(function (result) {
Dashboard.processPluginConfigurationUpdateResult(result);
});
});
e.preventDefault();
return false;
});
</script>
</div>
</body>
</html>
@@ -0,0 +1,87 @@
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.JRay.Models;
using Jellyfin.Plugin.JRay.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
namespace Jellyfin.Plugin.JRay.Controllers;
/// <summary>
/// Exposes scene-actor-extraction "truth" data: which actors are on screen
/// at a given timestamp in a movie.
/// </summary>
[ApiController]
[Route("Plugins/JRay/Items/{itemId}")]
[Authorize]
public class ActorsController : ControllerBase
{
private readonly ITruthDataService _truthDataService;
/// <summary>
/// Initializes a new instance of the <see cref="ActorsController"/> class.
/// </summary>
/// <param name="truthDataService">The truth data service.</param>
public ActorsController(ITruthDataService truthDataService)
{
_truthDataService = truthDataService;
}
/// <summary>
/// Gets the full actor timeline (every actor with their on-screen scene windows) for a movie.
/// </summary>
/// <param name="itemId">The Jellyfin item id.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The truth file contents, or 404 if no truth data exists for this item.</returns>
[HttpGet("Timeline")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<TruthFile>> GetTimeline(Guid itemId, CancellationToken cancellationToken)
{
var truth = await _truthDataService.GetTruthAsync(itemId, cancellationToken).ConfigureAwait(false);
if (truth is null)
{
return NotFound();
}
return Ok(truth);
}
/// <summary>
/// Gets the JRay context (currently: on-screen actors) at a given timestamp.
/// This is an extensible envelope — future fields (locations, trivia, etc.)
/// will be added here without changing the route.
/// </summary>
/// <param name="itemId">The Jellyfin item id.</param>
/// <param name="t">The timestamp, in seconds from the start of the movie.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The JRay context at <paramref name="t"/>, or 404 if no truth data exists for this item.</returns>
[HttpGet("jray")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<JRayContext>> GetContext(Guid itemId, [FromQuery] double t, CancellationToken cancellationToken)
{
var truth = await _truthDataService.GetTruthAsync(itemId, cancellationToken).ConfigureAwait(false);
if (truth is null)
{
return NotFound();
}
var context = new JRayContext();
foreach (var actor in truth.Actors.Where(actor => actor.Scenes.Any(scene => scene.Length == 2 && scene[0] <= t && t <= scene[1])))
{
context.Actors.Add(new ActorAtTime
{
Name = actor.Name,
ImdbId = actor.ImdbId,
TmdbId = actor.TmdbId,
JellyfinId = actor.JellyfinId
});
}
return Ok(context);
}
}
@@ -0,0 +1,76 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.JRay.Models;
using Jellyfin.Plugin.JRay.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
namespace Jellyfin.Plugin.JRay.Controllers;
/// <summary>
/// Accepts scene-actor-extraction "truth" data pushed directly by a remote
/// extraction worker, for servers that cannot run the extraction pipeline
/// locally. See SPEC.md.
/// </summary>
[ApiController]
[Route("Plugins/JRay/Items/{itemId}/Truth")]
[Authorize(Roles = "Administrator")]
public class TruthController : ControllerBase
{
private const int SupportedSchemaVersion = 1;
private readonly IManagedTruthStore _managedTruthStore;
private readonly ITruthDataService _truthDataService;
/// <summary>
/// Initializes a new instance of the <see cref="TruthController"/> class.
/// </summary>
/// <param name="managedTruthStore">The managed truth store.</param>
/// <param name="truthDataService">The truth data service.</param>
public TruthController(IManagedTruthStore managedTruthStore, ITruthDataService truthDataService)
{
_managedTruthStore = managedTruthStore;
_truthDataService = truthDataService;
}
/// <summary>
/// Uploads (creates or replaces) the truth data for an item.
/// </summary>
/// <param name="itemId">The Jellyfin item id.</param>
/// <param name="truth">The truth file contents.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>204 on success, or 400 if the schema version is unsupported.</returns>
[HttpPut]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> PutTruth(Guid itemId, [FromBody] TruthFile truth, CancellationToken cancellationToken)
{
if (truth.SchemaVersion != SupportedSchemaVersion)
{
return BadRequest($"Unsupported schema_version {truth.SchemaVersion}; expected {SupportedSchemaVersion}.");
}
await _managedTruthStore.SaveAsync(itemId, truth, cancellationToken).ConfigureAwait(false);
_truthDataService.Invalidate(itemId);
return NoContent();
}
/// <summary>
/// Removes any managed truth data for an item. The item falls back to
/// its sidecar truth file (if any) on subsequent reads.
/// </summary>
/// <param name="itemId">The Jellyfin item id.</param>
/// <returns>204, whether or not managed data existed.</returns>
[HttpDelete]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public IActionResult DeleteTruth(Guid itemId)
{
_managedTruthStore.Delete(itemId);
_truthDataService.Invalidate(itemId);
return NoContent();
}
}
@@ -0,0 +1,35 @@
using System;
using System.IO;
using System.Reflection;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
namespace Jellyfin.Plugin.JRay.Controllers;
/// <summary>
/// Serves static client-side assets for JRay, e.g. the pause-overlay script
/// injected into the web client.
/// </summary>
[ApiController]
[Route("Plugins/JRay")]
[AllowAnonymous]
public class WebController : ControllerBase
{
private const string OverlayScriptResource = "Jellyfin.Plugin.JRay.Web.jray-overlay.js";
/// <summary>
/// Gets the pause-overlay client script.
/// </summary>
/// <returns>The JavaScript source for the pause overlay.</returns>
[HttpGet("ClientScript")]
[ProducesResponseType(StatusCodes.Status200OK)]
public IActionResult GetClientScript()
{
var assembly = Assembly.GetExecutingAssembly();
var stream = assembly.GetManifestResourceStream(OverlayScriptResource)
?? throw new InvalidOperationException($"Embedded resource '{OverlayScriptResource}' not found.");
return File(stream, "application/javascript");
}
}
@@ -0,0 +1,35 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<RootNamespace>Jellyfin.Plugin.JRay</RootNamespace>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<Nullable>enable</Nullable>
<AnalysisMode>AllEnabledByDefault</AnalysisMode>
<CodeAnalysisRuleSet>../jellyfin.ruleset</CodeAnalysisRuleSet>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Jellyfin.Controller" Version="10.9.11" >
<ExcludeAssets>runtime</ExcludeAssets>
</PackageReference>
<PackageReference Include="Jellyfin.Model" Version="10.9.11">
<ExcludeAssets>runtime</ExcludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" />
<PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.556" PrivateAssets="All" />
<PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" />
</ItemGroup>
<ItemGroup>
<None Remove="Configuration\configPage.html" />
<EmbeddedResource Include="Configuration\configPage.html" />
<None Remove="Web\jray-overlay.js" />
<EmbeddedResource Include="Web\jray-overlay.js" />
</ItemGroup>
</Project>
@@ -0,0 +1,33 @@
using System.Text.Json.Serialization;
namespace Jellyfin.Plugin.JRay.Models;
/// <summary>
/// An actor visible on screen at a queried timestamp.
/// </summary>
public class ActorAtTime
{
/// <summary>
/// Gets or sets the actor's display name.
/// </summary>
[JsonPropertyName("name")]
public string Name { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the actor's IMDB person id, or "" if unresolved.
/// </summary>
[JsonPropertyName("imdb_id")]
public string ImdbId { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the actor's TMDB person id, or "" if unresolved.
/// </summary>
[JsonPropertyName("tmdb_id")]
public string TmdbId { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the actor's Jellyfin Person item GUID, or "" if unresolved.
/// </summary>
[JsonPropertyName("jellyfin_id")]
public string JellyfinId { get; set; } = string.Empty;
}
@@ -0,0 +1,18 @@
using System.Collections.ObjectModel;
using System.Text.Json.Serialization;
namespace Jellyfin.Plugin.JRay.Models;
/// <summary>
/// Extensible "what's happening at time t" context for a media item.
/// Returned by the <c>jray?t=</c> endpoint; new fields (e.g. locations,
/// trivia) can be added here without changing the route.
/// </summary>
public class JRayContext
{
/// <summary>
/// Gets the list of actors visible on screen at the queried timestamp.
/// </summary>
[JsonPropertyName("actors")]
public Collection<ActorAtTime> Actors { get; } = new();
}
+41
View File
@@ -0,0 +1,41 @@
using System.Collections.ObjectModel;
using System.Text.Json.Serialization;
namespace Jellyfin.Plugin.JRay.Models;
/// <summary>
/// One actor entry in a <see cref="TruthFile"/>, with the time windows during
/// which they are visible on screen.
/// </summary>
public class TruthActor
{
/// <summary>
/// Gets or sets the actor's display name.
/// </summary>
[JsonPropertyName("name")]
public string Name { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the actor's IMDB person id (e.g. "nm0000158"), or "" if unresolved.
/// </summary>
[JsonPropertyName("imdb_id")]
public string ImdbId { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the actor's TMDB person id, or "" if unresolved.
/// </summary>
[JsonPropertyName("tmdb_id")]
public string TmdbId { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the actor's Jellyfin Person item GUID, or "" if unresolved.
/// </summary>
[JsonPropertyName("jellyfin_id")]
public string JellyfinId { get; set; } = string.Empty;
/// <summary>
/// Gets the list of [start_sec, end_sec] windows during which the actor is on screen.
/// </summary>
[JsonPropertyName("scenes")]
public Collection<double[]> Scenes { get; } = new();
}
+41
View File
@@ -0,0 +1,41 @@
using System.Collections.ObjectModel;
using System.Text.Json.Serialization;
namespace Jellyfin.Plugin.JRay.Models;
/// <summary>
/// Root object of a scene-actor-extraction "truth" file
/// (schema_version 1, minimal verbosity). See SPEC.md.
/// </summary>
public class TruthFile
{
/// <summary>
/// Gets or sets the schema version of this file.
/// </summary>
[JsonPropertyName("schema_version")]
public int SchemaVersion { get; set; }
/// <summary>
/// Gets or sets the source media path at extraction time (informational).
/// </summary>
[JsonPropertyName("movie")]
public string Movie { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the sampling rate (frames per second) used during extraction.
/// </summary>
[JsonPropertyName("sample_fps")]
public double SampleFps { get; set; }
/// <summary>
/// Gets or sets the gap (seconds) below which consecutive detections were merged into one scene.
/// </summary>
[JsonPropertyName("anneal_sec")]
public double AnnealSec { get; set; }
/// <summary>
/// Gets the list of actors detected in the film, each with their on-screen scene windows.
/// </summary>
[JsonPropertyName("actors")]
public Collection<TruthActor> Actors { get; } = new();
}
+60
View File
@@ -0,0 +1,60 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using Jellyfin.Plugin.JRay.Configuration;
using Jellyfin.Plugin.JRay.Services;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Plugins;
using MediaBrowser.Model.Plugins;
using MediaBrowser.Model.Serialization;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.JRay;
/// <summary>
/// The main plugin.
/// </summary>
public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
{
private readonly ILogger<Plugin> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="Plugin"/> class.
/// </summary>
/// <param name="applicationPaths">Instance of the <see cref="IApplicationPaths"/> interface.</param>
/// <param name="xmlSerializer">Instance of the <see cref="IXmlSerializer"/> interface.</param>
/// <param name="logger">The logger.</param>
public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer, ILogger<Plugin> logger)
: base(applicationPaths, xmlSerializer)
{
Instance = this;
_logger = logger;
WebClientPatchService.Apply(ApplicationPaths, Configuration.EnableOverlay, _logger);
ConfigurationChanged += (_, _) => WebClientPatchService.Apply(ApplicationPaths, Configuration.EnableOverlay, _logger);
}
/// <inheritdoc />
public override string Name => "JRay";
/// <inheritdoc />
public override Guid Id => Guid.Parse("96a22d9d-23fd-49bb-8970-5e153817d223");
/// <summary>
/// Gets the current plugin instance.
/// </summary>
public static Plugin? Instance { get; private set; }
/// <inheritdoc />
public IEnumerable<PluginPageInfo> GetPages()
{
return
[
new PluginPageInfo
{
Name = Name,
EmbeddedResourcePath = string.Format(CultureInfo.InvariantCulture, "{0}.Configuration.configPage.html", GetType().Namespace)
}
];
}
}
@@ -0,0 +1,20 @@
using Jellyfin.Plugin.JRay.Services;
using Jellyfin.Plugin.JRay.Services.Interfaces;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Plugins;
using Microsoft.Extensions.DependencyInjection;
namespace Jellyfin.Plugin.JRay;
/// <summary>
/// Service registrator for dependency injection.
/// </summary>
public class ServiceRegistrator : IPluginServiceRegistrator
{
/// <inheritdoc />
public void RegisterServices(IServiceCollection serviceCollection, IServerApplicationHost applicationHost)
{
serviceCollection.AddSingleton<IManagedTruthStore, ManagedTruthStore>();
serviceCollection.AddSingleton<ITruthDataService, TruthDataService>();
}
}
@@ -0,0 +1,38 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.JRay.Models;
namespace Jellyfin.Plugin.JRay.Services.Interfaces;
/// <summary>
/// Stores and retrieves truth files that were pushed to JRay directly
/// (e.g. by a remote extraction worker), independent of any sidecar file
/// on the media filesystem.
/// </summary>
public interface IManagedTruthStore
{
/// <summary>
/// Loads the managed truth file for the given item, if one was uploaded.
/// </summary>
/// <param name="itemId">The Jellyfin library item id.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The parsed truth file, or null if none has been uploaded for this item.</returns>
Task<TruthFile?> LoadAsync(Guid itemId, CancellationToken cancellationToken);
/// <summary>
/// Saves (creates or replaces) the managed truth file for the given item.
/// </summary>
/// <param name="itemId">The Jellyfin library item id.</param>
/// <param name="truth">The truth file contents to persist.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A task that completes when the file has been written.</returns>
Task SaveAsync(Guid itemId, TruthFile truth, CancellationToken cancellationToken);
/// <summary>
/// Deletes the managed truth file for the given item, if one exists.
/// </summary>
/// <param name="itemId">The Jellyfin library item id.</param>
/// <returns><c>true</c> if a file was deleted; <c>false</c> if none existed.</returns>
bool Delete(Guid itemId);
}
@@ -0,0 +1,27 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.JRay.Models;
namespace Jellyfin.Plugin.JRay.Services.Interfaces;
/// <summary>
/// Loads and caches scene-actor-extraction "truth" files for library items.
/// </summary>
public interface ITruthDataService
{
/// <summary>
/// Gets the truth file for the given library item, if one exists.
/// </summary>
/// <param name="itemId">The Jellyfin library item id.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The parsed truth file, or null if no truth file exists for this item.</returns>
Task<TruthFile?> GetTruthAsync(Guid itemId, CancellationToken cancellationToken);
/// <summary>
/// Removes any cached truth file for the given item, so the next
/// <see cref="GetTruthAsync"/> call re-reads from the managed store or sidecar file.
/// </summary>
/// <param name="itemId">The Jellyfin library item id.</param>
void Invalidate(Guid itemId);
}
@@ -0,0 +1,90 @@
using System;
using System.IO;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.JRay.Models;
using Jellyfin.Plugin.JRay.Services.Interfaces;
using MediaBrowser.Common.Configuration;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.JRay.Services;
/// <summary>
/// Stores truth files pushed directly to JRay (e.g. by a remote extraction
/// worker) under the plugin's configuration directory, keyed by item id.
/// </summary>
public sealed class ManagedTruthStore : IManagedTruthStore
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
private readonly IApplicationPaths _applicationPaths;
private readonly ILogger<ManagedTruthStore> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="ManagedTruthStore"/> class.
/// </summary>
/// <param name="applicationPaths">The Jellyfin application paths.</param>
/// <param name="logger">The logger.</param>
public ManagedTruthStore(IApplicationPaths applicationPaths, ILogger<ManagedTruthStore> logger)
{
_applicationPaths = applicationPaths;
_logger = logger;
}
/// <inheritdoc />
public async Task<TruthFile?> LoadAsync(Guid itemId, CancellationToken cancellationToken)
{
var path = GetPath(itemId);
if (!File.Exists(path))
{
return null;
}
try
{
using var stream = File.OpenRead(path);
return await JsonSerializer.DeserializeAsync<TruthFile>(stream, JsonOptions, cancellationToken)
.ConfigureAwait(false);
}
catch (Exception ex) when (ex is IOException or JsonException)
{
_logger.LogWarning(ex, "JRay: failed to read managed truth file {Path}", path);
return null;
}
}
/// <inheritdoc />
public async Task SaveAsync(Guid itemId, TruthFile truth, CancellationToken cancellationToken)
{
var path = GetPath(itemId);
var directory = Path.GetDirectoryName(path) ?? throw new InvalidOperationException("Managed truth path has no directory.");
Directory.CreateDirectory(directory);
var tempPath = path + ".tmp";
using (var stream = File.Create(tempPath))
{
await JsonSerializer.SerializeAsync(stream, truth, JsonOptions, cancellationToken).ConfigureAwait(false);
}
File.Move(tempPath, path, overwrite: true);
}
/// <inheritdoc />
public bool Delete(Guid itemId)
{
var path = GetPath(itemId);
if (!File.Exists(path))
{
return false;
}
File.Delete(path);
return true;
}
private string GetPath(Guid itemId)
{
return Path.Combine(_applicationPaths.PluginConfigurationsPath, "JRay", "truth", itemId.ToString("D") + ".json");
}
}
@@ -0,0 +1,99 @@
using System;
using System.Collections.Concurrent;
using System.IO;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.JRay.Models;
using Jellyfin.Plugin.JRay.Services.Interfaces;
using MediaBrowser.Controller.Library;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.JRay.Services;
/// <summary>
/// Loads scene-actor-extraction truth files from disk, alongside each media
/// item's source file, and caches the parsed result for a configurable
/// duration.
/// </summary>
public sealed class TruthDataService : ITruthDataService
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
private readonly ILibraryManager _libraryManager;
private readonly IManagedTruthStore _managedTruthStore;
private readonly ILogger<TruthDataService> _logger;
private readonly ConcurrentDictionary<Guid, CacheEntry> _cache = new();
/// <summary>
/// Initializes a new instance of the <see cref="TruthDataService"/> class.
/// </summary>
/// <param name="libraryManager">The Jellyfin library manager.</param>
/// <param name="managedTruthStore">The managed truth store.</param>
/// <param name="logger">The logger.</param>
public TruthDataService(ILibraryManager libraryManager, IManagedTruthStore managedTruthStore, ILogger<TruthDataService> logger)
{
_libraryManager = libraryManager;
_managedTruthStore = managedTruthStore;
_logger = logger;
}
/// <inheritdoc />
public async Task<TruthFile?> GetTruthAsync(Guid itemId, CancellationToken cancellationToken)
{
var cacheDuration = TimeSpan.FromMinutes(Math.Max(0, Plugin.Instance?.Configuration.CacheDurationMinutes ?? 0));
if (_cache.TryGetValue(itemId, out var cached) && DateTime.UtcNow - cached.LoadedAt < cacheDuration)
{
return cached.Truth;
}
var managed = await _managedTruthStore.LoadAsync(itemId, cancellationToken).ConfigureAwait(false);
if (managed is not null)
{
_cache[itemId] = new CacheEntry(managed, DateTime.UtcNow);
return managed;
}
var item = _libraryManager.GetItemById(itemId);
if (item is null || string.IsNullOrEmpty(item.Path))
{
_logger.LogDebug("JRay: item {ItemId} not found or has no path", itemId);
return null;
}
var suffix = Plugin.Instance?.Configuration.TruthFileSuffix ?? ".jray.json";
var truthPath = Path.Combine(
Path.GetDirectoryName(item.Path) ?? string.Empty,
Path.GetFileNameWithoutExtension(item.Path) + suffix);
if (!File.Exists(truthPath))
{
_logger.LogDebug("JRay: no truth file at {TruthPath}", truthPath);
_cache[itemId] = new CacheEntry(null, DateTime.UtcNow);
return null;
}
try
{
using var stream = File.OpenRead(truthPath);
var truth = await JsonSerializer.DeserializeAsync<TruthFile>(stream, JsonOptions, cancellationToken)
.ConfigureAwait(false);
_cache[itemId] = new CacheEntry(truth, DateTime.UtcNow);
return truth;
}
catch (Exception ex) when (ex is IOException or JsonException)
{
_logger.LogWarning(ex, "JRay: failed to read truth file {TruthPath}", truthPath);
return null;
}
}
/// <inheritdoc />
public void Invalidate(Guid itemId)
{
_cache.TryRemove(itemId, out _);
}
private sealed record CacheEntry(TruthFile? Truth, DateTime LoadedAt);
}
@@ -0,0 +1,72 @@
using System;
using System.IO;
using MediaBrowser.Common.Configuration;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.JRay.Services;
/// <summary>
/// Injects (or removes) a script tag in the web client's <c>index.html</c>
/// that loads JRay's pause-overlay script. This follows the pattern used by
/// other Jellyfin plugins (e.g. Intro Skipper) since there is no official
/// plugin hook for player-overlay UI.
/// </summary>
public static class WebClientPatchService
{
private const string Marker = "<!-- jray-overlay -->";
private const string ScriptTag = "<script defer src=\"/Plugins/JRay/ClientScript\"></script>";
private const string Injected = ScriptTag + Marker + "\n</body>";
/// <summary>
/// Ensures the web client's index.html either has or does not have the
/// JRay overlay script injected, matching <paramref name="enableOverlay"/>.
/// </summary>
/// <param name="applicationPaths">The Jellyfin application paths.</param>
/// <param name="enableOverlay">Whether the overlay script should be present.</param>
/// <param name="logger">The logger.</param>
public static void Apply(IApplicationPaths applicationPaths, bool enableOverlay, ILogger logger)
{
var indexPath = Path.Combine(applicationPaths.WebPath, "index.html");
try
{
if (!File.Exists(indexPath))
{
logger.LogDebug("JRay: web client index.html not found at {Path}", indexPath);
return;
}
var html = File.ReadAllText(indexPath);
var hasMarker = html.Contains(Marker, StringComparison.Ordinal);
if (enableOverlay && !hasMarker)
{
var patched = ReplaceLast(html, "</body>", Injected);
File.WriteAllText(indexPath, patched);
logger.LogInformation("JRay: injected pause-overlay script into {Path}", indexPath);
}
else if (!enableOverlay && hasMarker)
{
var patched = html.Replace(ScriptTag + Marker + "\n", string.Empty, StringComparison.Ordinal)
.Replace(ScriptTag + Marker, string.Empty, StringComparison.Ordinal);
File.WriteAllText(indexPath, patched);
logger.LogInformation("JRay: removed pause-overlay script from {Path}", indexPath);
}
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
logger.LogWarning(ex, "JRay: failed to patch web client index.html at {Path}", indexPath);
}
}
private static string ReplaceLast(string source, string find, string replace)
{
var index = source.LastIndexOf(find, StringComparison.Ordinal);
if (index < 0)
{
return source;
}
return source[..index] + replace + source[(index + find.Length)..];
}
}
+109
View File
@@ -0,0 +1,109 @@
(function () {
'use strict';
var POLL_INTERVAL_MS = 1000;
var overlayEl = null;
function getItemIdFromHash() {
var match = window.location.hash.match(/[?&]id=([0-9a-fA-F-]+)/);
return match ? match[1] : null;
}
function removeOverlay() {
if (overlayEl && overlayEl.parentNode) {
overlayEl.parentNode.removeChild(overlayEl);
}
overlayEl = null;
}
function showOverlay(video, actors) {
removeOverlay();
if (!actors || actors.length === 0) {
return;
}
var container = video.parentElement;
if (!container) {
return;
}
overlayEl = document.createElement('div');
overlayEl.className = 'jrayOverlay';
overlayEl.style.position = 'absolute';
overlayEl.style.bottom = '10%';
overlayEl.style.left = '2%';
overlayEl.style.zIndex = '9999';
overlayEl.style.display = 'flex';
overlayEl.style.flexWrap = 'wrap';
overlayEl.style.gap = '12px';
overlayEl.style.pointerEvents = 'none';
actors.forEach(function (actor) {
var card = document.createElement('div');
card.style.background = 'rgba(0, 0, 0, 0.7)';
card.style.color = '#fff';
card.style.padding = '6px 12px';
card.style.borderRadius = '4px';
card.style.fontSize = '14px';
card.textContent = actor.name;
overlayEl.appendChild(card);
});
container.appendChild(overlayEl);
}
function fetchContext(itemId, t) {
if (!window.ApiClient) {
return;
}
var url = window.ApiClient.getUrl('Plugins/JRay/Items/' + itemId + '/jray', { t: t });
window.ApiClient.ajax({ url: url, type: 'GET', dataType: 'json' }).then(function (context) {
var video = document.querySelector('video');
if (!video || !video.paused) {
return;
}
showOverlay(video, context.actors);
}, function () {
// No truth data (404) or request error - fail silently, never break playback.
});
}
function onPause(event) {
var video = event.target;
var itemId = getItemIdFromHash();
if (!itemId) {
return;
}
fetchContext(itemId, video.currentTime);
}
function onPlay() {
removeOverlay();
}
function attach(video) {
if (video.dataset.jrayAttached) {
return;
}
video.dataset.jrayAttached = 'true';
video.addEventListener('pause', onPause);
video.addEventListener('play', onPlay);
video.addEventListener('playing', onPlay);
video.addEventListener('seeking', removeOverlay);
}
setInterval(function () {
var video = document.querySelector('video');
if (video) {
attach(video);
} else {
removeOverlay();
}
}, POLL_INTERVAL_MS);
})();