first commit
This commit is contained in:
@@ -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
|
||||
/// "<media file basename><TruthFileSuffix>" next to the media file,
|
||||
/// e.g. "Movie.mkv" -> "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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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)..];
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
})();
|
||||
Reference in New Issue
Block a user